Utils/test.ps1

Register-GnuUtil @{
    Name         = 'test'
    Category     = 'Shell helpers'
    Synopsis     = 'Check file types and compare values.'
    Usage        = @('test EXPRESSION', 'test', 'test OPTION')
    Description  = @(
        'Exit with the status determined by EXPRESSION.',
        '',
        'An omitted EXPRESSION defaults to false. Otherwise,',
        'EXPRESSION is true or false and sets exit status. It is one of:'
    )
    RawArguments  = $true
    UsageExitCode = 2
    Notes        = @(
        ' ( EXPRESSION ) EXPRESSION is true',
        ' ! EXPRESSION EXPRESSION is false',
        ' EXPRESSION1 -a EXPRESSION2 both EXPRESSION1 and EXPRESSION2 are true',
        ' EXPRESSION1 -o EXPRESSION2 either EXPRESSION1 or EXPRESSION2 is true',
        '',
        ' -n STRING the length of STRING is nonzero',
        ' STRING equivalent to -n STRING',
        ' -z STRING the length of STRING is zero',
        ' STRING1 = STRING2 the strings are equal',
        ' STRING1 != STRING2 the strings are not equal',
        ' STRING1 > STRING2 STRING1 sorts after STRING2 (ordinal)',
        ' STRING1 < STRING2 STRING1 sorts before STRING2 (ordinal)',
        '',
        ' INTEGER1 -eq INTEGER2 INTEGER1 is equal to INTEGER2',
        ' INTEGER1 -ge INTEGER2 INTEGER1 is greater than or equal to INTEGER2',
        ' INTEGER1 -gt INTEGER2 INTEGER1 is greater than INTEGER2',
        ' INTEGER1 -le INTEGER2 INTEGER1 is less than or equal to INTEGER2',
        ' INTEGER1 -lt INTEGER2 INTEGER1 is less than INTEGER2',
        ' INTEGER1 -ne INTEGER2 INTEGER1 is not equal to INTEGER2',
        ' (-l STRING may replace an INTEGER: the length of STRING)',
        '',
        ' FILE1 -ef FILE2 FILE1 and FILE2 are the same file',
        ' FILE1 -nt FILE2 FILE1 is newer (modification date) than FILE2',
        ' FILE1 -ot FILE2 FILE1 is older than FILE2',
        '',
        ' -b FILE FILE exists and is block special',
        ' -c FILE FILE exists and is character special',
        ' -d FILE FILE exists and is a directory',
        ' -e FILE FILE exists',
        ' -f FILE FILE exists and is a regular file',
        ' -g FILE FILE exists and is set-group-ID',
        ' -G FILE FILE exists and is owned by the effective group ID',
        ' -h FILE FILE exists and is a symbolic link (same as -L)',
        ' -k FILE FILE exists and has its sticky bit set',
        ' -L FILE FILE exists and is a symbolic link (same as -h)',
        ' -N FILE FILE exists and has been modified since it was last read',
        ' -O FILE FILE exists and is owned by the effective user ID',
        ' -p FILE FILE exists and is a named pipe',
        ' -r FILE FILE exists and the user has read access',
        ' -s FILE FILE exists and has a size greater than zero',
        ' -S FILE FILE exists and is a socket',
        ' -t FD file descriptor FD is opened on a terminal',
        ' -u FILE FILE exists and its set-user-ID bit is set',
        ' -w FILE FILE exists and the user has write access',
        ' -x FILE FILE exists and the user has execute (or search) access',
        '',
        'Exit status: 0 if EXPRESSION is true, 1 if it is false, 2 if an error occurred.',
        '',
        'PowerShell note: test sets $LASTEXITCODE and outputs nothing; use it as',
        '"Invoke-GnuTest -f x; if ($LASTEXITCODE -eq 0) { ... }". Quote ( ) ! = < > in',
        'PowerShell. "[" cannot be used as a command name. On Windows -x means a directory',
        'or an extension in PATHEXT, -O / -G mean the file exists, and -b -c -p -S -u -g -k',
        'are false.'
    )
}



function script:Stop-GnuTestSyntax {
    <#
    .SYNOPSIS
    Aborts the evaluation with a syntax error (exit code 2).
    #>

    param([Parameter(Mandatory)][string]$Message)
    throw "GNUTEST:$Message"
}

function script:Step-GnuTestArgument {
    <#
    .SYNOPSIS
    Moves to the next argument; with -Require, a missing argument is a syntax error.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [switch]$Require)
    $State.Pos++
    if ($Require -and $State.Pos -ge $State.Args.Length) { Stop-GnuTestBeyond -State $State }
}

function script:Stop-GnuTestBeyond {
    <#
    .SYNOPSIS
    The "missing argument after LAST" error of GNU test.
    #>

    param([Parameter(Mandatory)][hashtable]$State)
    Stop-GnuTestSyntax "missing argument after '$($State.Args[$State.Args.Length - 1])'"
}

function script:Test-GnuTestBinaryOperator {
    <#
    .SYNOPSIS
    $true for the binary operators of test.
    #>

    param([AllowNull()][string]$Text)
    @('=', '==', '!=', '<', '>', '-eq', '-ne', '-lt', '-le', '-gt', '-ge', '-nt', '-ot', '-ef') -ccontains $Text
}

function script:Test-GnuTestUnaryOperator {
    <#
    .SYNOPSIS
    $true for the unary operators of test.
    #>

    param([AllowNull()][string]$Text)
    @('-b', '-c', '-d', '-e', '-f', '-g', '-G', '-h', '-k', '-L', '-n', '-N', '-O', '-p', '-r', '-s', '-S', '-t', '-u', '-w', '-x', '-z') -ccontains $Text
}

function script:Invoke-GnuTestPosix {
    <#
    .SYNOPSIS
    POSIX rules for 1 to 4 arguments; more arguments use the full grammar.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][int]$Count)

    $a = $State.Args
    $p = $State.Pos
    switch ($Count) {
        1 { $State.Pos++; return -not [string]::IsNullOrEmpty($a[$p]) }
        2 { return Invoke-GnuTestTwo -State $State }
        3 { return Invoke-GnuTestThree -State $State }
        4 {
            if ($a[$p] -ceq '!') { Step-GnuTestArgument -State $State -Require; return -not (Invoke-GnuTestThree -State $State) }
            if ($a[$p] -ceq '(' -and $a[$p + 3] -ceq ')') {
                $State.Pos++
                $value = Invoke-GnuTestTwo -State $State
                $State.Pos++
                return $value
            }
        }
    }
    Invoke-GnuTestOr -State $State
}

function script:Invoke-GnuTestTwo {
    <#
    .SYNOPSIS
    Two arguments: "! STRING" or "-op OPERAND".
    #>

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

    $first = $State.Args[$State.Pos]
    if ($first -ceq '!') {
        $State.Pos++
        $value = -not [string]::IsNullOrEmpty($State.Args[$State.Pos])
        $State.Pos++
        return -not $value
    }
    if ($first.Length -eq 2 -and $first[0] -eq '-') {
        if (Test-GnuTestUnaryOperator $first) { return Invoke-GnuTestUnary -State $State }
        Stop-GnuTestSyntax "'$first': unary operator expected"
    }
    Stop-GnuTestBeyond -State $State
}

function script:Invoke-GnuTestThree {
    <#
    .SYNOPSIS
    Three arguments: a binary operation, "! A B", "( A )" or "A -a B" / "A -o B".
    #>

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

    $a = $State.Args
    $p = $State.Pos
    if (Test-GnuTestBinaryOperator $a[$p + 1]) { return Invoke-GnuTestBinary -State $State }
    if ($a[$p] -ceq '!') { Step-GnuTestArgument -State $State -Require; return -not (Invoke-GnuTestTwo -State $State) }
    if ($a[$p] -ceq '(' -and $a[$p + 2] -ceq ')') {
        $State.Pos += 2
        $value = -not [string]::IsNullOrEmpty($a[$p + 1])
        $State.Pos++
        return $value
    }
    if ($a[$p + 1] -ceq '-a' -or $a[$p + 1] -ceq '-o') { return Invoke-GnuTestOr -State $State }
    Stop-GnuTestSyntax "'$($a[$p + 1])': binary operator expected"
}

function script:Invoke-GnuTestOr {
    <#
    .SYNOPSIS
    EXPR -o EXPR ...
    #>

    param([Parameter(Mandatory)][hashtable]$State)
    $value = Invoke-GnuTestAnd -State $State
    while ($State.Pos -lt $State.Args.Length -and $State.Args[$State.Pos] -ceq '-o') {
        $State.Pos++
        $right = Invoke-GnuTestAnd -State $State
        $value = $value -or $right
    }
    $value
}

function script:Invoke-GnuTestAnd {
    <#
    .SYNOPSIS
    TERM -a TERM ...
    #>

    param([Parameter(Mandatory)][hashtable]$State)
    $value = Invoke-GnuTestTerm -State $State
    while ($State.Pos -lt $State.Args.Length -and $State.Args[$State.Pos] -ceq '-a') {
        $State.Pos++
        $right = Invoke-GnuTestTerm -State $State
        $value = $value -and $right
    }
    $value
}

function script:Invoke-GnuTestTerm {
    <#
    .SYNOPSIS
    ! TERM, ( EXPR ), a binary or unary operation, or a string.
    #>

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

    $a = $State.Args
    if ($State.Pos -ge $a.Length) { Stop-GnuTestBeyond -State $State }
    $invert = $false
    while ($State.Pos -lt $a.Length -and $a[$State.Pos] -ceq '!') {
        Step-GnuTestArgument -State $State -Require
        $invert = -not $invert
    }
    $p = $State.Pos
    if ($a[$p] -ceq '(') {
        Step-GnuTestArgument -State $State -Require
        $count = 1
        while ($State.Pos + $count -lt $a.Length -and $a[$State.Pos + $count] -cne ')') {
            if ($count -eq 4) { $count = $a.Length - $State.Pos; break }
            $count++
        }
        $value = Invoke-GnuTestPosix -State $State -Count $count
        if ($State.Pos -ge $a.Length) { Stop-GnuTestSyntax "')' expected" }
        if ($a[$State.Pos] -cne ')') { Stop-GnuTestSyntax "')' expected, found '$($a[$State.Pos])'" }
        $State.Pos++
    }
    elseif ($a.Length - $p -ge 4 -and $a[$p] -ceq '-l' -and (Test-GnuTestBinaryOperator $a[$p + 2])) { $value = Invoke-GnuTestBinary -State $State }
    elseif ($a.Length - $p -ge 3 -and (Test-GnuTestBinaryOperator $a[$p + 1])) { $value = Invoke-GnuTestBinary -State $State }
    elseif ($a[$p].Length -eq 2 -and $a[$p][0] -eq '-') {
        if (-not (Test-GnuTestUnaryOperator $a[$p])) { Stop-GnuTestSyntax "'$($a[$p])': unary operator expected" }
        $value = Invoke-GnuTestUnary -State $State
    }
    else {
        $value = -not [string]::IsNullOrEmpty($a[$p])
        $State.Pos++
    }
    $value -xor $invert
}

function script:ConvertFrom-GnuTestInteger {
    <#
    .SYNOPSIS
    An integer operand of test (blanks around it allowed), or a syntax error.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)
    $match = [regex]::Match($Text, '^\s*([+-]?\d+)\s*$')
    if (-not $match.Success) { Stop-GnuTestSyntax "invalid integer '$Text'" }
    [System.Numerics.BigInteger]::Parse($match.Groups[1].Value.TrimStart('+'), [System.Globalization.CultureInfo]::InvariantCulture)
}

function script:Invoke-GnuTestBinary {
    <#
    .SYNOPSIS
    A binary operation at the current position ("-l STRING" may replace integer operands).
    #>

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

    $a = $State.Args
    $leftIsLength = $false
    if ($a[$State.Pos] -ceq '-l') { $leftIsLength = $true; $State.Pos++ }
    $left = $a[$State.Pos]
    $operator = $a[$State.Pos + 1]
    $State.Pos += 2
    $rightIsLength = $false
    if ($a[$State.Pos] -ceq '-l' -and $State.Pos + 1 -lt $a.Length) { $rightIsLength = $true; $State.Pos++ }
    $right = $a[$State.Pos]
    $State.Pos++

    if ($operator -match '^-(eq|ne|lt|le|gt|ge)$') {
        if ($leftIsLength) { $leftValue = [System.Numerics.BigInteger]$left.Length } else { $leftValue = ConvertFrom-GnuTestInteger -Text $left }
        if ($rightIsLength) { $rightValue = [System.Numerics.BigInteger]$right.Length } else { $rightValue = ConvertFrom-GnuTestInteger -Text $right }
        $order = [System.Numerics.BigInteger]::Compare($leftValue, $rightValue)
        switch -CaseSensitive ($operator) {
            '-eq' { return $order -eq 0 }
            '-ne' { return $order -ne 0 }
            '-lt' { return $order -lt 0 }
            '-le' { return $order -le 0 }
            '-gt' { return $order -gt 0 }
            default { return $order -ge 0 }
        }
    }
    # GNU quirk with string operators: a left -l is ignored, and a right -l shifts the comparison,
    # so the operator itself is compared with STRING ("test 3 = -l abc" compares '=' with 'abc').
    if ($rightIsLength) { $left = $operator }
    switch -CaseSensitive ($operator) {
        { $_ -eq '=' -or $_ -eq '==' } { return [string]::Equals($left, $right, [System.StringComparison]::Ordinal) }
        '!=' { return -not [string]::Equals($left, $right, [System.StringComparison]::Ordinal) }
        '<' { return [string]::CompareOrdinal($left, $right) -lt 0 }
        '>' { return [string]::CompareOrdinal($left, $right) -gt 0 }
        '-ef' {
            $leftPath = Get-GnuCanonicalPath -Path $left -Require all
            $rightPath = Get-GnuCanonicalPath -Path $right -Require all
            return $leftPath.ContainsKey('Path') -and $rightPath.ContainsKey('Path') -and (Test-GnuSamePath -Left $leftPath.Path -Right $rightPath.Path)
        }
        default {
            # -nt / -ot: a missing file is older than any existing one.
            $leftTime = Get-GnuTestModificationTime -Path $left
            $rightTime = Get-GnuTestModificationTime -Path $right
            if ($operator -ceq '-ot') { $leftTime, $rightTime = $rightTime, $leftTime }
            if ($null -eq $leftTime) { return $false }
            if ($null -eq $rightTime) { return $true }
            return $leftTime -gt $rightTime
        }
    }
}

function script:Get-GnuTestModificationTime {
    <#
    .SYNOPSIS
    The modification time (UTC) of an existing file or directory, or $null.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string]$Path)
    if ([string]::IsNullOrEmpty($Path)) { return $null }
    $full = Resolve-GnuPath -Path $Path
    if ([System.IO.File]::Exists($full)) { return [System.IO.File]::GetLastWriteTimeUtc($full) }
    if ([System.IO.Directory]::Exists($full)) { return [System.IO.Directory]::GetLastWriteTimeUtc($full) }
    $null
}

function script:Invoke-GnuTestUnary {
    <#
    .SYNOPSIS
    A unary operation at the current position.
    #>

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

    $operator = $State.Args[$State.Pos]
    Step-GnuTestArgument -State $State -Require
    $operand = $State.Args[$State.Pos]
    $State.Pos++
    switch -CaseSensitive ($operator) {
        '-n' { return -not [string]::IsNullOrEmpty($operand) }
        '-z' { return [string]::IsNullOrEmpty($operand) }
        '-t' { return Test-GnuTerminal -Descriptor ([int](ConvertFrom-GnuTestInteger -Text $operand)) }
        default { return Test-GnuFileCondition -Operator $operator.Substring(1) -Path $operand }
    }
}

function script:Test-GnuTerminal {
    <#
    .SYNOPSIS
    $true if file descriptor 0, 1 or 2 is a terminal (console), as far as .NET can tell.
    #>

    param([Parameter(Mandatory)][int]$Descriptor)
    switch ($Descriptor) {
        0 { return -not [System.Console]::IsInputRedirected }
        1 { return -not [System.Console]::IsOutputRedirected }
        2 { return -not [System.Console]::IsErrorRedirected }
    }
    $false
}

function script:Get-GnuUnixModeString {
    <#
    .SYNOPSIS
    The 'ls -l' mode string of a path on Unix (PowerShell 7.1+ UnixMode, like 'crw-rw-rw-'), or nothing.
    #>

    param([Parameter(Mandatory)][string]$Path)
    if (Test-GnuWindows) { return }
    $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
    if ($null -ne $item -and $null -ne $item.PSObject.Properties['UnixMode']) { [string]$item.UnixMode }
}

function script:Test-GnuFileCondition {
    <#
    .SYNOPSIS
    The file tests of test (-b -c -d -e -f -g -G -h -k -L -N -O -p -r -s -S -u -w -x).
    #>

    param([Parameter(Mandatory)][string]$Operator, [Parameter(Mandatory)][AllowEmptyString()][string]$Path)

    if ([string]::IsNullOrEmpty($Path)) { return $false }
    $full = Resolve-GnuPath -Path $Path
    if ($Operator -ceq 'h' -or $Operator -ceq 'L') { return $null -ne (Get-GnuLinkTarget -Path $full) }

    # Like stat(): links are followed, so a dangling link does not exist.
    $isFile = [System.IO.File]::Exists($full)
    $isDirectory = [System.IO.Directory]::Exists($full)
    if (-not ($isFile -or $isDirectory)) { return $false }
    $mode = Get-GnuUnixModeString -Path $full
    $bits = Get-GnuUnixFileMode -Path $full
    switch -CaseSensitive ($Operator) {
        'e' { return $true }
        'd' { return $isDirectory }
        'f' { return $isFile -and ($null -eq $mode -or $mode.StartsWith('-', [System.StringComparison]::Ordinal)) }
        's' { return $isDirectory -or (New-Object System.IO.FileInfo -ArgumentList $full).Length -gt 0 }
        'b' { return $null -ne $mode -and $mode.StartsWith('b', [System.StringComparison]::Ordinal) }
        'c' { return $null -ne $mode -and $mode.StartsWith('c', [System.StringComparison]::Ordinal) }
        'p' { return $null -ne $mode -and $mode.StartsWith('p', [System.StringComparison]::Ordinal) }
        'S' { return $null -ne $mode -and $mode.StartsWith('s', [System.StringComparison]::Ordinal) }
        'u' { return $null -ne $bits -and ($bits -band 2048) -ne 0 }
        'g' { return $null -ne $bits -and ($bits -band 1024) -ne 0 }
        'k' { return $null -ne $bits -and ($bits -band 512) -ne 0 }
        'N' {
            $info = Get-Item -LiteralPath $full -Force
            return $info.LastWriteTimeUtc -gt $info.LastAccessTimeUtc
        }
        'r' {
            if ($isDirectory) { return $true }
            try { (New-Object System.IO.FileStream -ArgumentList $full, 'Open', 'Read', 'ReadWrite').Dispose(); return $true } catch { return $false }
        }
        'w' {
            if ($null -ne $bits) { return ($bits -band 146) -ne 0 }   # any w bit (0o222)
            if ($isDirectory) { return $true }
            return -not ((Get-Item -LiteralPath $full -Force).Attributes -band [System.IO.FileAttributes]::ReadOnly)
        }
        'x' {
            if ($null -ne $bits) { return ($bits -band 73) -ne 0 }    # any x bit (0o111)
            if ($isDirectory) { return $true }
            $extension = [System.IO.Path]::GetExtension($full)
            $pathExt = '.COM;.EXE;.BAT;.CMD'
            if ($env:PATHEXT) { $pathExt = $env:PATHEXT }
            return -not [string]::IsNullOrEmpty($extension) -and @($pathExt -split ';') -contains $extension
        }
        default { return $true }   # -O / -G: owned by the current user or group (approximation: exists)
    }
}