Utils/sed.ps1

Register-GnuUtil @{
    Name        = 'sed'
    Category    = 'Searching and comparing'
    Synopsis    = 'Stream editor for filtering and transforming text.'
    Usage       = @('sed [OPTION]... {script-only-if-no-other-script} [input-file]...')
    Description = @(
        'If no -e, --expression, -f, or --file option is given, then the first non-option',
        'argument is taken as the sed script to interpret. All remaining arguments are names',
        'of input files; if no input files are specified, then the standard input is read.'
    )
    Options     = @(
        @{ Short = 'n'; Long = 'quiet'; Key = 'quiet'; Help = 'suppress automatic printing of pattern space' }
        @{ Long = 'silent'; Key = 'quiet'; Help = 'same as --quiet' }
        @{ Short = 'e'; Long = 'expression'; Key = 'script'; Arg = 'script'; Multi = $true; Prefix = 'e:'; Help = 'add the script to the commands to be executed' }
        @{ Short = 'f'; Long = 'file'; Key = 'script'; Arg = 'script-file'; Multi = $true; Prefix = 'f:'; Help = 'add the contents of script-file to the commands to be executed' }
        @{ Long = 'follow-symlinks'; Help = 'follow symlinks when processing in place (always done)' }
        @{ Short = 'i'; Long = 'in-place'; Arg = 'SUFFIX'; OptionalArg = $true; Help = 'edit files in place (makes backup if SUFFIX supplied)' }
        @{ Short = 'b'; Long = 'binary'; Help = 'keep carriage returns: CR+LF is not treated as a line end' }
        @{ Short = 'l'; Long = 'line-length'; Arg = 'N'; Help = 'specify the desired line-wrap length for the `l'' command' }
        @{ Long = 'posix'; Help = 'accepted; GNU extensions stay enabled' }
        @{ Short = 'E'; Long = 'regexp-extended'; Key = 'extended'; Help = 'use extended regular expressions in the script' }
        @{ Short = 'r'; Key = 'extended'; Help = 'same as -E' }
        @{ Short = 's'; Long = 'separate'; Help = 'consider files as separate rather than as a single, continuous long stream' }
        @{ Long = 'sandbox'; Help = 'operate in sandbox mode (disable r/w commands)' }
        @{ Short = 'u'; Long = 'unbuffered'; Help = 'accepted; output is written line by line anyway' }
        @{ Short = 'z'; Long = 'null-data'; Help = 'separate lines by NUL characters' }
    )
    Notes       = @(
        'PowerShell notes: regular expressions run on .NET (POSIX syntax is translated). Input is read',
        'before the script starts, so `sed q` does not stop an endless pipeline. On Windows a line ending',
        'in CR+LF loses the CR unless -b is given, and -i writes CR+LF back. The e command and the e flag',
        'of s are not supported. Without a script the short usage is printed instead of the full help.'
    )
}



function script:New-GnuSedRegex {
    <#
    .SYNOPSIS
    Compiles a sed pattern; $null for the empty pattern (meaning: the last one used). Throws FormatException for bad patterns.
    #>

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

    if ([string]::IsNullOrEmpty($Pattern)) { return $null }
    $converted = ConvertTo-GnuDotNetRegex -Pattern (ConvertFrom-GnuSedEscape -Text $Pattern) -Extended:$Parser.Extended -AbsoluteEnd:(-not $Multiline)
    if ($converted.ContainsKey('Error')) { throw [System.FormatException]::new($converted.Error) }
    $options = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
    if ($IgnoreCase) { $options = $options -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase }
    if ($Multiline) { $options = $options -bor [System.Text.RegularExpressions.RegexOptions]::Multiline }
    try { return New-Object System.Text.RegularExpressions.Regex -ArgumentList $converted.Regex, $options }
    catch {
        # .NET messages are localized, so classify the error ourselves.
        $message = 'Invalid preceding regular expression'
        switch (Get-GnuParenthesisBalance -Regex $converted.Regex) {
            'open' { $message = 'Unmatched ( or \(' }
            'close' { $message = 'Unmatched ) or \)' }
            default { if ($converted.Regex -match '\\[1-9]') { $message = 'Invalid back reference' } }
        }
        throw [System.FormatException]::new($message)
    }
}

function script:Read-GnuSedEscapeCode {
    <#
    .SYNOPSIS
    Decodes the escape after the backslash at Text[Index] (\a \f \t \v \cX \dNNN \oNNN \xHH): @{ Text; Next }, or $null.
    #>

    param([Parameter(Mandatory)][string]$Text, [Parameter(Mandatory)][int]$Index)

    $letter = [string]$Text[$Index + 1]
    $single = ''
    switch -CaseSensitive ($letter) {
        'a' { $single = [string][char]7 }
        'f' { $single = [string][char]12 }
        'r' { $single = "`r" }
        't' { $single = "`t" }
        'v' { $single = [string][char]11 }
    }
    if ($single.Length -gt 0) { return @{ Text = $single; Next = $Index + 2 } }
    if ($letter -ceq 'c' -and $Index + 2 -lt $Text.Length) {
        return @{ Text = [string][char]([int][char]([string]$Text[$Index + 2]).ToUpperInvariant() -bxor 64); Next = $Index + 3 }
    }
    $rules = @{ d = @('^\d{1,3}', 10); o = @('^[0-7]{1,3}', 8); x = @('^[0-9A-Fa-f]{1,2}', 16) }
    if ('d', 'o', 'x' -ccontains $letter) {
        $digits = [regex]::Match($Text.Substring($Index + 2), $rules[$letter][0]).Value
        if ($digits.Length -gt 0) { return @{ Text = [string][char]([Convert]::ToInt32($digits, $rules[$letter][1]) -band 255); Next = $Index + 2 + $digits.Length } }
    }
    $null
}

function script:ConvertFrom-GnuSedEscape {
    <#
    .SYNOPSIS
    Replaces sed's character escapes in a pattern by the characters; a produced special character stays literal.
    #>

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

    $builder = New-Object System.Text.StringBuilder
    $inBracket = $false
    $i = 0
    while ($i -lt $Text.Length) {
        $char = [string]$Text[$i]
        if ($inBracket -and $char -ceq ']') { $inBracket = $false }
        elseif ($char -ceq '[' -and -not $inBracket) {
            $inBracket = $true
            [void]$builder.Append($char)
            $i++
            # A ']' right after '[' or '[^' is an ordinary member.
            if ($i -lt $Text.Length -and $Text[$i] -eq '^') { [void]$builder.Append('^'); $i++ }
            if ($i -lt $Text.Length -and $Text[$i] -eq ']') { [void]$builder.Append(']'); $i++ }
            continue
        }
        if ($char -ceq '\' -and $i + 1 -lt $Text.Length) {
            if ($inBracket -and $Text[$i + 1] -ceq 'n') { [void]$builder.Append("`n"); $i += 2; continue }
            $code = Read-GnuSedEscapeCode -Text $Text -Index $i
            if ($null -ne $code) {
                if (-not $inBracket -and '$*.[\]^'.Contains($code.Text)) { [void]$builder.Append('\') }
                [void]$builder.Append($code.Text)
                $i = $code.Next
                continue
            }
            if (-not $inBracket) {
                # An escaped pair stays together: '\\n' is a backslash and an n.
                [void]$builder.Append($char).Append($Text[$i + 1])
                $i += 2
                continue
            }
        }
        [void]$builder.Append($char)
        $i++
    }
    $builder.ToString()
}

function script:Skip-GnuSedSpace {
    <#
    .SYNOPSIS
    Moves past blanks; with -Newlines also past newlines and semicolons.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [switch]$Newlines)
    $text = $Parser.Text
    while ($Parser.Pos -lt $text.Length) {
        $char = $text[$Parser.Pos]
        $blank = $char -eq ' ' -or $char -eq "`t"
        if (-not ($blank -or ($Newlines -and ($char -eq "`n" -or $char -eq ';')))) { break }
        $Parser.Pos++
    }
}

function script:Get-GnuSedPeek {
    <#
    .SYNOPSIS
    The next script character as a string, or '' at the end.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser)
    if ($Parser.Pos -ge $Parser.Text.Length) { return '' }
    [string]$Parser.Text[$Parser.Pos]
}

function script:Read-GnuSedNumber {
    <#
    .SYNOPSIS
    Reads digits and returns their value, or $Default when there are none.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [long]$Default = 0)
    $digits = [regex]::Match($Parser.Text.Substring($Parser.Pos), '^\d+').Value
    if ($digits.Length -eq 0) { return $Default }
    $Parser.Pos += $digits.Length
    [long]$digits
}

function script:Read-GnuSedDelimited {
    <#
    .SYNOPSIS
    Reads up to the unescaped Delimiter (port of GNU's match_slash), or returns $null when it is missing.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [Parameter(Mandatory)][string]$Delimiter, [switch]$Regex)

    $text = $Parser.Text
    $builder = New-Object System.Text.StringBuilder
    while ($Parser.Pos -lt $text.Length) {
        $char = [string]$text[$Parser.Pos]
        if ($char -ceq "`n") { return $null }
        $Parser.Pos++
        if ($char -ceq $Delimiter) { return $builder.ToString() }
        if ($char -ceq '[' -and $Regex) {
            # A delimiter inside a bracket expression does not end the pattern.
            [void]$builder.Append($char)
            foreach ($first in '^', ']') {
                if ((Get-GnuSedPeek -Parser $Parser) -ceq $first) { [void]$builder.Append($first); $Parser.Pos++ }
            }
            while ($Parser.Pos -lt $text.Length -and $text[$Parser.Pos] -ne ']' -and $text[$Parser.Pos] -ne "`n") {
                [void]$builder.Append($text[$Parser.Pos])
                $Parser.Pos++
            }
            continue
        }
        if ($char -ceq '\') {
            if ($Parser.Pos -ge $text.Length) { return $null }
            $next = [string]$text[$Parser.Pos]
            $Parser.Pos++
            if ($next -ceq 'n' -and $Regex) { [void]$builder.Append("`n"); continue }
            if ($next -cne "`n" -and ($next -cne $Delimiter -or (-not $Regex -and $next -ceq '&'))) { [void]$builder.Append('\') }
            [void]$builder.Append($next)
            continue
        }
        [void]$builder.Append($char)
    }
    $null
}

function script:Read-GnuSedAddress {
    <#
    .SYNOPSIS
    Parses one address, or returns $null if there is none here.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser, [switch]$Second)

    $char = Get-GnuSedPeek -Parser $Parser
    if ($char -ceq '/' -or $char -ceq '\') {
        $Parser.Pos++
        $delimiter = $char
        if ($char -ceq '\') {
            $delimiter = Get-GnuSedPeek -Parser $Parser
            if ($delimiter.Length -eq 0) { throw [System.FormatException]::new('unterminated address regex') }
            $Parser.Pos++
        }
        $pattern = Read-GnuSedDelimited -Parser $Parser -Delimiter $delimiter -Regex
        if ($null -eq $pattern) { throw [System.FormatException]::new('unterminated address regex') }
        $ignore = $false
        $multi = $false
        while ($true) {
            $flag = Get-GnuSedPeek -Parser $Parser
            if ($flag -ceq 'I') { $ignore = $true } elseif ($flag -ceq 'M') { $multi = $true } else { break }
            $Parser.Pos++
        }
        return @{ Type = 'regex'; Regex = (New-GnuSedRegex -Parser $Parser -Pattern $pattern -IgnoreCase $ignore -Multiline $multi); Number = [long]0; Step = [long]0 }
    }
    if ($char -match '^\d$') {
        $number = Read-GnuSedNumber -Parser $Parser
        if ((Get-GnuSedPeek -Parser $Parser) -ceq '~') {
            $Parser.Pos++
            return @{ Type = 'step'; Regex = $null; Number = $number; Step = (Read-GnuSedNumber -Parser $Parser) }
        }
        return @{ Type = 'line'; Regex = $null; Number = $number; Step = [long]0 }
    }
    if ($char -ceq '$') {
        $Parser.Pos++
        return @{ Type = 'last'; Regex = $null; Number = [long]0; Step = [long]0 }
    }
    if ($Second -and ($char -ceq '+' -or $char -ceq '~')) {
        $Parser.Pos++
        Skip-GnuSedSpace -Parser $Parser
        return @{ Type = @{ '+' = 'plus'; '~' = 'multiple' }[$char]; Regex = $null; Number = (Read-GnuSedNumber -Parser $Parser); Step = [long]0 }
    }
    $null
}

function script:Read-GnuSedCommandEnd {
    <#
    .SYNOPSIS
    Checks that nothing but a command separator follows.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser)
    Skip-GnuSedSpace -Parser $Parser
    $char = Get-GnuSedPeek -Parser $Parser
    if ($char.Length -eq 0 -or $char -ceq '}' -or $char -ceq '#') { return }
    $Parser.Pos++
    if ($char -cne "`n" -and $char -cne ';') { throw [System.FormatException]::new('extra characters after command') }
}

function script:Read-GnuSedText {
    <#
    .SYNOPSIS
    Reads the text of a, i or c, including GNU's one-line forms.
    #>

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

    Skip-GnuSedSpace -Parser $Parser
    if ((Get-GnuSedPeek -Parser $Parser).Length -eq 0) { throw [System.FormatException]::new('expected \ after `a'', `c'' or `i''') }
    if ((Get-GnuSedPeek -Parser $Parser) -ceq '\') {
        $Parser.Pos++
        $saved = $Parser.Pos
        Skip-GnuSedSpace -Parser $Parser
        if ((Get-GnuSedPeek -Parser $Parser).Length -eq 0) { return '' }
        if ((Get-GnuSedPeek -Parser $Parser) -ceq "`n") { $Parser.Pos++ } else { $Parser.Pos = $saved }
    }
    $text = $Parser.Text
    $builder = New-Object System.Text.StringBuilder
    while ($Parser.Pos -lt $text.Length) {
        $char = [string]$text[$Parser.Pos]
        $Parser.Pos++
        if ($char -ceq "`n") { break }
        if ($char -ceq '\' -and $Parser.Pos -lt $text.Length) {
            $char = [string]$text[$Parser.Pos]
            $Parser.Pos++
        }
        [void]$builder.Append($char)
    }
    $builder.Append("`n").ToString()
}

function script:Read-GnuSedFileName {
    <#
    .SYNOPSIS
    Reads the file name of r, R, w, W or the w flag: the rest of the line.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser)
    if ($Parser.Sandbox) { throw [System.FormatException]::new('e/r/w commands disabled in sandbox mode') }
    Skip-GnuSedSpace -Parser $Parser
    $rest = $Parser.Text.Substring($Parser.Pos)
    $end = $rest.IndexOf("`n")
    if ($end -ge 0) { $rest = $rest.Substring(0, $end) }
    if ($rest.Length -eq 0) { throw [System.FormatException]::new('missing filename in r/R/w/W commands') }
    $Parser.Pos += $rest.Length
    $rest
}

function script:Read-GnuSedLabel {
    <#
    .SYNOPSIS
    Reads a label: up to whitespace or a semicolon.
    #>

    param([Parameter(Mandatory)][hashtable]$Parser)
    Skip-GnuSedSpace -Parser $Parser
    $label = [regex]::Match($Parser.Text.Substring($Parser.Pos), '^[^\s;]*').Value
    $Parser.Pos += $label.Length
    $label
}

function script:ConvertFrom-GnuSedReplacement {
    <#
    .SYNOPSIS
    Parses the replacement of s into parts: @{ Kind = text | group | case; Value }. Returns @{ Parts; MaxGroup }.
    #>

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

    $parts = New-Object 'System.Collections.Generic.List[hashtable]'
    $literal = New-Object System.Text.StringBuilder
    $flush = { if ($literal.Length -gt 0) { $parts.Add(@{ Kind = 'text'; Value = $literal.ToString() }); [void]$literal.Clear() } }
    $maxGroup = 0
    $i = 0
    while ($i -lt $Text.Length) {
        $char = [string]$Text[$i]
        if ($char -ceq '&') {
            . $flush
            $parts.Add(@{ Kind = 'group'; Value = 0 })
            $i++
            continue
        }
        if ($char -cne '\' -or $i + 1 -ge $Text.Length) {
            [void]$literal.Append($char)
            $i++
            continue
        }
        $next = [string]$Text[$i + 1]
        if ($next -match '^[0-9]$') {
            . $flush
            $parts.Add(@{ Kind = 'group'; Value = [int]$next })
            $maxGroup = [Math]::Max($maxGroup, [int]$next)
            $i += 2
            continue
        }
        if ('L', 'U', 'l', 'u', 'E' -ccontains $next) {
            . $flush
            $parts.Add(@{ Kind = 'case'; Value = $next })
            $i += 2
            continue
        }
        if ($next -ceq 'n') {
            [void]$literal.Append("`n")
            $i += 2
            continue
        }
        $code = Read-GnuSedEscapeCode -Text $Text -Index $i
        if ($null -ne $code) {
            [void]$literal.Append($code.Text)
            $i = $code.Next
            continue
        }
        [void]$literal.Append($next)
        $i += 2
    }
    . $flush
    @{ Parts = $parts.ToArray(); MaxGroup = $maxGroup }
}

function script:Read-GnuSedSubstitution {
    <#
    .SYNOPSIS
    Parses the s command after its name into Command.
    #>

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

    $unterminated = 'unterminated `s'' command'
    $delimiter = Get-GnuSedPeek -Parser $Parser
    if ($delimiter.Length -eq 0 -or $delimiter -ceq "`n" -or $delimiter -ceq '\') { throw [System.FormatException]::new($unterminated) }
    $Parser.Pos++
    $pattern = Read-GnuSedDelimited -Parser $Parser -Delimiter $delimiter -Regex
    if ($null -eq $pattern) { throw [System.FormatException]::new($unterminated) }
    $replacement = Read-GnuSedDelimited -Parser $Parser -Delimiter $delimiter
    if ($null -eq $replacement) { throw [System.FormatException]::new($unterminated) }

    $ignore = $false
    $multi = $false
    $done = $false
    while (-not $done) {
        $flag = Get-GnuSedPeek -Parser $Parser
        if ($flag.Length -eq 0 -or $flag -ceq '}' -or $flag -ceq '#') { break }
        $Parser.Pos++
        switch -CaseSensitive -Regex ($flag) {
            '^[iI]$' { $ignore = $true }
            '^[mM]$' { $multi = $true }
            '^p$' {
                if ($Command.Print) { throw [System.FormatException]::new('multiple `p'' options to `s'' command') }
                $Command.Print = $true
            }
            '^g$' {
                if ($Command.Global) { throw [System.FormatException]::new('multiple `g'' options to `s'' command') }
                $Command.Global = $true
            }
            '^\d$' {
                if ($Command.Number -gt 0) { throw [System.FormatException]::new('multiple number options to `s'' command') }
                $Parser.Pos--
                $Command.Number = Read-GnuSedNumber -Parser $Parser
                if ($Command.Number -eq 0) { throw [System.FormatException]::new('number option to `s'' command may not be zero') }
            }
            '^w$' { $Command.File = Read-GnuSedFileName -Parser $Parser; $done = $true }
            '^[\n;]$' { $done = $true }
            '^[ \t]$' { }
            default { throw [System.FormatException]::new('unknown option to `s''') }
        }
    }
    $Command.Regex = New-GnuSedRegex -Parser $Parser -Pattern $pattern -IgnoreCase $ignore -Multiline $multi
    $parsed = ConvertFrom-GnuSedReplacement -Text $replacement
    $Command.Parts = $parsed.Parts
    if ($null -ne $Command.Regex -and $parsed.MaxGroup -gt $Command.Regex.GetGroupNumbers().Count - 1) {
        throw [System.FormatException]::new("invalid reference \$($parsed.MaxGroup) on ``s' command's RHS")
    }
}

function script:Read-GnuSedTransliteration {
    <#
    .SYNOPSIS
    Parses the y command after its name into Command.Map.
    #>

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

    $unterminated = 'unterminated `y'' command'
    $delimiter = Get-GnuSedPeek -Parser $Parser
    if ($delimiter.Length -eq 0 -or $delimiter -ceq "`n" -or $delimiter -ceq '\') { throw [System.FormatException]::new($unterminated) }
    $Parser.Pos++
    $source = Read-GnuSedDelimited -Parser $Parser -Delimiter $delimiter
    if ($null -eq $source) { throw [System.FormatException]::new($unterminated) }
    $target = Read-GnuSedDelimited -Parser $Parser -Delimiter $delimiter
    if ($null -eq $target) { throw [System.FormatException]::new($unterminated) }
    $from = $source.Replace('\\', [string][char]1).Replace('\n', "`n").Replace([string][char]1, '\')
    $to = $target.Replace('\\', [string][char]1).Replace('\n', "`n").Replace([string][char]1, '\')
    if ($from.Length -ne $to.Length) { throw [System.FormatException]::new('strings for `y'' command are different lengths') }
    $map = New-Object 'System.Collections.Generic.Dictionary[char,char]'
    for ($i = 0; $i -lt $from.Length; $i++) { $map[$from[$i]] = $to[$i] }
    $Command.Map = $map
    Read-GnuSedCommandEnd -Parser $Parser
}

function script:ConvertFrom-GnuSedScript {
    <#
    .SYNOPSIS
    Compiles the script text into a flat list of commands. Throws FormatException for invalid scripts; Parser.Pos tells where.
    #>

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

    $commands = New-Object 'System.Collections.Generic.List[hashtable]'
    $labels = @{}
    $blocks = New-Object 'System.Collections.Generic.Stack[int]'
    while ($true) {
        Skip-GnuSedSpace -Parser $Parser -Newlines
        if ($Parser.Pos -ge $Parser.Text.Length) { break }
        $command = @{
            Name = ''; A1 = $null; A2 = $null; Negate = $false; Active = $false; End = [long]0; Text = ''; Label = ''; Target = -1; File = $null
            Regex = $null; Parts = @(); Print = $false; Global = $false; Number = [long]0; Map = $null; Value = [long]-1
        }
        $command.A1 = Read-GnuSedAddress -Parser $Parser
        if ($null -ne $command.A1) {
            Skip-GnuSedSpace -Parser $Parser
            if ((Get-GnuSedPeek -Parser $Parser) -ceq ',') {
                $Parser.Pos++
                Skip-GnuSedSpace -Parser $Parser
                $command.A2 = Read-GnuSedAddress -Parser $Parser -Second
                if ($null -eq $command.A2) {
                    if ($Parser.Pos -lt $Parser.Text.Length) { $Parser.Pos++ }
                    throw [System.FormatException]::new('unexpected `,''')
                }
            }
            if ($command.A1.Type -ceq 'line' -and $command.A1.Number -eq 0 -and ($null -eq $command.A2 -or $command.A2.Type -cne 'regex')) {
                if ($Parser.Pos -lt $Parser.Text.Length) { $Parser.Pos++ }
                throw [System.FormatException]::new('invalid usage of line address 0')
            }
            # 0,/re/ starts active, so the first line can already end the range.
            if ($command.A1.Type -ceq 'line' -and $command.A1.Number -eq 0) { $command.Active = $true }
        }
        Skip-GnuSedSpace -Parser $Parser
        while ((Get-GnuSedPeek -Parser $Parser) -ceq '!') {
            if ($command.Negate) {
                $Parser.Pos++
                throw [System.FormatException]::new('multiple `!''s')
            }
            $command.Negate = $true
            $Parser.Pos++
            Skip-GnuSedSpace -Parser $Parser
        }
        $name = Get-GnuSedPeek -Parser $Parser
        if ($name.Length -eq 0) { throw [System.FormatException]::new('missing command') }
        $Parser.Pos++
        $command.Name = $name
        switch -CaseSensitive -Regex ($name) {
            '^#$' {
                if ($null -ne $command.A1) { throw [System.FormatException]::new('comments don''t accept any addresses') }
                $rest = $Parser.Text.IndexOf("`n", $Parser.Pos)
                if ($rest -lt 0) { $rest = $Parser.Text.Length }
                $Parser.Pos = $rest
                $command.Name = ''
            }
            '^:$' {
                if ($null -ne $command.A1) { throw [System.FormatException]::new(': doesn''t want any addresses') }
                $label = Read-GnuSedLabel -Parser $Parser
                if ($label.Length -eq 0) { throw [System.FormatException]::new('":" lacks a label') }
                $labels[$label] = $commands.Count
            }
            '^\{$' { $blocks.Push($commands.Count) }
            '^\}$' {
                if ($blocks.Count -eq 0) { throw [System.FormatException]::new('unexpected `}''') }
                if ($null -ne $command.A1) { throw [System.FormatException]::new('} doesn''t want any addresses') }
                $commands[$blocks.Pop()].Target = $commands.Count
                Read-GnuSedCommandEnd -Parser $Parser
            }
            '^[=dDFgGhHnNpPxz]$' { Read-GnuSedCommandEnd -Parser $Parser }
            '^[aic]$' { $command.Text = Read-GnuSedText -Parser $Parser }
            '^[btT]$' { $command.Label = Read-GnuSedLabel -Parser $Parser }
            '^[lqQ]$' {
                if ($name -cne 'l' -and $null -ne $command.A2) { throw [System.FormatException]::new('command only uses one address') }
                Skip-GnuSedSpace -Parser $Parser
                $command.Value = Read-GnuSedNumber -Parser $Parser -Default -1
                Read-GnuSedCommandEnd -Parser $Parser
            }
            '^[rRwW]$' { $command.File = Read-GnuSedFileName -Parser $Parser }
            '^s$' { Read-GnuSedSubstitution -Parser $Parser -Command $command }
            '^y$' { Read-GnuSedTransliteration -Parser $Parser -Command $command }
            default { throw [System.FormatException]::new("unknown command: ``$name'") }
        }
        if ($command.Name.Length -gt 0) { $commands.Add($command) }
    }
    if ($blocks.Count -gt 0) {
        $Parser.Pos = 0
        throw [System.FormatException]::new('unmatched `{''')
    }
    foreach ($command in $commands) {
        if ('b', 't', 'T' -cnotcontains $command.Name) { continue }
        if ($command.Label.Length -eq 0) { $command.Target = $commands.Count }
        elseif ($labels.ContainsKey($command.Label)) { $command.Target = $labels[$command.Label] }
        else {
            $Parser.Located = $false
            throw [System.FormatException]::new("can't find label for jump to ``$($command.Label)'")
        }
    }
    , $commands.ToArray()
}

function script:Get-GnuSedErrorPrefix {
    <#
    .SYNOPSIS
    GNU's location prefix for a script error: which -e or -f piece, and where.
    #>

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

    $position = $Parser.Pos
    $expression = 0
    foreach ($piece in $Parser.Pieces) {
        if ($piece.Kind -ceq 'e') { $expression++ }
        $isLast = [object]::ReferenceEquals($piece, $Parser.Pieces[$Parser.Pieces.Count - 1])
        if ($position -le $piece.Start + $piece.Text.Length -or $isLast) {
            if ($piece.Kind -ceq 'e') { return "-e expression #$expression, char $([Math]::Min($position - $piece.Start, $piece.Text.Length)): " }
            $consumed = $piece.Text.Substring(0, [Math]::Min($position - $piece.Start, $piece.Text.Length))
            return "file $($piece.Name) line $($consumed.Split("`n").Count): "
        }
    }
}

function script:Open-GnuSedNextFile {
    <#
    .SYNOPSIS
    Loads the next readable input into the state. Returns $false when no input is left.
    #>

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

    $context = $State.Context
    while ($State.Queue.Count -gt 0) {
        $name = $State.Queue.Dequeue()
        $text = $null
        if ($name -ceq '-' -and -not $State.InPlace) { $text = [string](Get-GnuInputText -Context $context -Operand '-') }
        else {
            $path = Resolve-GnuPath -Path $name
            if ([System.IO.Directory]::Exists($path)) {
                if ($State.InPlace) { Write-GnuError -Context $context -Message "couldn't edit $($name): not a regular file" -ExitCode 4; continue }
                Write-GnuError -Context $context -Message "read error on $($name): Is a directory" -ExitCode 4
                $State.Queue.Clear()
                return $false
            }
            if (-not [System.IO.File]::Exists($path)) { Write-GnuError -Context $context -Message "can't read $($name): No such file or directory" -ExitCode 2; continue }
            $file = Read-GnuTextFile -Path $path
            $text = $file.Text
            $State.Encoding = $file.Encoding
            $State.Path = $path
        }
        $State.FileName = $name
        $State.Index = 0
        $State.LastHasEnd = $text.Length -eq 0 -or $text.EndsWith($State.Separator, [System.StringComparison]::Ordinal)
        $State.Lines = @(Split-GnuRecord -Text $text -Separator $State.Separator)
        $State.Crlf = $false
        if ($State.StripCr -and $State.Separator -ceq "`n") {
            $State.Crlf = $text.Contains("`r`n")
            for ($i = 0; $i -lt $State.Lines.Count; $i++) {
                if ($State.Lines[$i].EndsWith("`r", [System.StringComparison]::Ordinal)) { $State.Lines[$i] = $State.Lines[$i].Substring(0, $State.Lines[$i].Length - 1) }
            }
        }
        if ($State.Lines.Count -gt 0) { return $true }
    }
    $false
}

function script:Test-GnuSedInputLeft {
    <#
    .SYNOPSIS
    $true if another input line exists, opening later files as needed.
    #>

    param([Parameter(Mandatory)][hashtable]$State)
    if ($State.Index -lt $State.Lines.Count) { return $true }
    Open-GnuSedNextFile -State $State
}

function script:Add-GnuSedOutput {
    <#
    .SYNOPSIS
    Writes a line (with its terminator unless the input's last line had none) to the current output.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [AllowEmptyString()][string]$Text, [bool]$Terminated = $true)

    if ($null -eq $State.Sink) {
        $State.Output.Add($Text)
        $State.OutputEnded = $Terminated
        return
    }
    if ($State.SinkMissingEnd) { [void]$State.Sink.Append($State.LineEnd); $State.SinkMissingEnd = $false }
    [void]$State.Sink.Append($Text)
    if ($Terminated) { [void]$State.Sink.Append($State.LineEnd) } else { $State.SinkMissingEnd = $true }
}

function script:Add-GnuSedRawOutput {
    <#
    .SYNOPSIS
    Writes text that carries its own line ends (a, i, c, r, =).
    #>

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

    foreach ($line in @(Split-GnuRecord -Text $Text)) { Add-GnuSedOutput -State $State -Text $line }
}

function script:Invoke-GnuSedFlushAppend {
    <#
    .SYNOPSIS
    Writes what a, r and R queued for the end of the cycle.
    #>

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

    foreach ($item in $State.Append) {
        if ($item.Kind -ceq 'text') { Add-GnuSedRawOutput -State $State -Text $item.Value; continue }
        $operand = $item.Value
        if ($operand -ceq '/dev/stdin') { $operand = '-' }
        elseif (-not [System.IO.File]::Exists((Resolve-GnuPath -Path $operand))) { continue }
        $content = ''
        if ($operand -ceq '-') { $content = [string](Get-GnuInputText -Context $State.Context -Operand '-') }
        else { $content = (Read-GnuTextFile -Path (Resolve-GnuPath -Path $operand)).Text }
        Add-GnuSedRawOutput -State $State -Text $content
    }
    $State.Append.Clear()
}

function script:Read-GnuSedPatternSpace {
    <#
    .SYNOPSIS
    Reads the next line into the pattern space (appending with -Append). Returns $false at the end of the input.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [switch]$Append)

    Invoke-GnuSedFlushAppend -State $State
    if (-not (Test-GnuSedInputLeft -State $State)) { return $false }
    $line = $State.Lines[$State.Index]
    $State.Index++
    $State.LineNumber++
    $State.Replaced = $false
    $State.Terminated = $State.Index -lt $State.Lines.Count -or $State.LastHasEnd
    if ($Append) { $State.Pattern = $State.Pattern + $State.Separator + $line } else { $State.Pattern = $line }
    $true
}

function script:Get-GnuSedRegex {
    <#
    .SYNOPSIS
    The regex to run: the given one, or for an empty pattern the last one used.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [AllowNull()][System.Text.RegularExpressions.Regex]$Regex)
    if ($null -ne $Regex) { $State.LastRegex = $Regex; return $Regex }
    if ($null -eq $State.LastRegex) { throw [System.InvalidOperationException]::new('no previous regular expression') }
    $State.LastRegex
}

function script:Test-GnuSedAddress {
    <#
    .SYNOPSIS
    $true if one address matches the current line.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][hashtable]$Address)
    $line = $State.LineNumber
    switch -CaseSensitive ($Address.Type) {
        'line' { return $line -eq $Address.Number }
        'step' {
            if ($Address.Step -le 0) { return $line -eq $Address.Number }
            return $line -ge $Address.Number -and ($line - $Address.Number) % $Address.Step -eq 0
        }
        'last' { return -not (Test-GnuSedInputLeft -State $State) }
    }
    (Get-GnuSedRegex -State $State -Regex $Address.Regex).IsMatch($State.Pattern)
}

function script:Test-GnuSedSelected {
    <#
    .SYNOPSIS
    $true if a command applies to the current line (GNU's match_address_p), updating the state of ranges.
    #>

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

    if ($null -eq $Command.A1) { return $true }
    if ($null -eq $Command.A2) { return Test-GnuSedAddress -State $State -Address $Command.A1 }
    $line = $State.LineNumber
    $a2 = $Command.A2
    if ($Command.Active) {
        if ($Command.End -gt 0 -or $a2.Type -ceq 'line') {
            if ($line -ge $Command.End) { $Command.Active = $false }
            return $line -le $Command.End
        }
        if (Test-GnuSedAddress -State $State -Address $a2) { $Command.Active = $false }
        return $true
    }
    if (-not (Test-GnuSedAddress -State $State -Address $Command.A1)) { return $false }
    $Command.End = [long]0
    switch -CaseSensitive ($a2.Type) {
        'line' { $Command.End = $a2.Number }
        'plus' { $Command.End = $line + $a2.Number }
        'multiple' {
            if ($a2.Number -le 0) { $Command.End = $line }
            else { $Command.End = $line - ($line % $a2.Number) + $a2.Number }
        }
        default {
            $Command.Active = $true
            return $true
        }
    }
    $Command.Active = $Command.End -gt $line
    $true
}

function script:Add-GnuSedCasedText {
    <#
    .SYNOPSIS
    Appends replacement text, applying the active \U \L \u \l conversions.
    #>

    param([Parameter(Mandatory)][System.Text.StringBuilder]$Builder, [AllowEmptyString()][string]$Text, [Parameter(Mandatory)][hashtable]$Case)

    if ($Text.Length -eq 0) { return }
    $rest = $Text
    if ($Case.Once.Length -gt 0) {
        $first = $Text.Substring(0, 1)
        if ($Case.Once -ceq 'u') { $first = $first.ToUpperInvariant() } else { $first = $first.ToLowerInvariant() }
        [void]$Builder.Append($first)
        $rest = $Text.Substring(1)
        $Case.Once = ''
    }
    if ($Case.Mode -ceq 'U') { $rest = $rest.ToUpperInvariant() }
    elseif ($Case.Mode -ceq 'L') { $rest = $rest.ToLowerInvariant() }
    [void]$Builder.Append($rest)
}

function script:Invoke-GnuSedSubstitute {
    <#
    .SYNOPSIS
    Runs an s command on the pattern space. Returns $true if something was replaced.
    #>

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

    $regex = Get-GnuSedRegex -State $State -Regex $Command.Regex
    $text = $State.Pattern
    $wanted = [Math]::Max([long]1, $Command.Number)
    $builder = New-Object System.Text.StringBuilder
    $copied = 0
    $position = 0
    $count = [long]0
    $previousEnd = -1
    $replaced = $false
    while ($position -le $text.Length) {
        $match = $regex.Match($text, $position)
        if (-not $match.Success) { break }
        $end = $match.Index + $match.Length
        # An empty match right after the previous match does not count (s/a*/x/g on baaac is xbxcx).
        if ($match.Length -eq 0 -and $match.Index -eq $previousEnd) {
            $position = $match.Index + 1
            continue
        }
        $previousEnd = $end
        $count++
        if ($count -ge $wanted) {
            [void]$builder.Append($text, $copied, $match.Index - $copied)
            $case = @{ Mode = ''; Once = '' }
            foreach ($part in $Command.Parts) {
                switch -CaseSensitive ($part.Kind) {
                    'text' { Add-GnuSedCasedText -Builder $builder -Text $part.Value -Case $case }
                    'group' { Add-GnuSedCasedText -Builder $builder -Text $match.Groups[$part.Value].Value -Case $case }
                    'case' {
                        if ('U', 'L' -ccontains $part.Value) { $case.Mode = $part.Value; $case.Once = '' }
                        elseif ($part.Value -ceq 'E') { $case.Mode = ''; $case.Once = '' }
                        else { $case.Once = $part.Value }
                    }
                }
            }
            $copied = $end
            $replaced = $true
            if (-not $Command.Global) { break }
        }
        if ($match.Length -eq 0) { $position = $match.Index + 1 } else { $position = $end }
    }
    if (-not $replaced) { return $false }
    [void]$builder.Append($text, $copied, $text.Length - $copied)
    $State.Pattern = $builder.ToString()
    $true
}

function script:Format-GnuSedVisible {
    <#
    .SYNOPSIS
    The l command's lines: escapes for special bytes, wrapped at Width, ending in '$'.
    #>

    param([AllowEmptyString()][string]$Text, [long]$Width)

    $names = @{ 7 = '\a'; 8 = '\b'; 9 = '\t'; 10 = '\n'; 11 = '\v'; 12 = '\f'; 13 = '\r'; 92 = '\\' }
    $line = New-Object System.Text.StringBuilder
    foreach ($byte in $script:GnuUtf8.GetBytes($Text)) {
        $unit = [string][char]$byte
        if ($names.ContainsKey([int]$byte)) { $unit = $names[[int]$byte] }
        elseif ($byte -lt 32 -or $byte -gt 126) { $unit = '\' + [Convert]::ToString([int]$byte, 8).PadLeft(3, '0') }
        if ($Width -gt 0 -and $line.Length + $unit.Length -gt $Width - 1) {
            $line.ToString() + '\'
            [void]$line.Clear()
        }
        [void]$line.Append($unit)
    }
    $line.ToString() + '$'
}

function script:Write-GnuSedFile {
    <#
    .SYNOPSIS
    The w and W commands: /dev/stdout and /dev/stderr are the real streams, anything else was opened at the start.
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Text)
    switch -CaseSensitive ($Name) {
        '/dev/stdout' { $State.Output.Add($Text) }
        '/dev/stderr' { [System.Console]::Error.WriteLine($Text) }
        default { $State.Writers[$Name].Write($Text + $State.Separator) }
    }
}

function script:Invoke-GnuSedCycle {
    <#
    .SYNOPSIS
    Runs the script once on the pattern space. Returns 'print' (end of script), 'next' (no autoprint), 'restart' (D) or 'quit'.
    #>

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

    $commands = $State.Commands
    $pc = 0
    while ($pc -lt $commands.Count) {
        $command = $commands[$pc]
        $selected = Test-GnuSedSelected -State $State -Command $command
        if ($selected -eq $command.Negate) {
            if ($command.Name -ceq '{') { $pc = $command.Target + 1 } else { $pc++ }
            continue
        }
        $next = $pc + 1
        switch -CaseSensitive ($command.Name) {
            'a' { $State.Append.Add(@{ Kind = 'text'; Value = $command.Text }) }
            'b' { $next = $command.Target }
            'c' {
                if (-not $command.Active -or $null -eq $command.A2) { Add-GnuSedRawOutput -State $State -Text $command.Text }
                return 'next'
            }
            'd' { return 'next' }
            'D' {
                $newline = $State.Pattern.IndexOf($State.Separator, [System.StringComparison]::Ordinal)
                if ($newline -lt 0) { return 'next' }
                $State.Pattern = $State.Pattern.Substring($newline + 1)
                return 'restart'
            }
            'F' { Add-GnuSedOutput -State $State -Text $State.FileName }
            'g' { $State.Pattern = $State.Hold }
            'G' { $State.Pattern = $State.Pattern + $State.Separator + $State.Hold }
            'h' { $State.Hold = $State.Pattern }
            'H' { $State.Hold = $State.Hold + $State.Separator + $State.Pattern }
            'i' { Add-GnuSedRawOutput -State $State -Text $command.Text }
            'l' {
                $width = $State.ListWidth
                if ($command.Value -ge 0) { $width = $command.Value }
                foreach ($line in @(Format-GnuSedVisible -Text $State.Pattern -Width $width)) { Add-GnuSedOutput -State $State -Text $line }
            }
            'n' {
                if (-not (Test-GnuSedInputLeft -State $State)) { return 'print' }
                if (-not $State.Quiet) { Add-GnuSedOutput -State $State -Text $State.Pattern -Terminated $State.Terminated }
                $null = Read-GnuSedPatternSpace -State $State
            }
            'N' {
                if (-not (Test-GnuSedInputLeft -State $State)) { return 'print' }
                $null = Read-GnuSedPatternSpace -State $State -Append
            }
            'p' { Add-GnuSedOutput -State $State -Text $State.Pattern -Terminated $State.Terminated }
            'P' {
                $newline = $State.Pattern.IndexOf($State.Separator, [System.StringComparison]::Ordinal)
                if ($newline -lt 0) { Add-GnuSedOutput -State $State -Text $State.Pattern } else { Add-GnuSedOutput -State $State -Text $State.Pattern.Substring(0, $newline) }
            }
            'q' {
                if ($command.Value -ge 0) { $State.Context.ExitCode = [int]$command.Value }
                if (-not $State.Quiet) { Add-GnuSedOutput -State $State -Text $State.Pattern -Terminated $State.Terminated }
                Invoke-GnuSedFlushAppend -State $State
                return 'quit'
            }
            'Q' {
                if ($command.Value -ge 0) { $State.Context.ExitCode = [int]$command.Value }
                return 'quit'
            }
            'r' { $State.Append.Add(@{ Kind = 'file'; Value = $command.File }) }
            'R' {
                $line = Get-GnuSedNextFileLine -State $State -Name $command.File
                if ($null -ne $line) { $State.Append.Add(@{ Kind = 'text'; Value = $line + "`n" }) }
            }
            's' {
                if (Invoke-GnuSedSubstitute -State $State -Command $command) {
                    $State.Replaced = $true
                    if ($command.Print) { Add-GnuSedOutput -State $State -Text $State.Pattern -Terminated $State.Terminated }
                    if ($null -ne $command.File) { Write-GnuSedFile -State $State -Name $command.File -Text $State.Pattern }
                }
            }
            't' { if ($State.Replaced) { $State.Replaced = $false; $next = $command.Target } }
            'T' { if ($State.Replaced) { $State.Replaced = $false } else { $next = $command.Target } }
            'w' { Write-GnuSedFile -State $State -Name $command.File -Text $State.Pattern }
            'W' {
                $newline = $State.Pattern.IndexOf($State.Separator, [System.StringComparison]::Ordinal)
                $first = $State.Pattern
                if ($newline -ge 0) { $first = $State.Pattern.Substring(0, $newline) }
                Write-GnuSedFile -State $State -Name $command.File -Text $first
            }
            'x' {
                $swap = $State.Pattern
                $State.Pattern = $State.Hold
                $State.Hold = $swap
            }
            'y' {
                $chars = $State.Pattern.ToCharArray()
                for ($k = 0; $k -lt $chars.Length; $k++) {
                    if ($command.Map.ContainsKey($chars[$k])) { $chars[$k] = $command.Map[$chars[$k]] }
                }
                $State.Pattern = New-Object string -ArgumentList (, $chars)
            }
            'z' { $State.Pattern = '' }
            '=' { Add-GnuSedOutput -State $State -Text ([string]$State.LineNumber) }
        }
        $pc = $next
    }
    'print'
}

function script:Get-GnuSedNextFileLine {
    <#
    .SYNOPSIS
    The R command: the next line of a file, or $null at its end (missing files count as empty).
    #>

    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Name)

    if (-not $State.Readers.ContainsKey($Name)) {
        $lines = @()
        $path = Resolve-GnuPath -Path $Name
        if ([System.IO.File]::Exists($path)) { $lines = @(Split-GnuRecord -Text (Read-GnuTextFile -Path $path).Text) }
        $State.Readers[$Name] = @{ Lines = $lines; Index = 0 }
    }
    $reader = $State.Readers[$Name]
    if ($reader.Index -ge $reader.Lines.Count) { return $null }
    $reader.Index++
    $reader.Lines[$reader.Index - 1]
}

function script:Save-GnuSedInPlace {
    <#
    .SYNOPSIS
    Replaces the edited file (after a backup when a suffix was given).
    #>

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

    $path = $State.Path
    $suffix = $State.Suffix
    if ($suffix.Length -gt 0) {
        $leaf = [System.IO.Path]::GetFileName($path)
        $backup = $leaf + $suffix
        if ($suffix.Contains('*')) { $backup = $suffix.Replace('*', $leaf) }
        if ($backup.Contains('/')) { $backup = Resolve-GnuPath -Path $backup }
        else { $backup = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($path), $backup) }
        [System.IO.File]::Copy($path, $backup, $true)
    }
    $bytes = $State.Encoding.GetPreamble() + $State.Encoding.GetBytes($State.Sink.ToString())
    [System.IO.File]::WriteAllBytes($path, [byte[]]$bytes)
}

function script:Invoke-GnuSedRun {
    <#
    .SYNOPSIS
    Processes the queued input until it ends or a q command runs; emits the output lines. Sets State.Quit.
    #>

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

    $reuse = $false
    while ($true) {
        if (-not $reuse -and -not (Read-GnuSedPatternSpace -State $State)) { break }
        $reuse = $false
        $outcome = Invoke-GnuSedCycle -State $State
        if ($outcome -ceq 'print' -and -not $State.Quiet) { Add-GnuSedOutput -State $State -Text $State.Pattern -Terminated $State.Terminated }
        if ($outcome -ceq 'restart') {
            Invoke-GnuSedFlushAppend -State $State
            $reuse = $true
        }
        if (-not $State.Zero) {
            foreach ($line in $State.Output) { ConvertTo-GnuSedLines -Text $line }
            $State.Output.Clear()
        }
        if ($outcome -ceq 'quit') {
            $State.Quit = $true
            return
        }
    }
    Invoke-GnuSedFlushAppend -State $State
    if (-not $State.Zero) {
        foreach ($line in $State.Output) { ConvertTo-GnuSedLines -Text $line }
        $State.Output.Clear()
    }
}

function script:ConvertTo-GnuSedLines {
    <#
    .SYNOPSIS
    One output string per line: a pattern space can hold several lines after N or G.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'It splits one text into lines.')]
    param([AllowEmptyString()][string]$Text)
    if ($Text.Contains("`n")) { $Text.Split("`n") } else { $Text }
}

function script:Invoke-GnuSedEdit {
    <#
    .SYNOPSIS
    Implementation of sed.
    #>

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

    $operands = New-Object 'System.Collections.Generic.List[string]'
    foreach ($operand in @($Context.Operands)) { $operands.Add($operand) }
    $scripts = @(Get-GnuOptionValue -Context $Context -Key 'script' -Default @())
    if ($scripts.Count -eq 0) {
        if ($operands.Count -eq 0) {
            Write-GnuUsageError -Context $Context -Message 'Usage: sed [OPTION]... {script-only-if-no-other-script} [input-file]...'
            return
        }
        $scripts = @('e:' + $operands[0])
        $operands.RemoveAt(0)
    }

    $width = [long]70
    if (Test-GnuOption -Context $Context -Key 'line-length') {
        # GNU uses atoi here: text without leading digits counts as 0.
        $digits = [regex]::Match([string](Get-GnuOptionValue -Context $Context -Key 'line-length'), '^\d+').Value
        $width = [long]0
        if ($digits.Length -gt 0) { $width = [long]$digits }
    }

    $parser = @{ Text = ''; Pos = 0; Extended = Test-GnuOption -Context $Context -Key 'extended'; Sandbox = Test-GnuOption -Context $Context -Key 'sandbox'; Pieces = @(); Located = $true }
    $pieces = New-Object 'System.Collections.Generic.List[hashtable]'
    $builder = New-Object System.Text.StringBuilder
    foreach ($script in $scripts) {
        $kind = $script.Substring(0, 1)
        $body = $script.Substring(2)
        $name = ''
        if ($kind -ceq 'f') {
            $name = $body
            $body = $null
            if ($name -ceq '-' -or [System.IO.File]::Exists((Resolve-GnuPath -Path $name))) { $body = [string](Get-GnuInputText -Context $Context -Operand $name) }
            if ($null -eq $body) { Write-GnuError -Context $Context -Message "couldn't open file $($name): No such file or directory" -ExitCode 4; return }
            $body = $body.Replace("`r`n", "`n").TrimEnd("`n")
        }
        if ($pieces.Count -gt 0) { [void]$builder.Append("`n") }
        $pieces.Add(@{ Kind = $kind; Name = $name; Text = $body; Start = $builder.Length })
        [void]$builder.Append($body)
    }
    $parser.Pieces = $pieces.ToArray()
    $parser.Text = $builder.ToString()

    try { $commands = ConvertFrom-GnuSedScript -Parser $parser }
    catch [System.FormatException] {
        # A missing label is only found after parsing: GNU reports it without a location and as a fatal error.
        if ($parser.Located) { Write-GnuError -Context $Context -Message ((Get-GnuSedErrorPrefix -Parser $parser) + $_.Exception.Message) }
        else { Write-GnuError -Context $Context -Message $_.Exception.Message -ExitCode 4 }
        return
    }

    $quiet = Test-GnuOption -Context $Context -Key 'quiet'
    if ($parser.Text.StartsWith('#n', [System.StringComparison]::Ordinal)) { $quiet = $true }
    $inPlace = Test-GnuOption -Context $Context -Key 'in-place'
    $separate = $inPlace -or (Test-GnuOption -Context $Context -Key 'separate')
    $zero = Test-GnuOption -Context $Context -Key 'null-data'
    $separator = "`n"
    if ($zero) { $separator = "`0" }
    $files = @(Expand-GnuWildcard -Operands $operands.ToArray())
    if ($files.Count -eq 0) { $files = @('-') }

    $state = @{
        Context = $Context; Commands = $commands; Queue = New-Object 'System.Collections.Generic.Queue[string]'; Lines = @(); Index = 0; FileName = '-'
        Path = ''; Encoding = $script:GnuUtf8; LastHasEnd = $true; Crlf = $false; StripCr = (Test-GnuWindows) -and -not (Test-GnuOption -Context $Context -Key 'binary')
        Separator = $separator; Zero = $zero; InPlace = $inPlace; Suffix = [string](Get-GnuOptionValue -Context $Context -Key 'in-place' -Default ''); Quiet = $quiet
        ListWidth = $width; Pattern = ''; Hold = ''; LineNumber = [long]0; Replaced = $false; Terminated = $true; LastRegex = $null; Quit = $false
        Append = New-Object 'System.Collections.Generic.List[hashtable]'; Output = New-Object 'System.Collections.Generic.List[string]'; OutputEnded = $true; Sink = $null; SinkMissingEnd = $false; LineEnd = $separator
        Writers = @{}; Readers = @{}
    }
    try {
        foreach ($command in $commands) {
            if ('w', 'W', 's' -cnotcontains $command.Name -or $null -eq $command.File) { continue }
            if ('/dev/stdout', '/dev/stderr' -ccontains $command.File -or $state.Writers.ContainsKey($command.File)) { continue }
            try { $state.Writers[$command.File] = New-Object System.IO.StreamWriter -ArgumentList (Resolve-GnuPath -Path $command.File), $false, $script:GnuUtf8 }
            catch {
                Write-GnuError -Context $Context -Message "couldn't open file $($command.File): $(Get-GnuErrorReason -Exception $_.Exception)" -ExitCode 4
                return
            }
        }
        if (-not $separate) {
            foreach ($file in $files) { $state.Queue.Enqueue($file) }
            Invoke-GnuSedRun -State $state
        }
        else {
            foreach ($file in $files) {
                $state.Queue.Enqueue($file)
                $state.LineNumber = [long]0
                $state.Lines = @()
                $state.Index = 0
                foreach ($command in $commands) { $command.Active = $null -ne $command.A1 -and $command.A1.Type -ceq 'line' -and $command.A1.Number -eq 0 }
                if ($inPlace) {
                    $state.Sink = New-Object System.Text.StringBuilder
                    $state.SinkMissingEnd = $false
                    $state.Path = ''
                }
                Invoke-GnuSedRun -State $state
                if ($inPlace -and $state.Path.Length -gt 0) {
                    # CR+LF files keep their line ends: the CRs were removed when the lines were read.
                    if ($state.Crlf) { $state.Sink = New-Object System.Text.StringBuilder -ArgumentList ($state.Sink.ToString().Replace("`n", "`r`n")) }
                    Save-GnuSedInPlace -State $state
                }
                if ($state.Quit) { break }
            }
        }
        if ($zero -and $state.Output.Count -gt 0) {
            $joined = $state.Output.ToArray() -join "`0"
            if ($state.OutputEnded) { $joined += "`0" }
            $joined
        }
    }
    catch [System.InvalidOperationException] {
        $expressions = @($scripts | Where-Object { $_.StartsWith('e:', [System.StringComparison]::Ordinal) }).Count
        Write-GnuError -Context $Context -Message "-e expression #$expressions, char 0: $($_.Exception.Message)"
    }
    finally { foreach ($writer in $state.Writers.Values) { $writer.Dispose() } }
}