Utils/numfmt.ps1

Register-GnuUtil @{
    Name                   = 'numfmt'
    Category               = 'Text processing'
    Synopsis               = 'Convert numbers from/to human-readable strings.'
    Usage                  = @('numfmt [OPTION]... [NUMBER]...')
    Description            = @('Reformat NUMBER(s), or the numbers from standard input if none are specified.')
    NegativeNumberOperands = $true
    Options                = @(
        @{ Long = 'debug'; Help = 'print warnings about invalid input' }
        @{ Short = 'd'; Long = 'delimiter'; Arg = 'X'; Help = 'use X instead of whitespace for field delimiter' }
        @{ Long = 'field'; Arg = 'FIELDS'; Help = 'replace the numbers in these input fields (default=1) see FIELDS below' }
        @{ Long = 'format'; Arg = 'FORMAT'; Help = 'use printf style floating-point FORMAT; see FORMAT below for details' }
        @{ Long = 'from'; Arg = 'UNIT'; Help = 'auto-scale input numbers to UNITs; default is ''none''; see UNIT below' }
        @{ Long = 'from-unit'; Arg = 'N'; Help = 'specify the input unit size (instead of the default 1)' }
        @{ Long = 'grouping'; Help = 'use locale-defined grouping of digits, e.g. 1,000,000 (which means it has no effect in the C/POSIX locale)' }
        @{ Long = 'header'; Arg = 'N'; OptionalArg = $true; Help = 'print (without converting) the first N header lines; N defaults to 1 if not specified' }
        @{ Long = 'invalid'; Arg = 'MODE'; Help = 'failure mode for invalid numbers: MODE can be: abort (default), fail, warn, ignore' }
        @{ Long = 'padding'; Arg = 'N'; Help = 'pad the output to N characters; positive N will right-align; negative N will left-align; padding is ignored if the output is wider than N; the default is to automatically pad if a whitespace is found' }
        @{ Long = 'round'; Arg = 'METHOD'; Help = 'use METHOD for rounding when scaling; METHOD can be: up, down, from-zero (default), towards-zero, nearest' }
        @{ Long = 'suffix'; Arg = 'SUFFIX'; Help = 'add SUFFIX to output numbers, and accept optional SUFFIX in input numbers' }
        @{ Long = 'to'; Arg = 'UNIT'; Help = 'auto-scale output numbers to UNITs; see UNIT below' }
        @{ Long = 'to-unit'; Arg = 'N'; Help = 'the output unit size (instead of the default 1)' }
        @{ Short = 'z'; Long = 'zero-terminated'; Help = 'line delimiter is NUL, not newline' }
    )
    Notes                  = @(
        'UNIT options:',
        ' none no auto-scaling is done; suffixes will trigger an error',
        ' auto accept optional single/two letter suffix:',
        ' 1K = 1000,',
        ' 1Ki = 1024,',
        ' 1M = 1000000,',
        ' 1Mi = 1048576,',
        ' si accept optional single letter suffix:',
        ' 1K = 1000,',
        ' 1M = 1000000,',
        ' ...',
        ' iec accept optional single letter suffix:',
        ' 1K = 1024,',
        ' 1M = 1048576,',
        ' ...',
        ' iec-i accept optional two-letter suffix:',
        ' 1Ki = 1024,',
        ' 1Mi = 1048576,',
        ' ...',
        '',
        'FIELDS supports cut(1) style field ranges:',
        ' N N''th field, counted from 1',
        ' N- from N''th field, to end of line',
        ' N-M from N''th to M''th field (inclusive)',
        ' -M from first to M''th field (inclusive)',
        ' - all fields',
        'Multiple fields/ranges can be separated with commas',
        '',
        'FORMAT must be suitable for printing one floating-point argument ''%f''.',
        'Optional quote (%''f) will enable --grouping (if supported by current locale).',
        'Optional width value (%10f) will pad output. Optional zero (%010f) width',
        'will zero pad the number. Optional negative values (%-10f) will left align.',
        'Optional precision (%.1f) will override the input determined precision.',
        '',
        'Exit status is 0 if all input numbers were successfully converted.',
        'By default, numfmt will stop at the first conversion error with exit status 2.',
        'With --invalid=''fail'' a warning is printed for each conversion error',
        'and the exit status is 2. With --invalid=''warn'' each conversion error is',
        'diagnosed, but the exit status is 0. With --invalid=''ignore'' conversion',
        'errors are not diagnosed and the exit status is 0.',
        '',
        'PowerShell note: numbers are [decimal] (GNU uses long double), so results can differ',
        'in the last digit for some inputs. --debug is accepted and ignored.'
    )
}

$script:GnuNumfmtPowers = ' KMGTPEZY'



function script:Select-GnuNumfmtChoice {
    <#
    .SYNOPSIS
    The value of an option with a fixed list of arguments, or $null after a GNU style usage error.
    #>

    param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][string]$Key, [Parameter(Mandatory)][string[]]$Valid, [Parameter(Mandatory)][string]$Default)
    if (-not (Test-GnuOption -Context $Context -Key $Key)) { return $Default }
    $value = [string](Get-GnuOptionValue -Context $Context -Key $Key)
    if ($Valid -ccontains $value) { return $value }
    Write-GnuUsageError -Context $Context -Message ("invalid argument '$value' for '--$Key'`nValid arguments are:" + (($Valid | ForEach-Object { "`n - '$_'" }) -join ''))
    $null
}

function script:Invoke-GnuNumfmtRound {
    <#
    .SYNOPSIS
    Rounds a decimal to an integer with a numfmt --round METHOD.
    #>

    param([Parameter(Mandatory)][decimal]$Value, [Parameter(Mandatory)][string]$Method)
    switch ($Method) {
        'up' { return [Math]::Ceiling($Value) }
        'down' { return [Math]::Floor($Value) }
        'towards-zero' { return [Math]::Truncate($Value) }
        'nearest' { return [Math]::Round($Value, [System.MidpointRounding]::AwayFromZero) }
    }
    if ($Value -lt 0) { return -[Math]::Ceiling(-$Value) }
    [Math]::Ceiling($Value)
}

function script:Format-GnuNumfmtFixed {
    <#
    .SYNOPSIS
    A decimal with exactly Digits decimals, rounding half to even like C printf (and keeping '-0').
    #>

    param([Parameter(Mandatory)][decimal]$Value, [Parameter(Mandatory)][int]$Digits)
    $rounded = [Math]::Round($Value, $Digits, [System.MidpointRounding]::ToEven)
    $text = [Math]::Abs($rounded).ToString('F' + $Digits, [System.Globalization.CultureInfo]::InvariantCulture)
    if ($Value -lt 0) { $text = '-' + $text }
    $text
}

function script:ConvertFrom-GnuNumfmtNumber {
    <#
    .SYNOPSIS
    Parses one input number. Returns @{ Value ([decimal]); Precision } or @{ Error }.
    #>

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

    $number = $Text.Trim()
    if ($Settings.Suffix.Length -gt 0 -and $number.EndsWith($Settings.Suffix, [System.StringComparison]::Ordinal)) { $number = $number.Substring(0, $number.Length - $Settings.Suffix.Length) }
    $match = [regex]::Match($number, '^(-?)(\d*)(?:\.(\d+))?(.*)$')
    if ($match.Groups[2].Value.Length -eq 0 -and -not $match.Groups[3].Success) { return @{ Error = "invalid number: '$($Text.Trim())'" } }
    if ($match.Groups[2].Value.Length -gt 27) { return @{ Error = "value too large to be converted: '$($Text.Trim())'" } }
    $integer = $match.Groups[2].Value
    if ($integer.Length -eq 0) { $integer = '0' }
    $fraction = $match.Groups[3].Value
    $value = [decimal]::Parse("$integer.$($fraction)0", [System.Globalization.CultureInfo]::InvariantCulture)
    $precision = $fraction.Length
    $rest = $match.Groups[4].Value
    if ($rest.Length -gt 0) {
        if ($rest.StartsWith('.', [System.StringComparison]::Ordinal)) { return @{ Error = "invalid number: '$($Text.Trim())'" } }
        if ($Settings.From -eq 'none') { return @{ Error = "invalid suffix in input: '$($Text.Trim())'" } }
        $power = $script:GnuNumfmtPowers.IndexOf($rest[0])
        if ($power -lt 1) { return @{ Error = "invalid suffix in input: '$($Text.Trim())'" } }
        $rest = $rest.Substring(1)
        $base = 1000
        if ($Settings.From -eq 'iec' -or $Settings.From -eq 'iec-i') { $base = 1024 }
        if ($Settings.From -eq 'iec-i' -or $Settings.From -eq 'auto') {
            if ($rest.StartsWith('i', [System.StringComparison]::Ordinal)) { $base = 1024; $rest = $rest.Substring(1) }
            elseif ($Settings.From -eq 'iec-i') { return @{ Error = "missing 'i' suffix in input: '$($Text.Trim())' (e.g Ki/Mi/Gi)" } }
        }
        if ($rest.Length -gt 0) { return @{ Error = "invalid suffix in input '$($Text.Trim())': '$rest'" } }
        for ($i = 0; $i -lt $power; $i++) { $value *= $base }
        $precision = 0
    }
    if ($match.Groups[1].Value -eq '-') { $value = -$value }
    @{ Value = $value * $Settings.FromUnit; Precision = $precision }
}

function script:Format-GnuNumfmtNumber {
    <#
    .SYNOPSIS
    Formats one converted number (scaling, rounding, suffixes, --format, --padding). Returns @{ Text } or @{ Error }.
    #>

    param([Parameter(Mandatory)][decimal]$Value, [Parameter(Mandatory)][int]$Precision, [Parameter(Mandatory)][hashtable]$Settings)

    $value = $Value / $Settings.ToUnit
    $userPrecision = $Settings.FormatPrecision
    if ($Settings.To -eq 'none') {
        if ([Math]::Abs($value) -ge 10000000000000000000) {
            return @{ Error = "value too large to be printed: '$(Format-GnuFloat -Value ([double]$value) -Conversion 'g')' (consider using --to)" }
        }
        $digits = $Precision
        if ($userPrecision -ge 0) { $digits = $userPrecision }
        $scale = [decimal][Math]::Pow(10, $digits)
        $text = Format-GnuNumfmtFixed -Value ((Invoke-GnuNumfmtRound -Value ($value * $scale) -Method $Settings.Round) / $scale) -Digits $digits
    }
    else {
        $base = [decimal]1000
        if ($Settings.To -ne 'si') { $base = [decimal]1024 }
        $power = 0
        $scaled = $value
        while ([Math]::Abs($scaled) -ge $base) { $scaled /= $base; $power++ }
        $adjust = 0
        if ($userPrecision -ge 0) { $adjust = [Math]::Min($power * 3, $userPrecision) }
        elseif ([Math]::Abs($scaled) -lt 10) { $adjust = 1 }
        $factor = [decimal][Math]::Pow(10, $adjust)
        $scaled = (Invoke-GnuNumfmtRound -Value ($scaled * $factor) -Method $Settings.Round) / $factor
        if ([Math]::Abs($scaled) -ge $base) { $scaled /= $base; $power++ }
        if ($power -gt 8) { return @{ Error = "value too large to be printed: '$(Format-GnuFloat -Value ([double]$value) -Conversion 'g')' (cannot handle values > 999Y)" } }
        if ($userPrecision -ge 0) { $digits = $userPrecision }
        elseif ($scaled -ne 0 -and [Math]::Abs($scaled) -lt 10 -and $power -gt 0) { $digits = 1 }
        else { $digits = 0 }
        $text = Format-GnuNumfmtFixed -Value $scaled -Digits $digits
        if ($power -gt 0) {
            $text += $script:GnuNumfmtPowers[$power]
            if ($Settings.To -eq 'iec-i') { $text += 'i' }
        }
    }
    $text += $Settings.Suffix
    if ($Settings.FormatWidth -ne 0) {
        $width = [Math]::Abs($Settings.FormatWidth)
        if ($Settings.FormatWidth -lt 0) { $text = $text.PadRight($width) }
        elseif ($Settings.FormatZero -and $text.Length -lt $width) {
            $sign = ''
            if ($text.StartsWith('-', [System.StringComparison]::Ordinal)) { $sign = '-'; $text = $text.Substring(1) }
            $text = $sign + $text.PadLeft($width - $sign.Length, '0')
        }
        else { $text = $text.PadLeft($width) }
    }
    elseif ($Settings.Padding -gt 0) { $text = $text.PadLeft($Settings.Padding) }
    elseif ($Settings.Padding -lt 0) { $text = $text.PadRight(-$Settings.Padding) }
    @{ Text = $Settings.FormatPrefix + $text + $Settings.FormatSuffix }
}

function script:ConvertFrom-GnuNumfmtFieldList {
    <#
    .SYNOPSIS
    Parses --field into a predicate list of @{ From; To }. Returns @{ Ranges } or @{ Error }.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    if ($Text.Length -eq 0) { return @{ Error = 'fields are numbered from 1' } }
    $ranges = New-Object 'System.Collections.Generic.List[hashtable]'
    foreach ($item in $Text.Split(',')) {
        $match = [regex]::Match($item, '^(\d*)(-?)(\d*)$')
        if (-not $match.Success -or $item.Length -eq 0) {
            $bad = @($item.Split('-') | Where-Object { $_ -notmatch '^\d*$' }) + @($item)
            return @{ Error = "invalid field value '$($bad[0])'" }
        }
        $from = 1
        $to = [int]::MaxValue
        if ($match.Groups[1].Value.Length -gt 0) { $from = [int]$match.Groups[1].Value }
        if ($match.Groups[2].Value.Length -eq 0) { $to = $from }
        elseif ($match.Groups[3].Value.Length -gt 0) { $to = [int]$match.Groups[3].Value }
        if ($from -eq 0 -or $to -eq 0) { return @{ Error = 'fields are numbered from 1' } }
        $ranges.Add(@{ From = $from; To = $to })
    }
    @{ Ranges = $ranges }
}

function script:Invoke-GnuNumfmtConvert {
    <#
    .SYNOPSIS
    Implementation of numfmt.
    #>

    param([Parameter(Mandatory)][hashtable]$Context)

    $from = Select-GnuNumfmtChoice -Context $Context -Key 'from' -Valid @('none', 'auto', 'si', 'iec', 'iec-i') -Default 'none'
    if ($null -eq $from) { return }
    $to = Select-GnuNumfmtChoice -Context $Context -Key 'to' -Valid @('none', 'si', 'iec', 'iec-i') -Default 'none'
    if ($null -eq $to) { return }
    $round = Select-GnuNumfmtChoice -Context $Context -Key 'round' -Valid @('up', 'down', 'from-zero', 'towards-zero', 'nearest') -Default 'from-zero'
    if ($null -eq $round) { return }
    $invalid = Select-GnuNumfmtChoice -Context $Context -Key 'invalid' -Valid @('abort', 'fail', 'warn', 'ignore') -Default 'abort'
    if ($null -eq $invalid) { return }

    $settings = @{
        From = $from; To = $to; Round = $round; FromUnit = [decimal]1; ToUnit = [decimal]1; Suffix = ''; Padding = 0
        FormatPrefix = ''; FormatSuffix = ''; FormatWidth = 0; FormatZero = $false; FormatPrecision = -1
    }
    foreach ($key in @('from-unit', 'to-unit')) {
        if (-not (Test-GnuOption -Context $Context -Key $key)) { continue }
        $text = [string](Get-GnuOptionValue -Context $Context -Key $key)
        $unit = ConvertFrom-GnuNumfmtNumber -Text $text -Settings @{ Suffix = ''; From = 'auto'; FromUnit = [decimal]1 }
        if ($unit.ContainsKey('Error') -or $unit.Value -le 0) { Write-GnuError -Context $Context -Message "invalid unit size: '$text'"; return }
        if ($key -eq 'from-unit') { $settings.FromUnit = $unit.Value } else { $settings.ToUnit = $unit.Value }
    }
    if (Test-GnuOption -Context $Context -Key 'suffix') { $settings.Suffix = [string](Get-GnuOptionValue -Context $Context -Key 'suffix') }
    if (Test-GnuOption -Context $Context -Key 'padding') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'padding')
        if ($text -notmatch '^-?\d+$' -or [int]$text -eq 0) { Write-GnuError -Context $Context -Message "invalid padding value '$text'"; return }
        $settings.Padding = [int]$text
    }
    if (Test-GnuOption -Context $Context -Key 'format') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'format')
        $directives = [regex]::Matches($text, '%%|%')
        $single = @($directives | Where-Object { $_.Value -eq '%' })
        if ($single.Count -eq 0) { Write-GnuError -Context $Context -Message "format '$text' has no % directive"; return }
        if ($single.Count -gt 1) { Write-GnuError -Context $Context -Message "format '$text' has too many % directives"; return }
        $at = $single[0].Index
        $spec = [regex]::Match($text.Substring($at), "^%(0?)('?)(-?)(\d*)(?:\.(\d*))?f")
        if (-not $spec.Success) { Write-GnuError -Context $Context -Message "invalid format '$text', directive must be %[0]['][-][N][.][N]f"; return }
        $settings.FormatPrefix = $text.Substring(0, $at).Replace('%%', '%')
        $settings.FormatSuffix = $text.Substring($at + $spec.Length)
        $settings.FormatZero = $spec.Groups[1].Value -eq '0'
        if ($spec.Groups[4].Value.Length -gt 0) {
            $settings.FormatWidth = [int]$spec.Groups[4].Value
            if ($spec.Groups[3].Value -eq '-') { $settings.FormatWidth = -$settings.FormatWidth }
        }
        if ($spec.Groups[5].Success) {
            $settings.FormatPrecision = 0
            if ($spec.Groups[5].Value.Length -gt 0) { $settings.FormatPrecision = [int]$spec.Groups[5].Value }
        }
    }
    $ranges = @(@{ From = 1; To = 1 })
    if (Test-GnuOption -Context $Context -Key 'field') {
        $parsed = ConvertFrom-GnuNumfmtFieldList -Text ([string](Get-GnuOptionValue -Context $Context -Key 'field'))
        if ($parsed.ContainsKey('Error')) { Write-GnuUsageError -Context $Context -Message $parsed.Error; return }
        $ranges = $parsed.Ranges
    }
    $delimiter = $null
    if (Test-GnuOption -Context $Context -Key 'delimiter') {
        $delimiter = [string](Get-GnuOptionValue -Context $Context -Key 'delimiter')
        if ($delimiter.Length -ne 1) { Write-GnuError -Context $Context -Message 'the delimiter must be a single character'; return }
    }
    $header = 0
    if (Test-GnuOption -Context $Context -Key 'header') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'header')
        $header = 1
        if (-not [string]::IsNullOrEmpty($text)) {
            if ($text -notmatch '^\d+$' -or [int]$text -eq 0) { Write-GnuError -Context $Context -Message "invalid header value '$text'"; return }
            $header = [int]$text
        }
    }
    $zero = Test-GnuOption -Context $Context -Key 'zero-terminated'

    $lines = @($Context.Operands)
    if ($lines.Count -eq 0) {
        $text = Get-GnuInputText -Context $Context -Operand '-'
        if ($null -eq $text) { return }
        $separator = "`n"
        if ($zero) { $separator = "`0" } else { $text = $text.Replace("`r`n", "`n") }
        $lines = @(Split-GnuRecord -Text $text -Separator $separator)
    }
    else { $header = 0 }

    $output = New-Object 'System.Collections.Generic.List[string]'
    $failed = $false
    for ($n = 0; $n -lt $lines.Count; $n++) {
        if ($n -lt $header) { $output.Add($lines[$n]); continue }
        $fields = New-Object 'System.Collections.Generic.List[string]'
        $trailing = ''
        if ($null -eq $delimiter) {
            foreach ($match in [regex]::Matches($lines[$n], '\s*\S+')) { $fields.Add($match.Value) }
            $trailing = $lines[$n].Substring((@($fields) -join '').Length)
        }
        else { foreach ($part in $lines[$n].Split([char]$delimiter)) { $fields.Add($part) } }
        if ($fields.Count -eq 0) { $fields.Add($trailing); $trailing = '' }

        for ($f = 0; $f -lt $fields.Count; $f++) {
            $number = $f + 1
            if (@($ranges | Where-Object { $number -ge $_.From -and $number -le $_.To }).Count -eq 0) { continue }
            $field = $fields[$f]
            $parsed = ConvertFrom-GnuNumfmtNumber -Text $field -Settings $settings
            $result = $parsed
            if (-not $parsed.ContainsKey('Error')) { $result = Format-GnuNumfmtNumber -Value $parsed.Value -Precision $parsed.Precision -Settings $settings }
            if ($result.ContainsKey('Error')) {
                if ($invalid -ne 'ignore') { Write-GnuError -Context $Context -Message $result.Error -ExitCode 2 }
                if ($invalid -eq 'abort') { ConvertTo-GnuRecordOutput -Record $output.ToArray() -Zero:$zero; return }
                if ($invalid -eq 'fail') { $failed = $true }
                continue
            }
            $text = $result.Text
            # GNU pads to the input width only when the field starts with whitespace.
            if ($null -eq $delimiter -and $settings.Padding -eq 0 -and $settings.FormatWidth -eq 0 -and $field.Length -gt 0 -and [char]::IsWhiteSpace($field[0])) { $text = $text.PadLeft($field.Length) }
            $fields[$f] = $text
        }
        $joiner = ''
        if ($null -ne $delimiter) { $joiner = $delimiter }
        $output.Add(($fields -join $joiner) + $trailing)
    }
    ConvertTo-GnuRecordOutput -Record $output.ToArray() -Zero:$zero
    if ($failed) { $Context.ExitCode = 2 } elseif ($invalid -ne 'fail') { $Context.ExitCode = 0 }
}