Utils/csplit.ps1
|
Register-GnuUtil @{ Name = 'csplit' Category = 'Text processing' Synopsis = 'Split a file into sections determined by context lines.' Usage = @('csplit [OPTION]... FILE PATTERN...') Description = @( 'Output pieces of FILE separated by PATTERN(s) to files ''xx00'', ''xx01'', ...,', 'and output byte counts of each piece to standard output.', '', 'Read standard input if FILE is -' ) Options = @( @{ Short = 'b'; Long = 'suffix-format'; Arg = 'FORMAT'; Help = 'use sprintf FORMAT instead of %02d' } @{ Short = 'f'; Long = 'prefix'; Arg = 'PREFIX'; Help = 'use PREFIX instead of ''xx''' } @{ Short = 'k'; Long = 'keep-files'; Help = 'do not remove output files on errors' } @{ Long = 'suppress-matched'; Help = 'suppress the lines matching PATTERN' } @{ Short = 'n'; Long = 'digits'; Arg = 'DIGITS'; Help = 'use specified number of digits instead of 2' } @{ Short = 's'; Long = 'quiet'; Key = 'quiet'; Help = 'do not print counts of output file sizes' } @{ Short = 'q'; Key = 'quiet'; Help = 'do not print counts of output file sizes' } @{ Long = 'silent'; Key = 'quiet'; Help = 'do not print counts of output file sizes' } @{ Short = 'z'; Long = 'elide-empty-files'; Help = 'remove empty output files' } ) Notes = @( 'Each PATTERN may be:', ' INTEGER copy up to but not including specified line number', ' /REGEXP/[OFFSET] copy up to but not including a matching line', ' %REGEXP%[OFFSET] skip to, but not including a matching line', ' {INTEGER} repeat the previous pattern specified number of times', ' {*} repeat the previous pattern as many times as possible', '', 'A line OFFSET is a required ''+'' or ''-'' followed by a positive integer.', '', 'PowerShell note: REGEXP is a POSIX basic regular expression translated to .NET', '(like grep); quote patterns such as {*} and %...% in PowerShell.' ) } function script:ConvertFrom-GnuCsplitPattern { <# .SYNOPSIS Parses the PATTERN operands. Returns @{ Patterns } or @{ Error } (warnings are written directly). #> param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][string[]]$Operands) $patterns = New-Object 'System.Collections.Generic.List[hashtable]' $lastLine = 0 foreach ($text in $Operands) { if ($text -match '^\{(.*)\}$') { if ($patterns.Count -eq 0) { return @{ Error = "'$text': invalid pattern" } } $inner = $Matches[1] if ($inner -eq '*') { $patterns[$patterns.Count - 1].Repeat = -1; continue } if ($inner -notmatch '^\d+$') { return @{ Error = "'{$inner'}: integer required between '{' and '}'" } } $patterns[$patterns.Count - 1].Repeat = [int]$inner continue } if ($text.Length -gt 0 -and ($text[0] -eq '/' -or $text[0] -eq '%')) { $delimiter = $text[0] $close = $text.LastIndexOf($delimiter) if ($close -le 0) { return @{ Error = "$($text): closing delimiter '$delimiter' missing" } } $offsetText = $text.Substring($close + 1) $offset = 0 if ($offsetText.Length -gt 0) { if ($offsetText -notmatch '^[+-]\d+$') { return @{ Error = "'$text': integer expected after delimiter" } } $offset = [int]$offsetText } $converted = ConvertTo-GnuDotNetRegex -Pattern $text.Substring(1, $close - 1) $balance = 'ok' if (-not $converted.ContainsKey('Error')) { $balance = Get-GnuParenthesisBalance -Regex $converted.Regex } if ($converted.ContainsKey('Error') -or $balance -ne 'ok') { return @{ Error = "'$text': invalid regular expression: Invalid regular expression" } } $patterns.Add(@{ Text = $text; Kind = 'regex'; Skip = $delimiter -eq '%'; Regex = (New-Object regex -ArgumentList $converted.Regex); Offset = $offset; Repeat = 0 }) continue } if ($text -notmatch '^\d+$') { return @{ Error = "'$text': invalid pattern" } } $line = [long]$text if ($line -eq 0) { return @{ Error = "0: line number must be greater than zero" } } if ($line -lt $lastLine) { return @{ Error = "line number '$text' is smaller than preceding line number, $lastLine" } } if ($line -eq $lastLine) { Write-GnuWarning -Context $Context -Message "warning: line number '$text' is the same as preceding line number" } $lastLine = $line $patterns.Add(@{ Text = $text; Kind = 'line'; Line = $line; Repeat = 0 }) } @{ Patterns = $patterns } } function script:Open-GnuCsplitPiece { <# .SYNOPSIS Starts the next output piece (a byte buffer written when it is closed). #> param([Parameter(Mandatory)][hashtable]$State) $State.Piece = New-Object 'System.Collections.Generic.List[byte]' } function script:Close-GnuCsplitPiece { <# .SYNOPSIS Writes the current piece to the next file name and prints its size (empty pieces are dropped with -z). #> param([Parameter(Mandatory)][hashtable]$State) if ($null -eq $State.Piece) { return } $bytes = $State.Piece.ToArray() $State.Piece = $null if ($State.Elide -and $bytes.Length -eq 0) { return } $name = $State.Prefix + $State.Suffix.Prefix + (Format-GnuPrintfInteger -Value $State.Number -Conversion $State.Suffix.Conversion -Flags $State.Suffix.Flags -Width $State.Suffix.Width -Precision $State.Suffix.Precision) + $State.Suffix.Rest $State.Number++ [System.IO.File]::WriteAllBytes((Resolve-GnuPath -Path $name), [byte[]]$bytes) $State.Created.Add($name) if (-not $State.Quiet) { $State.Output.Add([string]$bytes.Length) } } function script:Add-GnuCsplitLine { <# .SYNOPSIS Appends input line Index (0-based) to the current piece. #> param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][long]$Index) $range = $State.Records[$Index] for ($i = $range[0]; $i -lt $range[1]; $i++) { $State.Piece.Add($State.Data[$i]) } } function script:Stop-GnuCsplit { <# .SYNOPSIS A fatal pattern error: the rest of the input goes to the open piece, then the files are removed unless -k. #> param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Message) Write-GnuError -Context $State.Context -Message $Message if ($null -ne $State.Piece) { for ($i = $State.First; $i -lt $State.Records.Count; $i++) { Add-GnuCsplitLine -State $State -Index $i } Close-GnuCsplitPiece -State $State } if (-not $State.Keep) { foreach ($name in $State.Created) { Remove-Item -LiteralPath (Resolve-GnuPath -Path $name) -Force -ErrorAction SilentlyContinue } } $State.Failed = $true } function script:Invoke-GnuCsplitFile { <# .SYNOPSIS Implementation of csplit (the GNU algorithm: lines are numbered from 1, a piece ends before the split line). #> param([Parameter(Mandatory)][hashtable]$Context) $operands = @($Context.Operands) if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing operand'; return } if ($operands.Count -eq 1) { Write-GnuUsageError -Context $Context -Message "missing operand after '$($operands[0])'"; return } $digits = 2 if (Test-GnuOption -Context $Context -Key 'digits') { $text = [string](Get-GnuOptionValue -Context $Context -Key 'digits') if ($text -notmatch '^\d+$') { Write-GnuError -Context $Context -Message "invalid number: '$text'"; return } $digits = [int]$text } $format = "%0$($digits)d" if (Test-GnuOption -Context $Context -Key 'suffix-format') { $format = [string](Get-GnuOptionValue -Context $Context -Key 'suffix-format') } $directives = @([regex]::Matches($format, '%%|%') | Where-Object { $_.Value -eq '%' }) if ($directives.Count -eq 0) { Write-GnuError -Context $Context -Message 'missing % conversion specification in suffix'; return } if ($directives.Count -gt 1) { Write-GnuError -Context $Context -Message 'too many % conversion specifications in suffix'; return } $at = $directives[0].Index $spec = [regex]::Match($format.Substring($at), "^%([-#0']*)(\d*)(?:\.(\d*))?(.)?") $conversion = $spec.Groups[4].Value if ('diuoxX'.IndexOf($conversion, [System.StringComparison]::Ordinal) -lt 0 -or $conversion.Length -eq 0) { Write-GnuError -Context $Context -Message "invalid conversion specifier in suffix: $conversion" return } $precision = -1 if ($spec.Groups[3].Success) { $precision = 0; if ($spec.Groups[3].Value.Length -gt 0) { $precision = [int]$spec.Groups[3].Value } } $width = 0 if ($spec.Groups[2].Value.Length -gt 0) { $width = [int]$spec.Groups[2].Value } $suffix = @{ Prefix = $format.Substring(0, $at).Replace('%%', '%'); Rest = $format.Substring($at + $spec.Length).Replace('%%', '%') Conversion = $conversion.Replace('i', 'd'); Flags = $spec.Groups[1].Value; Width = $width; Precision = $precision } $parsed = ConvertFrom-GnuCsplitPattern -Context $Context -Operands @($operands | Select-Object -Skip 1) if ($parsed.ContainsKey('Error')) { Write-GnuError -Context $Context -Message $parsed.Error; return } $data = Get-GnuInputBytes -Context $Context -Operand $operands[0] if ($null -eq $data) { return } [byte[]]$data = $data $prefix = 'xx' if (Test-GnuOption -Context $Context -Key 'prefix') { $prefix = [string](Get-GnuOptionValue -Context $Context -Key 'prefix') } $state = @{ Context = $Context; Data = $data; Records = (Get-GnuSplitRecord -Data $data -Separator 10); First = [long]0; Next = [long]0 Piece = $null; Number = 0; Created = New-Object 'System.Collections.Generic.List[string]'; Output = New-Object 'System.Collections.Generic.List[string]' Prefix = $prefix; Suffix = $suffix; Quiet = Test-GnuOption -Context $Context -Key 'quiet'; Elide = Test-GnuOption -Context $Context -Key 'elide-empty-files' Keep = Test-GnuOption -Context $Context -Key 'keep-files'; Suppress = Test-GnuOption -Context $Context -Key 'suppress-matched'; Failed = $false } try { Invoke-GnuCsplitPattern -State $state -Patterns $parsed.Patterns } finally { $state.Output } } function script:Invoke-GnuCsplitPattern { <# .SYNOPSIS Applies the patterns to the input lines; the rest of the input becomes the last piece. #> param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][System.Collections.Generic.List[hashtable]]$Patterns) $total = $State.Records.Count foreach ($pattern in $Patterns) { $repetition = 0 while ($pattern.Repeat -eq -1 -or $repetition -le $pattern.Repeat) { $suffix = '' if ($repetition -gt 0) { $suffix = " on repetition $repetition" } if ($pattern.Kind -eq 'line') { # Copy up to, but not including, line LINE (LINE, 2*LINE, ... with repetitions). $target = $pattern.Line * ($repetition + 1) Open-GnuCsplitPiece -State $State if ($target -gt $total) { Stop-GnuCsplit -State $State -Message "'$($pattern.Text)': line number out of range$suffix"; return } for ($i = $State.First; $i -lt $target - 1; $i++) { Add-GnuCsplitLine -State $State -Index $i } Close-GnuCsplitPiece -State $State $State.First = [Math]::Max($State.First, $target - 1) $State.Next = $State.First if ($State.Suppress -and $State.First -lt $total) { $State.First++; $State.Next++ } } else { if (-not $pattern.Skip) { Open-GnuCsplitPiece -State $State } $match = -1 for ($i = $State.Next; $i -lt $total; $i++) { $range = $State.Records[$i] $line = $script:GnuUtf8.GetString($State.Data, [int]$range[0], [int]($range[1] - $range[0])).TrimEnd("`n").TrimEnd("`r") if ($pattern.Regex.IsMatch($line)) { $match = $i; break } } if ($match -lt 0) { if ($pattern.Repeat -eq -1 -and $repetition -gt 0) { # {*} ends at the last match: the rest goes to this piece (or is skipped with %%). if (-not $pattern.Skip) { for ($i = $State.First; $i -lt $total; $i++) { Add-GnuCsplitLine -State $State -Index $i } Close-GnuCsplitPiece -State $State } $State.First = $total $State.Done = $true return } Stop-GnuCsplit -State $State -Message "'$($pattern.Text)': match not found$suffix" return } $break = $match + $pattern.Offset if ($break -lt $State.First -or $break -gt $total) { Stop-GnuCsplit -State $State -Message "'$($pattern.Text)': line number out of range$suffix" return } if (-not $pattern.Skip) { for ($i = $State.First; $i -lt $break; $i++) { Add-GnuCsplitLine -State $State -Index $i } Close-GnuCsplitPiece -State $State } $State.First = $break $State.Next = $break + 1 if ($State.Suppress -and $State.First -lt $total -and $pattern.Offset -eq 0) { $State.First++ } } $repetition++ } } Open-GnuCsplitPiece -State $State for ($i = $State.First; $i -lt $total; $i++) { Add-GnuCsplitLine -State $State -Index $i } Close-GnuCsplitPiece -State $State } |