Utils/tr.ps1

Register-GnuUtil @{
    Name        = 'tr'
    Category    = 'Text processing'
    Synopsis    = 'Translate or delete characters.'
    Usage       = @('tr [OPTION]... STRING1 [STRING2]')
    Description = @('Translate, squeeze, and/or delete characters from standard input,', 'writing to standard output. STRING1 and STRING2 specify arrays of', 'characters ARRAY1 and ARRAY2 that control the action.')
    Options     = @(
        @{ Short = 'c'; Long = 'complement'; Key = 'complement'; Help = 'use the complement of ARRAY1' }
        @{ Short = 'C'; Key = 'complement'; Help = 'use the complement of ARRAY1' }
        @{ Short = 'd'; Long = 'delete'; Help = 'delete characters in ARRAY1, do not translate' }
        @{ Short = 's'; Long = 'squeeze-repeats'; Help = 'replace each sequence of a repeated character that is listed in the last specified ARRAY, with a single occurrence of that character' }
        @{ Short = 't'; Long = 'truncate-set1'; Help = 'first truncate ARRAY1 to length of ARRAY2' }
    )
    Notes       = @(
        'ARRAYs are specified as strings of characters. Most represent themselves.',
        'Interpreted sequences are:',
        '',
        ' \NNN character with octal value NNN (1 to 3 octal digits)',
        ' \\ backslash',
        ' \a audible BEL',
        ' \b backspace',
        ' \f form feed',
        ' \n new line',
        ' \r return',
        ' \t horizontal tab',
        ' \v vertical tab',
        ' CHAR1-CHAR2 all characters from CHAR1 to CHAR2 in ascending order',
        ' [CHAR*] in ARRAY2, copies of CHAR until length of ARRAY1',
        ' [CHAR*REPEAT] REPEAT copies of CHAR, REPEAT octal if starting with 0',
        ' [:alnum:] all letters and digits',
        ' [:alpha:] all letters',
        ' [:blank:] all horizontal whitespace',
        ' [:cntrl:] all control characters',
        ' [:digit:] all digits',
        ' [:graph:] all printable characters, not including space',
        ' [:lower:] all lower case letters',
        ' [:print:] all printable characters, including space',
        ' [:punct:] all punctuation characters',
        ' [:space:] all horizontal or vertical whitespace',
        ' [:upper:] all upper case letters',
        ' [:xdigit:] all hexadecimal digits',
        ' [=CHAR=] all characters which are equivalent to CHAR',
        '',
        'Translation occurs if -d is not given and both STRING1 and STRING2 appear.',
        '-t may be used only when translating. ARRAY2 is extended to length of',
        'ARRAY1 by repeating its last character as necessary. Excess characters',
        'of ARRAY2 are ignored. Character classes expand in unspecified order;',
        'while translating, [:lower:] and [:upper:] may be used in pairs to',
        'specify case conversion. Squeezing occurs after translation or deletion.',
        '',
        'PowerShell note: tr reads only the pipeline or the console (no FILE). Classes',
        'are the ASCII classes of the C locale; with -c, characters above U+00FF',
        'are translated to the last character of ARRAY2.'
    )
}



$script:GnuTrClasses = @{
    alnum  = '0-9A-Za-z'
    alpha  = 'A-Za-z'
    blank  = "`t "
    cntrl  = "`0-$([char]31)$([char]127)"
    digit  = '0-9'
    graph  = '!-~'
    lower  = 'a-z'
    print  = ' -~'
    punct  = '!-/:-@[-`{-~'
    space  = "`t-$([char]13) "
    upper  = 'A-Z'
    xdigit = '0-9A-Fa-f'
}

function script:ConvertFrom-GnuTrSet {
    <#
    .SYNOPSIS
    Parses a tr STRING into elements: @{ Type = 'char'|'class'|'repeat'; ... }. Returns @{ Elements } or @{ Error }.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    # First pass: characters with escapes resolved, keeping bracket constructs whole.
    $tokens = New-Object 'System.Collections.Generic.List[hashtable]'
    $i = 0
    while ($i -lt $Text.Length) {
        $char = $Text[$i]
        if ($char -eq '[') {
            $rest = $Text.Substring($i)
            if ($rest -cmatch '^\[:(\w+):\]') {
                if (-not $script:GnuTrClasses.ContainsKey($Matches[1])) { return @{ Error = "invalid character class '$($Matches[1])'" } }
                $tokens.Add(@{ Type = 'class'; Name = $Matches[1] })
                $i += $Matches[0].Length
                continue
            }
            if ($rest -cmatch '^\[=(\\?.)=\]') {
                $tokens.Add(@{ Type = 'char'; Char = (ConvertFrom-GnuTrEscape -Text $Matches[1]).Char; Escaped = $true })
                $i += $Matches[0].Length
                continue
            }
            if ($rest -cmatch '^\[(\\[0-7]{1,3}|\\.|[^\\])\*(\d*)\]') {
                $countText = $Matches[2]
                $count = -1
                if ($countText.Length -gt 0) {
                    try {
                        if ($countText.StartsWith('0', [System.StringComparison]::Ordinal)) { $count = [System.Convert]::ToInt32($countText, 8) }
                        else { $count = [int]$countText }
                    }
                    catch { return @{ Error = "invalid repeat count '$countText' in [c*n] construct" } }
                    if ($count -eq 0) { $count = -1 }
                }
                $tokens.Add(@{ Type = 'repeat'; Char = (ConvertFrom-GnuTrEscape -Text $Matches[1]).Char; Count = $count })
                $i += $Matches[0].Length
                continue
            }
        }
        if ($char -eq '\' -and $i + 1 -lt $Text.Length) {
            $escape = ConvertFrom-GnuTrEscape -Text $Text.Substring($i)
            $tokens.Add(@{ Type = 'char'; Char = $escape.Char; Escaped = $true })
            $i += $escape.Length
            continue
        }
        $tokens.Add(@{ Type = 'char'; Char = $char; Escaped = $false })
        $i++
    }

    # Second pass: ranges CHAR1-CHAR2.
    $elements = New-Object 'System.Collections.Generic.List[hashtable]'
    for ($k = 0; $k -lt $tokens.Count; $k++) {
        $token = $tokens[$k]
        if ($token.Type -eq 'char' -and $k + 2 -lt $tokens.Count -and $tokens[$k + 1].Type -eq 'char' -and $tokens[$k + 1].Char -eq '-' -and
            -not $tokens[$k + 1].Escaped -and $tokens[$k + 2].Type -eq 'char') {
            $low = [int]$token.Char
            $high = [int]$tokens[$k + 2].Char
            if ($high -lt $low) {
                return @{ Error = "range-endpoints of '$(ConvertTo-GnuTrDisplay $token.Char)-$(ConvertTo-GnuTrDisplay $tokens[$k + 2].Char)' are in reverse collating sequence order" }
            }
            for ($code = $low; $code -le $high; $code++) { $elements.Add(@{ Type = 'char'; Char = [char]$code }) }
            $k += 2
            continue
        }
        $elements.Add($token)
    }
    @{ Elements = $elements }
}

function script:ConvertFrom-GnuTrEscape {
    <#
    .SYNOPSIS
    Resolves the escape at the start of Text ('\n', '\101', '\\', '\q' -> 'q'). Returns @{ Char; Length }.
    #>

    param([Parameter(Mandatory)][string]$Text)

    if ($Text.Length -lt 2 -or $Text[0] -ne '\') { return @{ Char = $Text[0]; Length = 1 } }
    if ($Text -match '^\\([0-7]{1,3})') {
        $digits = $Matches[1]
        # GNU: a 3-digit octal above \377 uses only two digits.
        if ($digits.Length -eq 3 -and [System.Convert]::ToInt32($digits, 8) -gt 255) { $digits = $digits.Substring(0, 2) }
        return @{ Char = [char][System.Convert]::ToInt32($digits, 8); Length = 1 + $digits.Length }
    }
    $map = @{ a = 7; b = 8; f = 12; n = 10; r = 13; t = 9; v = 11 }
    $next = [string]$Text[1]
    if ($map.ContainsKey($next) -and $next -cmatch '^[a-z]$') { return @{ Char = [char]$map[$next]; Length = 2 } }
    @{ Char = $Text[1]; Length = 2 }
}

function script:ConvertTo-GnuTrDisplay {
    <#
    .SYNOPSIS
    A character as GNU tr shows it in messages (octal escape for control characters).
    #>

    param([Parameter(Mandatory)][char]$Char)
    if ([int]$Char -lt 32 -or [int]$Char -eq 127) { return '\' + [System.Convert]::ToString([int]$Char, 8).PadLeft(3, '0') }
    [string]$Char
}

function script:Expand-GnuTrSet {
    <#
    .SYNOPSIS
    Expands parsed elements into a list of characters; a fill repeat ([c*]) takes FillLength minus the rest.
    #>

    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[hashtable]]$Elements,
        [int]$FillLength = 0
    )

    $fixed = 0
    foreach ($element in $Elements) {
        switch ($element.Type) {
            'char' { $fixed++ }
            'class' { $fixed += (Expand-GnuTrClass -Name $element.Name).Count }
            'repeat' { if ($element.Count -gt 0) { $fixed += $element.Count } }
        }
    }
    $chars = New-Object 'System.Collections.Generic.List[char]'
    foreach ($element in $Elements) {
        switch ($element.Type) {
            'char' { $chars.Add($element.Char) }
            'class' { foreach ($char in (Expand-GnuTrClass -Name $element.Name)) { $chars.Add($char) } }
            'repeat' {
                $count = $element.Count
                if ($count -lt 0) { $count = [Math]::Max(0, $FillLength - $fixed) }
                for ($n = 0; $n -lt $count; $n++) { $chars.Add($element.Char) }
            }
        }
    }
    , $chars
}

function script:Get-GnuTrCaseClassOffset {
    <#
    .SYNOPSIS
    Positions (in the expanded set) where a [:upper:] or [:lower:] class starts, as hashtable keys.
    #>

    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[hashtable]]$Elements,
        [int]$FillLength = 0
    )

    $offsets = @{}
    $prefix = New-Object 'System.Collections.Generic.List[hashtable]'
    foreach ($element in $Elements) {
        if ($element.Type -eq 'class' -and ($element.Name -eq 'upper' -or $element.Name -eq 'lower')) {
            $offsets[(Expand-GnuTrSet -Elements $prefix -FillLength $FillLength).Count] = $element.Name
        }
        $prefix.Add($element)
    }
    $offsets
}

function script:Expand-GnuTrClass {
    <#
    .SYNOPSIS
    The characters of a [:class:] in ascending order.
    #>

    param([Parameter(Mandatory)][string]$Name)

    $set = (ConvertFrom-GnuTrSet -Text $script:GnuTrClasses[$Name]).Elements
    $chars = New-Object 'System.Collections.Generic.List[char]'
    foreach ($element in $set) { $chars.Add($element.Char) }
    $chars.Sort()
    , $chars
}

function script:Invoke-GnuTranslate {
    <#
    .SYNOPSIS
    Implementation of tr.
    #>

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

    $operands = @($Context.Operands)
    $delete = Test-GnuOption -Context $Context -Key 'delete'
    $squeeze = Test-GnuOption -Context $Context -Key 'squeeze-repeats'
    $complement = Test-GnuOption -Context $Context -Key 'complement'
    $truncate = Test-GnuOption -Context $Context -Key 'truncate-set1'

    if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing operand'; return }
    $translate = -not $delete -and $operands.Count -ge 2
    $maxOperands = 2
    if ($delete -and -not $squeeze) { $maxOperands = 1 }
    if ($operands.Count -gt $maxOperands) {
        $message = "extra operand '$($operands[$maxOperands])'"
        if ($maxOperands -eq 1) { $message += "`nOnly one string may be given when deleting without squeezing repeats." }
        Write-GnuUsageError -Context $Context -Message $message
        return
    }
    if ($operands.Count -eq 1 -and -not $delete -and -not $squeeze) {
        Write-GnuUsageError -Context $Context -Message "missing operand after '$($operands[0])'`nTwo strings must be given when translating."
        return
    }
    if ($delete -and $squeeze -and $operands.Count -lt 2) {
        Write-GnuUsageError -Context $Context -Message "missing operand after '$($operands[0])'`nTwo strings must be given when both deleting and squeezing repeats."
        return
    }

    $parsed1 = ConvertFrom-GnuTrSet -Text $operands[0]
    if ($parsed1.ContainsKey('Error')) { Write-GnuError -Context $Context -Message $parsed1.Error; return }
    if (@($parsed1.Elements | Where-Object { $_.Type -eq 'repeat' }).Count -gt 0) {
        Write-GnuError -Context $Context -Message 'the [c*] repeat construct may not appear in string1'
        return
    }
    $set1 = Expand-GnuTrSet -Elements $parsed1.Elements
    $member1 = New-Object 'System.Collections.Generic.HashSet[char]'
    foreach ($char in $set1) { [void]$member1.Add($char) }
    if ($complement) {
        $set1 = New-Object 'System.Collections.Generic.List[char]'
        for ($code = 0; $code -le 255; $code++) { if (-not $member1.Contains([char]$code)) { $set1.Add([char]$code) } }
    }

    $set2 = New-Object 'System.Collections.Generic.List[char]'
    if ($operands.Count -ge 2) {
        $parsed2 = ConvertFrom-GnuTrSet -Text $operands[1]
        if ($parsed2.ContainsKey('Error')) { Write-GnuError -Context $Context -Message $parsed2.Error; return }
        if ($translate) {
            $badClass = @($parsed2.Elements | Where-Object { $_.Type -eq 'class' -and $_.Name -ne 'upper' -and $_.Name -ne 'lower' })
            if ($badClass.Count -gt 0) {
                Write-GnuError -Context $Context -Message "when translating, the only character classes that may appear in string2 are 'upper' and 'lower'"
                return
            }
        }
        $set2 = Expand-GnuTrSet -Elements $parsed2.Elements -FillLength $set1.Count
        if ($translate) {
            # [:upper:] / [:lower:] in STRING2 must line up with [:lower:] / [:upper:] in STRING1.
            $offsets1 = Get-GnuTrCaseClassOffset -Elements $parsed1.Elements
            $offsets2 = Get-GnuTrCaseClassOffset -Elements $parsed2.Elements -FillLength $set1.Count
            foreach ($offset in $offsets2.Keys) {
                if ($complement -or -not $offsets1.ContainsKey($offset)) {
                    Write-GnuError -Context $Context -Message 'misaligned [:upper:] and/or [:lower:] construct'
                    return
                }
            }
        }
    }

    $map = New-Object 'System.Collections.Generic.Dictionary[char,char]'
    if ($translate) {
        if ($set2.Count -eq 0 -and $set1.Count -gt 0 -and -not $truncate) {
            Write-GnuError -Context $Context -Message 'when not truncating set1, string2 must be non-empty'
            return
        }
        $length = $set1.Count
        if ($truncate) { $length = [Math]::Min($set1.Count, $set2.Count) }
        for ($n = 0; $n -lt $length; $n++) {
            $target = $set2[[Math]::Min($n, $set2.Count - 1)]
            $map[$set1[$n]] = $target
        }
    }

    $squeezeSet = New-Object 'System.Collections.Generic.HashSet[char]'
    $squeezeOutsideLatin1 = $false
    if ($squeeze) {
        if ($operands.Count -ge 2) { foreach ($char in $set2) { [void]$squeezeSet.Add($char) } }
        else {
            foreach ($char in $set1) { [void]$squeezeSet.Add($char) }
            $squeezeOutsideLatin1 = $complement
        }
    }
    $fallback = $null
    if ($translate -and $complement -and $set2.Count -gt 0 -and -not $truncate) { $fallback = $set2[$set2.Count - 1] }

    $text = Get-GnuInputText -Context $Context -Operand '-'
    if ($null -eq $text) { return }
    $builder = New-Object System.Text.StringBuilder
    $hasLast = $false
    $last = [char]0
    foreach ($char in $text.ToCharArray()) {
        $outside = $complement -and [int]$char -gt 255 -and -not $member1.Contains($char)
        if ($delete) {
            $inSet1 = $member1.Contains($char) -xor $complement
            if ($inSet1) { continue }
        }
        elseif ($translate) {
            if ($map.ContainsKey($char)) { $char = $map[$char] }
            elseif ($outside -and $null -ne $fallback) { $char = $fallback }
        }
        if ($squeeze -and $hasLast -and $char -ceq $last -and ($squeezeSet.Contains($char) -or ($squeezeOutsideLatin1 -and $outside))) { continue }
        [void]$builder.Append($char)
        $hasLast = $true
        $last = $char
    }
    ConvertTo-GnuOutputLine -Text $builder.ToString()
}