Utils/find.ps1

Register-GnuUtil @{
    Name         = 'find'
    Category     = 'Searching and comparing'
    Synopsis     = 'Search for files in a directory hierarchy.'
    Usage        = @('find [-H] [-L] [-P] [-Olevel] [starting-point...] [expression]')
    Description  = @(
        'Default path is the current directory; default expression is -print.',
        'Expression may consist of: operators, options, tests, and actions.',
        '',
        'Operators (decreasing precedence; -and is implicit where no others are given):',
        ' ( EXPR ) ! EXPR -not EXPR EXPR1 -a EXPR2 EXPR1 -and EXPR2',
        ' EXPR1 -o EXPR2 EXPR1 -or EXPR2 EXPR1 , EXPR2',
        '',
        'Positional options (always true):',
        ' -daystart -follow -nowarn -regextype -warn',
        '',
        'Normal options (always true, specified before other expressions):',
        ' -depth -files0-from FILE -maxdepth LEVELS -mindepth LEVELS',
        ' -mount -noleaf -xdev -ignore_readdir_race -noignore_readdir_race',
        '',
        'Tests (N can be +N or -N or N):',
        ' -amin N -anewer FILE -atime N -cmin N -cnewer FILE -ctime N -empty -false',
        ' -ilname PATTERN -iname PATTERN -ipath PATTERN -iregex PATTERN',
        ' -iwholename PATTERN -lname PATTERN -mmin N -mtime N -name PATTERN -newer FILE',
        ' -path PATTERN -regex PATTERN -size N[bcwkMG] -true -type [bcdpflsD]',
        ' -wholename PATTERN -xtype [bcdpfls]',
        '',
        'Actions:',
        ' -delete -print0 -printf FORMAT -fprintf FILE FORMAT -print -fprint FILE -fprint0 FILE',
        ' -prune -quit',
        ' -exec COMMAND ; -exec COMMAND {} + -ok COMMAND ;',
        ' -execdir COMMAND ; -execdir COMMAND {} + -okdir COMMAND ;'
    )
    RawArguments = $true
    Notes        = @(
        'PowerShell notes: quote the ; that ends -exec (''; or `;) - unquoted it ends the PowerShell',
        'statement; ''\;'' works as well, and an unquoted {} is fine. COMMAND can be a program, a',
        'PowerShell command or a util of this module (echo, cat run the utils). Entries of a directory',
        'are visited in name order. -ctime and -cmin use the modification time (.NET has no status',
        'change time; on Linux .NET often reports the modification time as creation time), -xdev only stops at',
        'drive and mount roots. Not supported: -ls',
        '-fls -perm -user -group -uid -gid -nouser -nogroup -links -inum -samefile -newerXY -used',
        '-readable -writable -executable -context -files0-from. -printf: %n is 1, %i and %D are 0, %b and',
        '%k assume 4 KiB clusters, %Z is not supported.'
    )
}



function script:Get-GnuFindToken {
    <#
    .SYNOPSIS
    The current token without consuming it, or $null at the end.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser)
    if ($Parser.Pos -ge $Parser.Tokens.Count) { return $null }
    $Parser.Tokens[$Parser.Pos]
}

function script:Read-GnuFindArgument {
    <#
    .SYNOPSIS
    The argument of a predicate, or GNU's error when it is missing.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [Parameter(Mandatory)][string]$Name)
    if ($Parser.Pos -ge $Parser.Tokens.Count) { throw [System.FormatException]::new("missing argument to ``$Name'") }
    $Parser.Pos++
    $Parser.Tokens[$Parser.Pos - 1]
}

function script:ConvertFrom-GnuFindNumber {
    <#
    .SYNOPSIS
    A numeric test argument (+N, -N, N) as @{ Sign; Number }, or $null.
    #>

    param([AllowEmptyString()][string]$Text)
    if ($Text -notmatch '^([+-]?)(\d+(?:\.\d*)?|\.\d+)$') { return $null }
    @{ Sign = $Matches[1]; Number = [double]::Parse($Matches[2], [System.Globalization.CultureInfo]::InvariantCulture) }
}

function script:New-GnuFindRegex {
    <#
    .SYNOPSIS
    A -regex pattern for the current -regextype, matched against the whole path.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [AllowEmptyString()][string]$Pattern, [bool]$IgnoreCase)

    $source = $Pattern
    $extended = $Parser.RegexType -ceq 'extended'
    if ($Parser.RegexType -ceq 'emacs') {
        # Emacs syntax: + and ? are operators, \+ \? \{ \} are plain characters; otherwise it reads like a BRE.
        $builder = New-Object System.Text.StringBuilder
        $inBracket = $false
        for ($i = 0; $i -lt $Pattern.Length; $i++) {
            $char = [string]$Pattern[$i]
            if ($inBracket) { if ($char -ceq ']') { $inBracket = $false }; [void]$builder.Append($char); continue }
            if ($char -ceq '[') { $inBracket = $true; [void]$builder.Append($char); continue }
            if ($char -ceq '\' -and $i + 1 -lt $Pattern.Length) {
                $next = [string]$Pattern[$i + 1]
                $i++
                if ('+', '?', '{', '}' -ccontains $next) { [void]$builder.Append($next) } else { [void]$builder.Append('\').Append($next) }
                continue
            }
            if ($char -ceq '+' -or $char -ceq '?') { [void]$builder.Append('\').Append($char); continue }
            [void]$builder.Append($char)
        }
        $source = $builder.ToString()
    }
    $converted = ConvertTo-GnuDotNetRegex -Pattern $source -Extended:$extended -AbsoluteEnd
    if ($converted.ContainsKey('Error')) { throw [System.FormatException]::new("failed to compile regular expression '$Pattern': $($converted.Error)") }
    $options = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
    if ($IgnoreCase) { $options = $options -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase }
    $text = '^(?:' + $converted.Regex + ')\z'
    try { New-Object System.Text.RegularExpressions.Regex -ArgumentList $text, $options }
    catch {
        $message = 'Invalid regular expression'
        switch (Get-GnuParenthesisBalance -Regex $converted.Regex) {
            'open' { $message = 'Unmatched ( or \(' }
            'close' { $message = 'Unmatched ) or \)' }
        }
        throw [System.FormatException]::new("failed to compile regular expression '$Pattern': $message")
    }
}

function script:New-GnuFindPattern {
    <#
    .SYNOPSIS
    A compiled shell pattern for -name, -path and -lname.
    #>

    param([AllowEmptyString()][string]$Pattern, [bool]$IgnoreCase)
    $options = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
    if ($IgnoreCase) { $options = $options -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase }
    New-Object System.Text.RegularExpressions.Regex -ArgumentList (ConvertTo-GnuGlobRegex -Pattern $Pattern), $options
}

function script:ConvertFrom-GnuFindTypeList {
    <#
    .SYNOPSIS
    The letters of a -type / -xtype argument (f,d,l), with GNU's messages for bad lists.
    #>

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

    $valid = 'bcdpfls'
    $letters = New-Object 'System.Collections.Generic.List[string]'
    for ($i = 0; $i -lt $Text.Length; $i++) {
        $letter = [string]$Text[$i]
        if ($letter -ceq 'D') { throw [System.FormatException]::new("$Name D is not supported because Solaris doors are not supported on the platform find was compiled on.") }
        if (-not $valid.Contains($letter)) { throw [System.FormatException]::new("Unknown argument to $($Name): $letter") }
        if ($letters.Contains($letter)) { throw [System.FormatException]::new("Duplicate file type '$letter' in the argument list to $Name.") }
        $letters.Add($letter)
        if ($i + 1 -lt $Text.Length) {
            if ($Text[$i + 1] -ne ',') { throw [System.FormatException]::new("Must separate multiple arguments to $Name using: ','") }
            $i++
            if ($i + 1 -ge $Text.Length) { throw [System.FormatException]::new("Last file type in list argument to $Name is missing, i.e., list is ending on: ','") }
        }
    }
    , $letters.ToArray()
}

function script:Read-GnuFindExec {
    <#
    .SYNOPSIS
    The command of -exec, -execdir, -ok and -okdir up to ; or {} +.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [Parameter(Mandatory)][hashtable]$Node)

    $words = New-Object 'System.Collections.Generic.List[string]'
    while ($true) {
        $token = Get-GnuFindToken -Parser $Parser
        if ($null -eq $token) { throw [System.FormatException]::new("missing argument to ``$($Node.Name)'") }
        $Parser.Pos++
        if ($token -ceq ';' -or $token -ceq '\;') { break }
        if ($token -ceq '+' -and $words.Count -gt 0 -and $words[$words.Count - 1] -ceq '{}' -and $Node.Name -cnotlike '-ok*') {
            $Node.Batch = $true
            $words.RemoveAt($words.Count - 1)
            if (@($words | Where-Object { $_.Contains('{}') }).Count -gt 0) { throw [System.FormatException]::new("Only one instance of {} is supported with $($Node.Name) ... +") }
            break
        }
        $words.Add($token)
    }
    if ($words.Count -eq 0) {
        if ($Parser.Tokens[$Parser.Pos - 1] -ceq ';') { throw [System.FormatException]::new("invalid argument ``;' to ``$($Node.Name)'") }
        throw [System.FormatException]::new("missing argument to ``$($Node.Name)'")
    }
    $Node.Words = $words.ToArray()
    $Node.Pending = New-Object 'System.Collections.Generic.List[string]'
    $Node.PendingDir = $null
}

function script:ConvertFrom-GnuFindFormat {
    <#
    .SYNOPSIS
    Parses a -printf format into literal text and directives (@{ Text } / @{ Flags; Width; Precision; Letter; Kind }).
    Unknown directives and escapes are kept as written, with GNU's warning.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [AllowEmptyString()][string]$Format)

    $parts = New-Object 'System.Collections.Generic.List[hashtable]'
    $literal = New-Object System.Text.StringBuilder
    $flush = { if ($literal.Length -gt 0) { $parts.Add(@{ Text = $literal.ToString() }); [void]$literal.Clear() } }
    $escapes = @{ a = 7; b = 8; f = 12; n = 10; r = 13; t = 9; v = 11 }
    $i = 0
    while ($i -lt $Format.Length) {
        $char = [string]$Format[$i]
        if ($char -ceq '\' -and $i + 1 -lt $Format.Length) {
            $next = [string]$Format[$i + 1]
            $octal = [regex]::Match($Format.Substring($i + 1), '^[0-7]{1,3}').Value
            if ($octal.Length -gt 0) { [void]$literal.Append([char]([Convert]::ToInt32($octal, 8) -band 255)); $i += 1 + $octal.Length; continue }
            if ($escapes.ContainsKey($next) -and $next -cmatch '^[a-z]$') { [void]$literal.Append([char]$escapes[$next]); $i += 2; continue }
            if ($next -ceq '\') { [void]$literal.Append('\'); $i += 2; continue }
            if ($next -ceq 'c') { . $flush; $parts.Add(@{ Stop = $true }); $i += 2; continue }
            $Parser.Warnings.Add("warning: unrecognized escape ``\$next'")
            [void]$literal.Append('\').Append($next)
            $i += 2
            continue
        }
        if ($char -cne '%' -or $i + 1 -ge $Format.Length) { [void]$literal.Append($char); $i++; continue }
        $match = [regex]::Match($Format.Substring($i + 1), '^([-+ #0]*)(\d*)(?:\.(\d*))?([ACT]?)(.?)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
        $kind = $match.Groups[4].Value
        $letter = $match.Groups[5].Value
        $valid = $true
        if ($kind.Length -gt 0) { $valid = $letter.Length -gt 0 -and ($letter -cmatch '^[aAbBcCdDeFgGhHIjklmMnprsStTuUVwWxXyYzZ+@]$') }
        elseif ($letter -ceq '%') { [void]$literal.Append('%'); $i += 2; continue }
        else { $valid = $letter -cmatch '^[abcdDfFgGhHiklmMnpPsStuUyY]$' -or ($letter -cmatch '^[ACT]$') }
        if (-not $valid -or $letter.Length -eq 0) {
            # GNU prints an unknown directive as written, including the character after it.
            $shown = $kind + $letter
            $Parser.Warnings.Add("warning: unrecognized format directive ``%$shown'")
            [void]$literal.Append('%').Append($shown)
            $i += 1 + $match.Groups[1].Length + $match.Groups[2].Length + $match.Groups[3].Length + [int]$match.Groups[3].Success + $shown.Length
            continue
        }
        . $flush
        $parts.Add(@{ Flags = $match.Groups[1].Value; Width = $match.Groups[2].Value; Precision = $match.Groups[3].Value; Precise = $match.Groups[3].Success; Kind = $kind; Letter = $letter })
        $i += 1 + $match.Length
    }
    . $flush
    , $parts.ToArray()
}

function script:Format-GnuFindTime {
    <#
    .SYNOPSIS
    A time for %a %c %t and %Ak %Ck %Tk; seconds carry GNU's ten fraction digits.
    #>

    param([Parameter(Mandatory)][datetime]$Time, [AllowEmptyString()][string]$Letter)

    $local = [System.DateTimeOffset]::new($Time.ToLocalTime())
    $fraction = (($Time.Ticks % 10000000).ToString('0000000', [System.Globalization.CultureInfo]::InvariantCulture)) + '000'
    switch -CaseSensitive ($Letter) {
        '' { return (Format-GnuDate -Time $local -Format '%a %b %e %H:%M:%S') + ".$fraction " + (Format-GnuDate -Time $local -Format '%Y') }
        '@' { return [string]$local.ToUnixTimeSeconds() + ".$fraction" }
        '+' { return (Format-GnuDate -Time $local -Format '%Y-%m-%d+%H:%M:%S') + ".$fraction" }
        'S' { return (Format-GnuDate -Time $local -Format '%S') + ".$fraction" }
        'T' { return (Format-GnuDate -Time $local -Format '%H:%M:%S') + ".$fraction" }
    }
    Format-GnuDate -Time $local -Format "%$Letter"
}

function script:Get-GnuFindDirective {
    <#
    .SYNOPSIS
    The text of one -printf directive for an entry.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Entry, [Parameter(Mandatory)][hashtable]$Part)

    $invariant = [System.Globalization.CultureInfo]::InvariantCulture
    $item = Get-GnuFindItem -Entry $Entry
    $key = $Part.Letter
    if ($Part.Kind.Length -gt 0) { $key = $Part.Kind }
    if ('a', 'A', 't', 'T', 'c', 'C' -ccontains $key) {
        if ($null -eq $item) { return '' }
        $field = $key.ToLowerInvariant()
        if ($field -ceq 't') { $field = 'm' }
        $time = Get-GnuFindTime -Item $item -Field $field
        $letter = ''
        if ($Part.Kind.Length -gt 0) { $letter = $Part.Letter }
        return Format-GnuFindTime -Time $time -Letter $letter
    }
    $size = Get-GnuFindSize -Entry $Entry
    $blocks = [Math]::Ceiling($size / 4096) * 8
    if ((Get-GnuFindType -Entry $Entry -Follow $Entry.Follow) -ceq 'd' -and (Test-GnuWindows)) { $blocks = 0 }
    $trimmed = $Entry.Path.TrimEnd('/', '\')
    switch -CaseSensitive ($Part.Letter) {
        'p' { return $Entry.Path }
        'f' { return $Entry.Name }
        'h' {
            $cut = $trimmed.LastIndexOf('/')
            if ($trimmed.Length -eq 0 -and $Entry.Path.StartsWith('/', [System.StringComparison]::Ordinal)) { return '/' }
            if ($cut -lt 0) { return '.' }
            if ($cut -eq 0) { return '/' }
            return $trimmed.Substring(0, $cut)
        }
        'H' { return $Entry.Start }
        'P' {
            if ($Entry.Depth -eq 0) { return '' }
            return $Entry.Path.Substring($Entry.Start.TrimEnd('/', '\').Length + 1)
        }
        'd' { return [string]$Entry.Depth }
        's' { return ([long]$size).ToString($invariant) }
        'k' { return ([long]($blocks / 2)).ToString($invariant) }
        'b' { return ([long]$blocks).ToString($invariant) }
        'S' {
            if ($size -eq 0) { return '1' }
            return ($blocks * 512 / $size).ToString('0.######', $invariant)
        }
        'y' { return Get-GnuFindType -Entry $Entry -Follow $false }
        'Y' {
            $type = Get-GnuFindType -Entry $Entry -Follow $true
            if ($type -ceq 'l') { return 'N' }
            return $type
        }
        'l' { return [string](Get-GnuLinkTarget -Path $Entry.Full) }
        'n' { return '1' }
        'i' { return '0' }
        'D' { return '0' }
        'F' {
            $volume = Get-GnuFindVolume -State $State -Path ([System.IO.Path]::GetFullPath($Entry.Full).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar)
            foreach ($drive in [System.IO.DriveInfo]::GetDrives()) { if ($drive.Name -eq $volume -and $drive.IsReady) { return ([string]$drive.DriveFormat).ToLowerInvariant() } }
            return 'unknown'
        }
        { 'm', 'M', 'u', 'U', 'g', 'G' -ccontains $_ } {
            if ($null -eq $item) { return '' }
            if ($_ -ceq 'M') { return Get-GnuLsMode -Item $item }
            if ($_ -ceq 'm') {
                $mode = Get-GnuLsMode -Item $item
                $value = 0
                for ($k = 1; $k -lt 10; $k++) { if ($mode[$k] -ne '-') { $value += [int][Math]::Pow(2, 9 - $k) } }
                return [Convert]::ToString($value, 8)
            }
            $owner = Get-GnuLsOwner -Item $item
            switch -CaseSensitive ($_) {
                'u' { return $owner.Owner }
                'g' { return $owner.Group }
                'U' { return Get-GnuStatId -Name $owner.Owner }
            }
            return Get-GnuStatId -Name $owner.Group -Group
        }
    }
    ''
}

function script:Format-GnuFindPrintf {
    <#
    .SYNOPSIS
    The text -printf writes for an entry.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Entry, [Parameter(Mandatory)][hashtable[]]$Parts)

    $builder = New-Object System.Text.StringBuilder
    foreach ($part in $Parts) {
        if ($part.ContainsKey('Stop')) { break }
        if ($part.ContainsKey('Text')) { [void]$builder.Append($part.Text); continue }
        $text = Get-GnuFindDirective -State $State -Entry $Entry -Part $part
        if ($part.Precise) {
            $precision = 0
            if ($part.Precision.Length -gt 0) { $precision = [int]$part.Precision }
            if ($text.Length -gt $precision) { $text = $text.Substring(0, $precision) }
        }
        if ($part.Width.Length -gt 0) {
            $width = [int]$part.Width
            if ($part.Flags.Contains('-')) { $text = $text.PadRight($width) }
            elseif ($part.Flags.Contains('0') -and $text -match '^\d+$') { $text = $text.PadLeft($width, '0') }
            else { $text = $text.PadLeft($width) }
        }
        [void]$builder.Append($text)
    }
    $builder.ToString()
}

function script:Get-GnuFindTime {
    <#
    .SYNOPSIS
    The access, modification or change time of an item. .NET has no change time: the later of creation and modification.
    #>

    param([Parameter(Mandatory)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory)][string]$Field)
    switch -CaseSensitive ($Field) {
        'a' { return $Item.LastAccessTimeUtc }
        'c' {
            if ($Item.CreationTimeUtc -gt $Item.LastWriteTimeUtc) { return $Item.CreationTimeUtc }
            return $Item.LastWriteTimeUtc
        }
    }
    $Item.LastWriteTimeUtc
}

function script:Read-GnuFindPredicate {
    <#
    .SYNOPSIS
    One test, action or option of the expression.
    #>

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

    $name = Get-GnuFindToken -Parser $Parser
    if ($name.Length -lt 2 -or $name[0] -ne '-') {
        throw [System.FormatException]::new("paths must precede expression: ``$name'")
    }
    $Parser.Pos++
    $node = @{ Kind = 'test'; Name = $name; Value = $null }
    switch -CaseSensitive -Regex ($name) {
        '^-(true|false)$' { }
        '^-(name|iname|lname|ilname)$' {
            $pattern = Read-GnuFindArgument -Parser $Parser -Name $name
            $node.Value = New-GnuFindPattern -Pattern $pattern -IgnoreCase ($name -clike '-i*')
        }
        '^-(path|ipath|wholename|iwholename)$' {
            $pattern = Read-GnuFindArgument -Parser $Parser -Name $name
            $node.Value = New-GnuFindPattern -Pattern $pattern -IgnoreCase ($name -clike '-i*')
        }
        '^-(regex|iregex)$' {
            $pattern = Read-GnuFindArgument -Parser $Parser -Name $name
            $node.Value = New-GnuFindRegex -Parser $Parser -Pattern $pattern -IgnoreCase ($name -ceq '-iregex')
        }
        '^-(type|xtype)$' {
            $text = Read-GnuFindArgument -Parser $Parser -Name $name
            if ($text.Length -eq 0) { throw [System.FormatException]::new("missing argument to ``$name'") }
            $node.Value = ConvertFrom-GnuFindTypeList -Name $name -Text $text
        }
        '^-size$' {
            $text = Read-GnuFindArgument -Parser $Parser -Name $name
            # Like GNU: a last character that is not a digit is the unit, the rest must be a number.
            if ($text.Length -gt 0 -and $text[$text.Length - 1] -notmatch '\d' -and 'bcwkMG'.IndexOf($text[$text.Length - 1]) -lt 0) {
                throw [System.FormatException]::new("invalid -size type ``$($text[$text.Length - 1])'")
            }
            if ($text -cnotmatch '^([+-]?)(\d+)([bcwkMG]?)$') { throw [System.FormatException]::new("Invalid argument ``$text' to -size") }
            $units = @{ '' = 512; b = 512; c = 1; w = 2; k = 1024; M = 1048576; G = 1073741824 }
            $node.Value = @{ Sign = $Matches[1]; Number = [long]$Matches[2]; Unit = [double]$units[$Matches[3]] }
        }
        '^-[amc](time|min)$' {
            $text = Read-GnuFindArgument -Parser $Parser -Name $name
            $number = ConvertFrom-GnuFindNumber -Text $text
            if ($null -eq $number) { throw [System.FormatException]::new("invalid argument ``$text' to ``$name'") }
            $number.Unit = 86400
            if ($name -clike '*min') { $number.Unit = 60 }
            $number.Field = $name.Substring(1, 1)
            $number.Reference = $Parser.Now
            if ($Parser.DayStart -and $number.Unit -eq 86400) { $number.Reference = $Parser.Tomorrow }
            $node.Value = $number
        }
        '^-[ac]?newer$' {
            $file = Read-GnuFindArgument -Parser $Parser -Name $name
            $path = Resolve-GnuPath -Path $file
            if (-not ([System.IO.File]::Exists($path) -or [System.IO.Directory]::Exists($path))) { throw [System.FormatException]::new("'$file': No such file or directory") }
            $node.Value = (Get-Item -LiteralPath $path -Force).LastWriteTimeUtc
        }
        '^-empty$' { }
        '^-(print|print0|prune|quit|delete)$' {
            $node.Kind = 'action'
            if ($name -ceq '-delete') { $Parser.Delete = $true; $Parser.Depth = $true }
            if ($name -ceq '-prune') { $Parser.Prune = $true }
            if ($name -ceq '-print0') { $Parser.Nul = $true }
        }
        '^-printf$' {
            $node.Kind = 'action'
            $node.Value = ConvertFrom-GnuFindFormat -Parser $Parser -Format (Read-GnuFindArgument -Parser $Parser -Name $name)
        }
        '^-fprintf$' {
            $node.Kind = 'action'
            $file = Read-GnuFindArgument -Parser $Parser -Name $name
            $format = Read-GnuFindArgument -Parser $Parser -Name $name
            try { $writer = New-Object System.IO.StreamWriter -ArgumentList (Resolve-GnuPath -Path $file), $false, $script:GnuUtf8 }
            catch { throw [System.FormatException]::new("$($file): $(Get-GnuErrorReason -Exception $_.Exception)") }
            $Parser.Writers.Add($writer)
            $node.Value = @{ Writer = $writer; Parts = (ConvertFrom-GnuFindFormat -Parser $Parser -Format $format) }
        }
        '^-fprint0?$' {
            $node.Kind = 'action'
            $file = Read-GnuFindArgument -Parser $Parser -Name $name
            $path = Resolve-GnuPath -Path $file
            try { $node.Value = New-Object System.IO.StreamWriter -ArgumentList $path, $false, $script:GnuUtf8 }
            catch { throw [System.FormatException]::new("$($file): $(Get-GnuErrorReason -Exception $_.Exception)") }
            $Parser.Writers.Add($node.Value)
        }
        '^-(exec|execdir|ok|okdir)$' {
            $node.Kind = 'action'
            $node.Batch = $false
            Read-GnuFindExec -Parser $Parser -Node $node
            $Parser.Execs.Add($node)
        }
        '^-(maxdepth|mindepth)$' {
            $text = Read-GnuFindArgument -Parser $Parser -Name $name
            if ($text -notmatch '^\+?\d+$') { throw [System.FormatException]::new("Expected a positive decimal integer argument to $name, but got '$text'") }
            $Parser[$name.Substring(1)] = [int]$text
            $node.Kind = 'option'
        }
        '^-(depth|d)$' { $Parser.Depth = $true; $Parser.ExplicitDepth = $true; $node.Kind = 'option' }
        '^-(xdev|mount)$' { $Parser.Xdev = $true; $node.Kind = 'option' }
        '^-follow$' { $Parser.Follow = 'L'; $node.Kind = 'option' }
        '^-daystart$' { $Parser.DayStart = $true; $node.Kind = 'option' }
        '^-regextype$' {
            $type = Read-GnuFindArgument -Parser $Parser -Name $name
            $types = @{
                'findutils-default' = 'emacs'; 'emacs' = 'emacs'; 'gnu-awk' = 'extended'; 'awk' = 'extended'; 'posix-awk' = 'extended'; 'egrep' = 'extended'
                'posix-egrep' = 'extended'; 'posix-extended' = 'extended'; 'ed' = 'basic'; 'grep' = 'basic'; 'posix-basic' = 'basic'; 'posix-minimal-basic' = 'basic'; 'sed' = 'basic'
            }
            if (-not $types.ContainsKey($type)) {
                throw [System.FormatException]::new("Unknown regular expression type '$type'; valid types are 'findutils-default', 'awk', 'ed', 'egrep', 'emacs', 'gnu-awk', 'grep', 'posix-awk', 'posix-basic', 'posix-egrep', 'posix-extended', 'posix-minimal-basic', 'sed'.")
            }
            $Parser.RegexType = $types[$type]
            $node.Kind = 'option'
        }
        '^-(noleaf|warn|nowarn|ignore_readdir_race|noignore_readdir_race)$' { $node.Kind = 'option' }
        '^-(ls|fls|perm|user|group|uid|gid|nouser|nogroup|links|inum|samefile|used|readable|writable|executable|context|files0-from|newer[amcBt][amcBt])$' {
            throw [System.FormatException]::new("$name is not supported by PSGnuUtils")
        }
        default { throw [System.FormatException]::new("unknown predicate ``$name'") }
    }
    $node
}

function script:Read-GnuFindPrimary {
    <#
    .SYNOPSIS
    ( EXPR ), ! EXPR or a predicate.
    #>

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

    $token = Get-GnuFindToken -Parser $Parser
    if ($token -ceq '!' -or $token -ceq '-not') {
        $Parser.Pos++
        $next = Get-GnuFindToken -Parser $Parser
        if ($null -eq $next -or $next -ceq ')') { throw [System.FormatException]::new("expected an expression after '$token'") }
        return @{ Kind = 'not'; Child = (Read-GnuFindPrimary -Parser $Parser) }
    }
    if ($token -ceq '(') {
        $Parser.Pos++
        if ((Get-GnuFindToken -Parser $Parser) -ceq ')') { throw [System.FormatException]::new('invalid expression; empty parentheses are not allowed.') }
        if ($null -eq (Get-GnuFindToken -Parser $Parser)) { throw [System.FormatException]::new("invalid expression; expected to find a ')' but didn't see one. Perhaps you need an extra predicate after '('") }
        $inner = Read-GnuFindComma -Parser $Parser
        if ((Get-GnuFindToken -Parser $Parser) -cne ')') { throw [System.FormatException]::new("invalid expression; expected to find a ')' but didn't see one. Perhaps you need an extra predicate after '('") }
        $Parser.Pos++
        return $inner
    }
    if ('-o', '-or', '-a', '-and', ',' -ccontains $token) { throw [System.FormatException]::new("invalid expression; you have used a binary operator '$token' with nothing before it.") }
    Read-GnuFindPredicate -Parser $Parser
}

function script:Read-GnuFindAnd {
    <#
    .SYNOPSIS
    EXPR -a EXPR, and adjacent expressions (implicit -a).
    #>

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

    $left = Read-GnuFindPrimary -Parser $Parser
    while ($true) {
        $token = Get-GnuFindToken -Parser $Parser
        if ($null -eq $token -or ')', '-o', '-or', ',' -ccontains $token) { return $left }
        if ($token -ceq '-a' -or $token -ceq '-and') {
            $Parser.Pos++
            $next = Get-GnuFindToken -Parser $Parser
            if ($null -eq $next) { throw [System.FormatException]::new("expected an expression after '$token'") }
            if (')', '-o', '-or', ',' -ccontains $next) { throw [System.FormatException]::new("invalid expression; you have used a binary operator '$token' with nothing after it.") }
        }
        $left = @{ Kind = 'and'; Left = $left; Right = (Read-GnuFindPrimary -Parser $Parser) }
    }
}

function script:Read-GnuFindOr {
    <#
    .SYNOPSIS
    EXPR -o EXPR.
    #>

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

    $left = Read-GnuFindAnd -Parser $Parser
    while ((Get-GnuFindToken -Parser $Parser) -ceq '-o' -or (Get-GnuFindToken -Parser $Parser) -ceq '-or') {
        $token = Get-GnuFindToken -Parser $Parser
        $Parser.Pos++
        $next = Get-GnuFindToken -Parser $Parser
        if ($null -eq $next) { throw [System.FormatException]::new("expected an expression after '$token'") }
        if (')', ',' -ccontains $next) { throw [System.FormatException]::new("invalid expression; you have used a binary operator '$token' with nothing after it.") }
        $left = @{ Kind = 'or'; Left = $left; Right = (Read-GnuFindAnd -Parser $Parser) }
    }
    $left
}

function script:Read-GnuFindComma {
    <#
    .SYNOPSIS
    EXPR , EXPR: both are evaluated, the value is the right one.
    #>

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

    $left = Read-GnuFindOr -Parser $Parser
    while ((Get-GnuFindToken -Parser $Parser) -ceq ',') {
        $Parser.Pos++
        $next = Get-GnuFindToken -Parser $Parser
        if ($null -eq $next) { throw [System.FormatException]::new("expected an expression after ','") }
        if ($next -ceq ')') { throw [System.FormatException]::new("invalid expression; you have used a binary operator ',' with nothing after it.") }
        $left = @{ Kind = 'comma'; Left = $left; Right = (Read-GnuFindOr -Parser $Parser) }
    }
    $left
}

function script:Test-GnuFindAction {
    <#
    .SYNOPSIS
    $true if the expression has an action that prints or runs something (then there is no default -print).
    #>

    param([Parameter(Mandatory)][hashtable]$Node)
    switch ($Node.Kind) {
        'not' { return Test-GnuFindAction -Node $Node.Child }
        { 'and', 'or', 'comma' -ccontains $_ } { return (Test-GnuFindAction -Node $Node.Left) -or (Test-GnuFindAction -Node $Node.Right) }
        'action' { return '-prune', '-quit' -cnotcontains $Node.Name }
    }
    $false
}

function script:Get-GnuFindType {
    <#
    .SYNOPSIS
    The type letter of an entry (f d l p s b c), following a link when Follow is set.
    #>

    param([Parameter(Mandatory)][hashtable]$Entry, [bool]$Follow)

    $key = "Type$Follow"
    if ($Entry.ContainsKey($key)) { return $Entry[$key] }
    $type = 'l'
    $isLink = $null -ne (Get-GnuLinkTarget -Path $Entry.Full)
    # A dangling link stays a link when followed (File.Exists says true for one on Unix).
    $dangling = $isLink -and $Follow -and (Get-GnuCanonicalPath -Path $Entry.Full -Require 'all').ContainsKey('Error')
    if (-not ($isLink -and -not $Follow) -and -not $dangling) {
        if ([System.IO.Directory]::Exists($Entry.Full)) { $type = 'd' }
        elseif ([System.IO.File]::Exists($Entry.Full)) {
            $type = 'f'
            if (-not $isLink) {
                $mode = Get-GnuUnixModeString -Path $Entry.Full
                if (-not [string]::IsNullOrEmpty($mode) -and 'p', 's', 'b', 'c' -ccontains [string]$mode[0]) { $type = [string]$mode[0] }
            }
        }
    }
    $Entry[$key] = $type
    $type
}

function script:Get-GnuFindItem {
    <#
    .SYNOPSIS
    The FileSystemInfo for size and time tests: the link itself, or its target when following.
    #>

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

    if ($Entry.ContainsKey('Item')) { return $Entry.Item }
    $item = $null
    $path = $Entry.Full
    if ($Entry.Follow -and $null -ne (Get-GnuLinkTarget -Path $path)) {
        $resolved = Get-GnuCanonicalPath -Path $path -Require 'all'
        if ($resolved.ContainsKey('Path')) { $path = $resolved.Path }
    }
    $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
    $Entry.Item = $item
    $item
}

function script:Get-GnuFindSize {
    <#
    .SYNOPSIS
    The size -size and -empty see: a file's length, a link's target text, 4096 for directories off Windows.
    #>

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

    $type = Get-GnuFindType -Entry $Entry -Follow $Entry.Follow
    if ($type -ceq 'l') { return [double]"$(Get-GnuLinkTarget -Path $Entry.Full)".Length }
    if ($type -ceq 'd') {
        if (Test-GnuWindows) { return [double]0 }
        return [double]4096
    }
    $item = Get-GnuFindItem -Entry $Entry
    if ($null -eq $item -or $item -isnot [System.IO.FileInfo]) { return [double]0 }
    [double]$item.Length
}

function script:Test-GnuFindNumber {
    <#
    .SYNOPSIS
    Compares a value with +N, -N or N.
    #>

    param([Parameter(Mandatory)][hashtable]$Test, [double]$Value)
    switch ($Test.Sign) {
        '+' { return $Value -gt $Test.Number }
        '-' { return $Value -lt $Test.Number }
    }
    $Value -eq $Test.Number
}

function script:Invoke-GnuFindCommand {
    <#
    .SYNOPSIS
    Runs one -exec / -execdir / -ok command. Returns $true if it exited with 0.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string[]]$Words, [AllowNull()][string]$Directory)

    Clear-GnuFindOutput -State $State
    $command = Resolve-GnuExecCommand -Name $Words[0]
    if ($null -eq $command) {
        # In GNU the child process fails, not find: the test is false and find's exit status stays.
        Write-GnuError -Context $State.Context -Message "'$($Words[0])': No such file or directory" -ExitCode 0
        return $false
    }
    $rest = @()
    if ($Words.Count -gt 1) { $rest = @($Words[1..($Words.Count - 1)]) }
    $child = $State.Context.Clone()
    $child.ExitCode = 0
    $pushed = $false
    try {
        if (-not [string]::IsNullOrEmpty($Directory)) { Push-Location -LiteralPath $Directory; $pushed = $true }
        # Collected, not written: output inside the evaluation would become the value of the expression.
        foreach ($item in @(Invoke-GnuChildCommand -Context $child -Command $command -Arguments $rest -InputObject $null)) { $State.Lines.Add($item) }
    }
    finally { if ($pushed) { Pop-Location } }
    $child.ExitCode -eq 0
}

function script:Invoke-GnuFindExec {
    <#
    .SYNOPSIS
    The -exec family for one entry.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Node, [Parameter(Mandatory)][hashtable]$Entry)

    $inDirectory = $Node.Name -clike '*dir'
    $directory = $null
    $name = $Entry.Path
    if ($inDirectory) {
        $directory = [System.IO.Path]::GetDirectoryName($Entry.Full)
        if ([string]::IsNullOrEmpty($directory)) { $directory = $Entry.Full }
        $name = './' + [System.IO.Path]::GetFileName($Entry.Full.TrimEnd('\', '/'))
        if ($Entry.Depth -eq 0 -and [System.IO.Directory]::Exists($Entry.Full) -and $Entry.Path -match '^\.?/?$') { $directory = $Entry.Full; $name = '.' }
    }
    if ($Node.Batch) {
        if ($inDirectory -and $null -ne $Node.PendingDir -and $Node.PendingDir -cne $directory) { Send-GnuFindBatch -State $State -Node $Node }
        $Node.PendingDir = $directory
        $Node.Pending.Add($name)
        $length = 0
        foreach ($item in $Node.Pending) { $length += $item.Length + 1 }
        if ($length -gt $State.Limit) { Send-GnuFindBatch -State $State -Node $Node }
        return $true
    }
    $words = [string[]]@($Node.Words | ForEach-Object { $_.Replace('{}', $name) })
    if ($Node.Name -clike '-ok*') {
        Clear-GnuFindOutput -State $State
        if (-not (Read-GnuFindAnswer -Question "< $($Node.Words[0]) ... $name > ? ")) { return $false }
    }
    Invoke-GnuFindCommand -State $State -Words $words -Directory $directory
}

function script:Read-GnuFindAnswer {
    <#
    .SYNOPSIS
    The -ok prompt on standard error; $true if the answer starts with y.
    #>

    param([Parameter(Mandatory)][string]$Question)
    [System.Console]::Error.Write($Question)
    $answer = [System.Console]::In.ReadLine()
    $null -ne $answer -and $answer -match '^\s*[yY]'
}

function script:Send-GnuFindBatch {
    <#
    .SYNOPSIS
    Runs a collected -exec ... + command; a failure makes find exit with 1.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Node)
    if ($Node.Pending.Count -eq 0) { return }
    $words = [string[]](@($Node.Words) + $Node.Pending.ToArray())
    $Node.Pending.Clear()
    if (-not (Invoke-GnuFindCommand -State $State -Words $words -Directory $Node.PendingDir)) { $State.Context.ExitCode = 1 }
}

function script:Add-GnuFindOutput {
    <#
    .SYNOPSIS
    Collects a path with its terminator: lines for the walk to write, NUL-terminated output as one string.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Text, [Parameter(Mandatory)][string]$Terminator)
    if (-not $State.Nul -and $State.Buffer.Length -eq 0) { $State.Lines.Add($Text); return }
    [void]$State.Buffer.Append($Text).Append($Terminator)
}

function script:Add-GnuFindRaw {
    <#
    .SYNOPSIS
    Collects -printf text, which carries its own line ends.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [AllowEmptyString()][string]$Text)
    [void]$State.Buffer.Append($Text)
    if (-not $State.Nul -and $State.Buffer.ToString().EndsWith("`n", [System.StringComparison]::Ordinal)) { Clear-GnuFindOutput -State $State }
}

function script:Write-GnuFindLine {
    <#
    .SYNOPSIS
    Writes what the last evaluation collected.
    #>

    param([Parameter(Mandatory)][hashtable]$State)
    foreach ($line in $State.Lines) { $line }
    $State.Lines.Clear()
}

function script:Clear-GnuFindOutput {
    <#
    .SYNOPSIS
    Moves collected NUL-terminated output to the lines, before a command adds its own output.
    #>

    param([Parameter(Mandatory)][hashtable]$State)
    if ($State.Buffer.Length -eq 0) { return }
    foreach ($line in @(ConvertTo-GnuOutputLine -Text $State.Buffer.ToString())) { $State.Lines.Add($line) }
    [void]$State.Buffer.Clear()
}

function script:Test-GnuFindNode {
    <#
    .SYNOPSIS
    Evaluates the expression for one entry.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Node, [Parameter(Mandatory)][hashtable]$Entry)

    if ($State.Quit) { return $false }
    switch -CaseSensitive ($Node.Kind) {
        'and' { return (Test-GnuFindNode -State $State -Node $Node.Left -Entry $Entry) -and (Test-GnuFindNode -State $State -Node $Node.Right -Entry $Entry) }
        'or' { return (Test-GnuFindNode -State $State -Node $Node.Left -Entry $Entry) -or (Test-GnuFindNode -State $State -Node $Node.Right -Entry $Entry) }
        'comma' { $null = Test-GnuFindNode -State $State -Node $Node.Left -Entry $Entry; return Test-GnuFindNode -State $State -Node $Node.Right -Entry $Entry }
        'not' { return -not (Test-GnuFindNode -State $State -Node $Node.Child -Entry $Entry) }
        'option' { return $true }
    }
    $value = $Node.Value
    switch -CaseSensitive -Regex ($Node.Name) {
        '^-true$' { return $true }
        '^-false$' { return $false }
        '^-i?name$' { return $value.IsMatch($Entry.Name) }
        '^-i?(path|wholename)$' { return $value.IsMatch($Entry.Path) }
        '^-i?regex$' { return $value.IsMatch($Entry.Path) }
        '^-i?lname$' {
            $target = Get-GnuLinkTarget -Path $Entry.Full
            return $null -ne $target -and $value.IsMatch([string]$target)
        }
        '^-type$' { return $value -ccontains (Get-GnuFindType -Entry $Entry -Follow $Entry.Follow) }
        '^-xtype$' { return $value -ccontains (Get-GnuFindType -Entry $Entry -Follow (-not $Entry.Follow)) }
        '^-size$' { return Test-GnuFindNumber -Test $value -Value ([Math]::Ceiling((Get-GnuFindSize -Entry $Entry) / $value.Unit)) }
        '^-empty$' {
            $type = Get-GnuFindType -Entry $Entry -Follow $Entry.Follow
            if ($type -ceq 'd') {
                try { return @(Get-GnuFindEntryName -Path $Entry.Full).Count -eq 0 }
                catch { return $false }
            }
            return $type -ceq 'f' -and (Get-GnuFindSize -Entry $Entry) -eq 0
        }
        '^-[amc](time|min)$' {
            $item = Get-GnuFindItem -Entry $Entry
            if ($null -eq $item) { return $false }
            $time = Get-GnuFindTime -Item $item -Field $value.Field
            return Test-GnuFindNumber -Test $value -Value ([Math]::Floor(($value.Reference - $time).TotalSeconds / $value.Unit))
        }
        '^-[ac]?newer$' {
            $item = Get-GnuFindItem -Entry $Entry
            if ($null -eq $item) { return $false }
            $time = Get-GnuFindTime -Item $item -Field 'm'
            if ($Node.Name -ceq '-anewer') { $time = Get-GnuFindTime -Item $item -Field 'a' }
            if ($Node.Name -ceq '-cnewer') { $time = Get-GnuFindTime -Item $item -Field 'c' }
            return $time -gt $value
        }
        '^-print$' { Add-GnuFindOutput -State $State -Text $Entry.Path -Terminator "`n"; return $true }
        '^-print0$' { Add-GnuFindOutput -State $State -Text $Entry.Path -Terminator "`0"; return $true }
        '^-fprint$' { $value.Write($Entry.Path + "`n"); return $true }
        '^-printf$' { Add-GnuFindRaw -State $State -Text (Format-GnuFindPrintf -State $State -Entry $Entry -Parts $value); return $true }
        '^-fprintf$' { $value.Writer.Write((Format-GnuFindPrintf -State $State -Entry $Entry -Parts $value.Parts)); return $true }
        '^-fprint0$' { $value.Write($Entry.Path + "`0"); return $true }
        '^-prune$' { if (-not $State.Depth) { $State.Pruned = $true }; return $true }
        '^-quit$' { $State.Quit = $true; return $true }
        '^-delete$' { return Remove-GnuFindEntry -State $State -Entry $Entry }
        '^-(exec|execdir|ok|okdir)$' { return Invoke-GnuFindExec -State $State -Node $Node -Entry $Entry }
    }
}

function script:Remove-GnuFindEntry {
    <#
    .SYNOPSIS
    The -delete action. The starting point '.' is left alone, as in GNU.
    #>

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

    if ($Entry.Path -ceq '.') { return $true }
    try {
        $isLink = $null -ne (Get-GnuLinkTarget -Path $Entry.Full)
        if ([System.IO.Directory]::Exists($Entry.Full)) {
            if (-not $isLink -and @([System.IO.Directory]::EnumerateFileSystemEntries($Entry.Full) | Select-Object -First 1).Count -gt 0) {
                Write-GnuError -Context $State.Context -Message "cannot delete '$($Entry.Path)': Directory not empty"
                return $false
            }
            [System.IO.Directory]::Delete($Entry.Full, $false)
        }
        else {
            $attributes = [System.IO.File]::GetAttributes($Entry.Full)
            if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0 -and (Test-GnuWindows)) { [System.IO.File]::SetAttributes($Entry.Full, $attributes -bxor [System.IO.FileAttributes]::ReadOnly) }
            [System.IO.File]::Delete($Entry.Full)
        }
        $true
    }
    catch {
        Write-GnuError -Context $State.Context -Message "cannot delete '$($Entry.Path)': $(Get-GnuErrorReason -Exception $_.Exception)"
        $false
    }
}

function script:Get-GnuFindVolume {
    <#
    .SYNOPSIS
    The drive or mount root a path is on (-xdev): the longest one that contains it.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Path)
    $best = ''
    foreach ($root in $State.Volumes) {
        if ($Path.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase) -and $root.Length -gt $best.Length) { $best = $root }
    }
    $best
}

function script:Get-GnuFindEntryName {
    <#
    .SYNOPSIS
    The names in a directory, in ordinal order (GNU uses the order of the file system, which differs everywhere).
    #>

    param([Parameter(Mandatory)][string]$Path)
    $names = [string[]]@([System.IO.Directory]::GetFileSystemEntries($Path) | ForEach-Object { [System.IO.Path]::GetFileName($_) })
    [Array]::Sort($names, [System.StringComparer]::Ordinal)
    $names
}

function script:Invoke-GnuFindWalk {
    <#
    .SYNOPSIS
    Visits one entry and, for directories, everything below it.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Full, [int]$Depth, [string]$Name)

    $follow = $State.Follow -ceq 'L' -or ($State.Follow -ceq 'H' -and $Depth -eq 0)
    $entry = @{ Path = $Path; Full = $Full; Depth = $Depth; Name = $Name; Follow = $follow; Start = $State.Start }
    $type = Get-GnuFindType -Entry $entry -Follow $follow
    $evaluate = $Depth -ge $State.MinDepth
    $State.Pruned = $false
    if ($evaluate -and -not $State.Depth) {
        $null = Test-GnuFindNode -State $State -Node $State.Expression -Entry $entry
        Write-GnuFindLine -State $State
    }
    if ($type -ceq 'd' -and -not $State.Pruned -and -not $State.Quit -and $Depth -lt $State.MaxDepth) {
        $canonical = $Full
        if ($follow) {
            $resolved = Get-GnuCanonicalPath -Path $Full -Require 'all'
            if ($resolved.ContainsKey('Path')) { $canonical = $resolved.Path }
            if ($State.Active.ContainsKey($canonical)) {
                Write-GnuError -Context $State.Context -Message "File system loop detected; '$Path' is part of the same file system loop as '$($State.Active[$canonical])'."
                return
            }
        }
        $skip = $State.Xdev -and $Depth -gt 0 -and (Get-GnuFindVolume -State $State -Path ($canonical.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar)) -cne $State.Volume
        if (-not $skip) {
            $State.Active[$canonical] = $Path
            try {
                foreach ($child in @(Get-GnuFindEntryName -Path $Full)) {
                    if ($State.Quit) { break }
                    Invoke-GnuFindWalk -State $State -Path (Join-GnuDisplayPath -Directory $Path -Leaf $child) -Full ([System.IO.Path]::Combine($Full, $child)) -Depth ($Depth + 1) -Name $child
                }
            }
            catch { Write-GnuError -Context $State.Context -Message "'$Path': $(Get-GnuErrorReason -Exception $_.Exception)" }
            finally { $State.Active.Remove($canonical) }
        }
    }
    if ($evaluate -and $State.Depth -and -not $State.Quit) {
        $null = Test-GnuFindNode -State $State -Node $State.Expression -Entry $entry
        Write-GnuFindLine -State $State
    }
}

function script:Invoke-GnuFindSearch {
    <#
    .SYNOPSIS
    Implementation of find.
    #>

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

    $tokens = [string[]]@($Context.Operands)
    $position = 0
    $follow = 'P'
    while ($position -lt $tokens.Count -and ($tokens[$position] -cmatch '^-[HLP]+$' -or $tokens[$position] -cmatch '^-O\d*$')) {
        if ($tokens[$position] -cmatch '^-[HLP]+$') { $follow = [string]$tokens[$position][$tokens[$position].Length - 1] }
        $position++
    }
    if ($position -lt $tokens.Count -and $tokens[$position] -ceq '--') { $position++ }
    $starts = New-Object 'System.Collections.Generic.List[string]'
    # As in GNU, ')' and ',' are file names here: only '-', '(' and '!' start the expression.
    while ($position -lt $tokens.Count -and -not (($tokens[$position].Length -gt 1 -and $tokens[$position][0] -eq '-') -or '(', '!' -ccontains $tokens[$position])) {
        $starts.Add($tokens[$position])
        $position++
    }
    if ($starts.Count -eq 0) { $starts.Add('.') }

    $now = [System.DateTime]::UtcNow
    $parser = @{
        Tokens = [string[]]@($tokens | Select-Object -Skip $position); Pos = 0; Follow = $follow; maxdepth = [int]::MaxValue; mindepth = 0; Depth = $false; Xdev = $false
        DayStart = $false; Now = $now; Tomorrow = [System.DateTime]::Today.AddDays(1).ToUniversalTime(); RegexType = 'emacs'; ExplicitDepth = $false; Warnings = New-Object 'System.Collections.Generic.List[string]'; Delete = $false; Prune = $false; Nul = $false
        Writers = New-Object 'System.Collections.Generic.List[System.IO.StreamWriter]'; Execs = New-Object 'System.Collections.Generic.List[hashtable]'
    }
    try {
        try {
            $expression = @{ Kind = 'test'; Name = '-true'; Value = $null }
            if ($parser.Tokens.Count -gt 0) { $expression = Read-GnuFindComma -Parser $parser }
            # The parser only stops early at a ')' without its '('.
            if ($parser.Pos -lt $parser.Tokens.Count) { throw [System.FormatException]::new("invalid expression; you have too many ')'") }
            if ($parser.Delete -and $parser.Prune -and -not $parser.ExplicitDepth) {
                throw [System.FormatException]::new('The -delete action automatically turns on -depth, but -prune does nothing when -depth is in effect. If you want to carry on anyway, just explicitly use the -depth option.')
            }
        }
        catch [System.FormatException] {
            Write-GnuError -Context $Context -Message $_.Exception.Message
            return
        }
        foreach ($warning in $parser.Warnings) { Write-GnuWarning -Context $Context -Message $warning }
        if (-not (Test-GnuFindAction -Node $expression)) { $expression = @{ Kind = 'and'; Left = $expression; Right = @{ Kind = 'action'; Name = '-print'; Value = $null } } }

        $limit = [long]131072
        if (Test-GnuWindows) { $limit = [long]32000 }
        $state = @{
            Context = $Context; Expression = $expression; Follow = $parser.Follow; MaxDepth = $parser.maxdepth; MinDepth = $parser.mindepth; Depth = $parser.Depth
            Xdev = $parser.Xdev; Volumes = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $_.Name }); Volume = ''; Pruned = $false; Quit = $false
            Nul = $parser.Nul; Buffer = New-Object System.Text.StringBuilder; Lines = New-Object 'System.Collections.Generic.List[object]'; Active = @{}; Limit = $limit; Start = ''
        }
        foreach ($start in $starts) {
            if ($state.Quit) { break }
            $full = Resolve-GnuPath -Path $start
            $exists = [System.IO.File]::Exists($full) -or [System.IO.Directory]::Exists($full) -or $null -ne (Get-GnuLinkTarget -Path $full)
            if ([string]::IsNullOrEmpty($start) -or -not $exists) {
                Write-GnuError -Context $Context -Message "'$start': No such file or directory"
                continue
            }
            $state.Volume = Get-GnuFindVolume -State $state -Path ([System.IO.Path]::GetFullPath($full).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar)
            $name = $start
            if ($start.TrimEnd('/', '\').Length -gt 0) { $name = [System.IO.Path]::GetFileName($start.TrimEnd('/', '\')) }
            $state.Start = $start
            Invoke-GnuFindWalk -State $state -Path $start -Full $full -Depth 0 -Name $name
        }
        foreach ($node in $parser.Execs) { if ($node.Batch) { Send-GnuFindBatch -State $state -Node $node } }
        Clear-GnuFindOutput -State $state
        Write-GnuFindLine -State $state
    }
    finally { foreach ($writer in $parser.Writers) { $writer.Dispose() } }
}