Utils/printf.ps1
|
Register-GnuUtil @{ Name = 'printf' Category = 'Shell helpers' Synopsis = 'Format and print data.' Usage = @('printf FORMAT [ARGUMENT]...', 'printf OPTION') Description = @('Print ARGUMENT(s) according to FORMAT, or execute according to OPTION:') RawArguments = $true Notes = @( 'FORMAT controls the output as in C printf. Interpreted sequences are:', '', ' \" double quote', ' \\ backslash', ' \a alert (BEL)', ' \b backspace', ' \c produce no further output', ' \e escape', ' \f form feed', ' \n new line', ' \r carriage return', ' \t horizontal tab', ' \v vertical tab', ' \NNN byte with octal value NNN (1 to 3 digits)', ' \xHH byte with hexadecimal value HH (1 to 2 digits)', ' \uHHHH Unicode (ISO/IEC 10646) character with hex value HHHH (4 digits)', ' \UHHHHHHHH Unicode character with hex value HHHHHHHH (8 digits)', ' %% a single %', ' %b ARGUMENT as a string with ''\'' escapes interpreted,', ' except that octal escapes are of the form \0 or \0NNN', ' %q ARGUMENT is printed in a format that can be reused as shell input,', ' escaping non-printable characters with the proposed POSIX $'''' syntax.', '', 'and all C format specifications ending with one of diouxXfeEgGcs, with', 'ARGUMENTs converted to proper type first. Variable widths are handled.', '', 'PowerShell note: %a / %A are not supported; floating point uses [double]. Quote', 'the FORMAT in PowerShell (''%d\n''), and write a literal % in it as %%.' ) } function script:Format-GnuPrintfText { <# .SYNOPSIS One pass over FORMAT. Returns @{ Text; Index (next unused argument); Stop (\c or a fatal error) }. #> param( [Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][AllowEmptyString()][string]$Format, [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Arguments, [Parameter(Mandatory)][int]$Index ) $builder = New-Object System.Text.StringBuilder $i = 0 while ($i -lt $Format.Length) { $char = $Format[$i] if ($char -eq '\') { $escape = ConvertFrom-GnuPrintfEscape -Context $Context -Text $Format -Start $i if ($escape.Stop) { return @{ Text = $builder.ToString(); Index = $Index; Stop = $true } } [void]$builder.Append($escape.Text) $i = $escape.Next continue } if ($char -ne '%') { [void]$builder.Append($char) $i++ continue } $directive = [regex]::Match($Format.Substring($i), "^%([-+ #0']*)(\*|\d+)?(?:\.(\*|\d*))?(?:hh|h|ll|l|L|j|z|t)?([a-zA-Z%])?") $conversion = $directive.Groups[4].Value $i += $directive.Length if ($conversion -eq '%' -and $directive.Length -eq 2) { [void]$builder.Append('%'); continue } if ([string]::IsNullOrEmpty($conversion) -or 'diouxXfFeEgGcsbq'.IndexOf($conversion, [System.StringComparison]::Ordinal) -lt 0) { Write-GnuError -Context $Context -Message "$($directive.Value): invalid conversion specification" return @{ Text = $builder.ToString(); Index = $Index; Stop = $true } } $flags = $directive.Groups[1].Value $width = 0 if ($directive.Groups[2].Success) { if ($directive.Groups[2].Value -eq '*') { $widthArgument = '0' if ($Index -lt $Arguments.Count) { $widthArgument = $Arguments[$Index] } $Index++ $width = [int](ConvertFrom-GnuPrintfInteger -Context $Context -Text $widthArgument) if ($width -lt 0) { $flags += '-'; $width = -$width } } else { $width = [int]$directive.Groups[2].Value } } $precision = -1 if ($directive.Groups[3].Success) { if ($directive.Groups[3].Value -eq '*') { $precisionArgument = '0' if ($Index -lt $Arguments.Count) { $precisionArgument = $Arguments[$Index] } $Index++ $precision = [int](ConvertFrom-GnuPrintfInteger -Context $Context -Text $precisionArgument) } elseif ($directive.Groups[3].Value.Length -gt 0) { $precision = [int]$directive.Groups[3].Value } else { $precision = 0 } } $argument = $null if ($Index -lt $Arguments.Count) { $argument = $Arguments[$Index] } $Index++ switch -CaseSensitive ($conversion) { 'b' { $text = '' if ($null -ne $argument) { $escaped = ConvertFrom-GnuPrintfEscape -Context $Context -Text $argument -Start 0 -All -OctalZero $text = $escaped.Text if ($escaped.Stop) { return @{ Text = $builder.ToString() + $text; Index = $Index; Stop = $true } } } [void]$builder.Append((Format-GnuPrintfPad -Text $text -Width $width -Flags $flags)) } 'c' { $text = [string][char]0 if (-not [string]::IsNullOrEmpty($argument)) { $text = [string]$argument[0] } [void]$builder.Append((Format-GnuPrintfPad -Text $text -Width $width -Flags $flags)) } 's' { $text = [string]$argument if ($precision -ge 0 -and $precision -lt $text.Length) { $text = $text.Substring(0, $precision) } [void]$builder.Append((Format-GnuPrintfPad -Text $text -Width $width -Flags $flags)) } 'q' { [void]$builder.Append((Format-GnuPrintfPad -Text (ConvertTo-GnuShellQuote -Text ([string]$argument)) -Width $width -Flags $flags)) } { 'diouxX'.IndexOf($_, [System.StringComparison]::Ordinal) -ge 0 } { $text = '0' if ($null -ne $argument) { $text = $argument } $value = ConvertFrom-GnuPrintfInteger -Context $Context -Text $text -Unsigned:('ouxX'.IndexOf($conversion, [System.StringComparison]::Ordinal) -ge 0) [void]$builder.Append((Format-GnuPrintfInteger -Value $value -Conversion $conversion -Flags $flags -Width $width -Precision $precision)) } default { $text = '0' if ($null -ne $argument) { $text = $argument } $value = ConvertFrom-GnuPrintfFloat -Context $Context -Text $text [void]$builder.Append((Format-GnuFloat -Value $value -Conversion $conversion -Precision $precision -Flags $flags.Replace("'", '') -Width $width)) } } } @{ Text = $builder.ToString(); Index = $Index; Stop = $false } } function script:ConvertFrom-GnuPrintfEscape { <# .SYNOPSIS Interprets the escape at Text[Start] (or, with -All, every escape of Text). Returns @{ Text; Next; Stop }. .PARAMETER OctalZero %b rules: octal escapes are \0NNN (the 0 is not one of the up to 3 digits). #> param( [Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][int]$Start, [switch]$All, [switch]$OctalZero ) $builder = New-Object System.Text.StringBuilder $i = $Start while ($i -lt $Text.Length) { if ($Text[$i] -ne '\') { # Only reached with -All: a single escape always starts at a backslash. [void]$builder.Append($Text[$i]) $i++ continue } if ($i + 1 -ge $Text.Length) { [void]$builder.Append('\'); $i++; if (-not $All) { break } else { continue } } $next = $Text[$i + 1] $i += 2 $simple = @{ '"' = '"'; '\' = '\'; 'a' = [char]7; 'b' = [char]8; 'e' = [char]27; 'f' = [char]12; 'n' = "`n"; 'r' = "`r"; 't' = "`t"; 'v' = [char]11 } if ($next -ceq 'c') { return @{ Text = $builder.ToString(); Next = $i; Stop = $true } } if ($simple.ContainsKey([string]$next) -and [string]$next -cmatch '^["\\abefnrtv]$') { [void]$builder.Append($simple[[string]$next]) } elseif ('01234567'.IndexOf($next) -ge 0) { $i-- if ($OctalZero -and $next -eq '0') { $i++ } $digits = '' while ($digits.Length -lt 3 -and $i -lt $Text.Length -and '01234567'.IndexOf($Text[$i]) -ge 0) { $digits += $Text[$i]; $i++ } $code = 0 if ($digits.Length -gt 0) { $code = [System.Convert]::ToInt32($digits, 8) -band 255 } [void]$builder.Append([char]$code) } elseif ($next -ceq 'x' -or $next -ceq 'u' -or $next -ceq 'U') { $maximum = 8 if ($next -ceq 'x') { $maximum = 2 } elseif ($next -ceq 'u') { $maximum = 4 } $digits = '' while ($digits.Length -lt $maximum -and $i -lt $Text.Length -and '0123456789abcdefABCDEF'.IndexOf($Text[$i]) -ge 0) { $digits += $Text[$i]; $i++ } if ($digits.Length -eq 0 -or ($next -cne 'x' -and $digits.Length -lt $maximum)) { Write-GnuError -Context $Context -Message 'missing hexadecimal number in escape' return @{ Text = $builder.ToString(); Next = $i; Stop = $true } } $code = [System.Convert]::ToInt32($digits, 16) if ($next -ceq 'x') { [void]$builder.Append([char]$code) } else { [void]$builder.Append([char]::ConvertFromUtf32($code)) } } else { [void]$builder.Append('\').Append($next) } if (-not $All) { break } } @{ Text = $builder.ToString(); Next = $i; Stop = $false } } function script:ConvertFrom-GnuPrintfInteger { <# .SYNOPSIS Converts a printf argument to an integer (decimal, 0x hex, 0 octal, 'c character code), reporting errors like GNU. #> param( [Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [switch]$Unsigned ) if ($Text.Length -ge 2 -and ($Text[0] -eq "'" -or $Text[0] -eq '"')) { return [System.Numerics.BigInteger][int][char]::ConvertToUtf32($Text, 1) } $match = [regex]::Match($Text, '^\s*([+-]?)(0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)') if (-not $match.Success) { Write-GnuError -Context $Context -Message "'$Text': expected a numeric value" return [System.Numerics.BigInteger]::Zero } $digits = $match.Groups[2].Value $value = [System.Numerics.BigInteger]::Zero if ($digits -match '^0[xX]') { foreach ($digit in $digits.Substring(2).ToCharArray()) { $value = $value * 16 + [System.Convert]::ToInt32([string]$digit, 16) } } elseif ($digits.StartsWith('0', [System.StringComparison]::Ordinal)) { foreach ($digit in $digits.ToCharArray()) { $value = $value * 8 + [int][string]$digit } } else { $value = [System.Numerics.BigInteger]::Parse($digits, [System.Globalization.CultureInfo]::InvariantCulture) } if ($match.Groups[1].Value -eq '-') { $value = -$value } $minimum = [System.Numerics.BigInteger][long]::MinValue $maximum = [System.Numerics.BigInteger][long]::MaxValue if ($Unsigned) { $maximum = [System.Numerics.BigInteger][uint64]::MaxValue $minimum = -$maximum } if ($value -gt $maximum -or $value -lt $minimum) { Write-GnuError -Context $Context -Message "'$Text': Numerical result out of range" if ($value -gt $maximum -or $Unsigned) { $value = $maximum } else { $value = $minimum } } elseif ($match.Length -lt $Text.Length) { Write-GnuError -Context $Context -Message "'$Text': value not completely converted" } $value } function script:ConvertFrom-GnuPrintfFloat { <# .SYNOPSIS Converts a printf argument to a [double], reporting errors like GNU. #> param( [Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][AllowEmptyString()][string]$Text ) if ($Text.Length -ge 2 -and ($Text[0] -eq "'" -or $Text[0] -eq '"')) { return [double][char]::ConvertToUtf32($Text, 1) } $match = [regex]::Match($Text, '^\s*([+-]?)((?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|(?i:inf(?:inity)?|nan))') if (-not $match.Success) { Write-GnuError -Context $Context -Message "'$Text': expected a numeric value" return 0.0 } $body = $match.Groups[2].Value.ToLowerInvariant() if ($body.StartsWith('inf', [System.StringComparison]::Ordinal)) { $value = [double]::PositiveInfinity } elseif ($body -eq 'nan') { $value = [double]::NaN } else { $value = [double]::Parse($body, [System.Globalization.CultureInfo]::InvariantCulture) } if ($match.Groups[1].Value -eq '-') { $value = -$value } if ($match.Length -lt $Text.Length) { Write-GnuError -Context $Context -Message "'$Text': value not completely converted" } $value } function script:Format-GnuPrintfInteger { <# .SYNOPSIS Formats an integer like C printf (d i o u x X with flags, width and precision). #> param( [Parameter(Mandatory)][System.Numerics.BigInteger]$Value, [Parameter(Mandatory)][string]$Conversion, [AllowEmptyString()][string]$Flags = '', [int]$Width = 0, [int]$Precision = -1 ) $sign = '' $prefix = '' if ($Conversion -eq 'd' -or $Conversion -eq 'i') { if ($Value -lt 0) { $sign = '-' } elseif ($Flags.Contains('+')) { $sign = '+' } elseif ($Flags.Contains(' ')) { $sign = ' ' } $digits = [System.Numerics.BigInteger]::Abs($Value).ToString([System.Globalization.CultureInfo]::InvariantCulture) } else { if ($Value -lt 0) { $Value += [System.Numerics.BigInteger]::Pow(2, 64) } $base = 10 if ($Conversion -eq 'o') { $base = 8 } elseif ($Conversion -ceq 'x' -or $Conversion -ceq 'X') { $base = 16 } $digits = ConvertTo-GnuRadixString -Value $Value -Base $base if ($Conversion -ceq 'X') { $digits = $digits.ToUpperInvariant() } } if ($Precision -eq 0 -and $Value -eq 0) { $digits = '' } if ($Precision -gt $digits.Length) { $digits = $digits.PadLeft($Precision, '0') } if ($Flags.Contains('#')) { if ($Conversion -eq 'o' -and -not $digits.StartsWith('0', [System.StringComparison]::Ordinal)) { $digits = '0' + $digits } elseif ($Conversion -ceq 'x' -and $Value -ne 0) { $prefix = '0x' } elseif ($Conversion -ceq 'X' -and $Value -ne 0) { $prefix = '0X' } } $text = $sign + $prefix + $digits if ($text.Length -ge $Width) { return $text } if ($Flags.Contains('-')) { return $text.PadRight($Width) } if ($Flags.Contains('0') -and $Precision -lt 0) { return $sign + $prefix + $digits.PadLeft($Width - $sign.Length - $prefix.Length, '0') } $text.PadLeft($Width) } function script:ConvertTo-GnuRadixString { <# .SYNOPSIS A non-negative BigInteger in base 8, 10 or 16 (lower case, no leading zeros). #> param([Parameter(Mandatory)][System.Numerics.BigInteger]$Value, [Parameter(Mandatory)][int]$Base) if ($Value -eq 0) { return '0' } $builder = New-Object System.Text.StringBuilder while ($Value -gt 0) { [void]$builder.Insert(0, '0123456789abcdef'[[int]($Value % $Base)]) $Value = [System.Numerics.BigInteger]::Divide($Value, $Base) } $builder.ToString() } function script:Format-GnuPrintfPad { <# .SYNOPSIS Pads a string conversion to Width (left aligned with the '-' flag). #> param([Parameter(Mandatory)][AllowEmptyString()][string]$Text, [int]$Width = 0, [AllowEmptyString()][string]$Flags = '') if ($Text.Length -ge $Width) { return $Text } if ($Flags.Contains('-')) { return $Text.PadRight($Width) } $Text.PadLeft($Width) } function script:ConvertTo-GnuShellQuote { <# .SYNOPSIS Quotes a string for reuse as shell input, like GNU's shell-escape quoting (printf %q, ls -Q style). #> param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) if ($Text.Length -eq 0) { return "''" } $special = '\s!"$&''()*;<>?\[\\\]`{|}' if ($Text -cmatch "^[^~#$special][^$special]*`$" -and $Text -cnotmatch '[\x00-\x1f\x7f]') { return $Text } if ($Text -cnotmatch '[\x00-\x1f\x7f]') { if ($Text.Contains("'") -and $Text -cnotmatch '[$`\\"!]') { return '"' + $Text + '"' } return "'" + $Text.Replace("'", "'\''") + "'" } # Control characters: printable runs in single quotes, control characters as $'\t'. $names = @{ 7 = 'a'; 8 = 'b'; 9 = 't'; 10 = 'n'; 11 = 'v'; 12 = 'f'; 13 = 'r' } $builder = New-Object System.Text.StringBuilder foreach ($run in [regex]::Matches($Text, '[\x00-\x1f\x7f]+|[^\x00-\x1f\x7f]+')) { if ($run.Value -cmatch '^[\x00-\x1f\x7f]') { [void]$builder.Append("`$'") foreach ($char in $run.Value.ToCharArray()) { if ($names.ContainsKey([int]$char)) { [void]$builder.Append('\' + $names[[int]$char]) } else { [void]$builder.Append('\' + [System.Convert]::ToString([int]$char, 8).PadLeft(3, '0')) } } [void]$builder.Append("'") } else { [void]$builder.Append("'" + $run.Value.Replace("'", "'\''") + "'") } } $builder.ToString() } |