BicepConsoleTTK.psm1
|
function Import-Bicep { [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string[]]$ImportString ) begin { $collatedContent = [System.Text.StringBuilder]::new() $emittedMembers = [System.Collections.Generic.HashSet[string]]::new() } process { foreach ($s in $ImportString) { # Parse the import string $match = [regex]::Match($s, "[iI]mport\s+(.+)\s+from\s+'(.+)'") if ($match.Success) { $imports = $match.Groups[1].Value.Trim() $filePath = $match.Groups[2].Value.Trim() # Resolve the file path $resolvedPath = Resolve-Path -LiteralPath $filePath -ErrorAction SilentlyContinue if (-not $resolvedPath) { throw "Import-Bicep: File not found: $filePath" } # Read the file content $content = Get-Content -Path $resolvedPath -Raw -Encoding UTF8 # Extract the specified members $fileLines = $content -split '\r?\n' $members = if ($imports -eq '*') { $null } else { $imports.Trim('{}').Split(',') | ForEach-Object { $_.Trim() } } # ── Helper: extract one member (and its leading decorators) from $fileLines. # Returns @{ Content = string; EndLine = int } where EndLine = -1 on failure. # Variables intentionally prefixed _x_ to minimise scope bleed when called with &. $extractOneMember = { param([string[]]$_x_lines, [int]$_x_start, [int]$_x_declare, [string]$_x_type) $_x_bc = 0; $_x_bk = 0; $_x_mc = ''; $_x_ei = -1 for ($_x_k = $_x_start; $_x_k -lt $_x_lines.Length; $_x_k++) { $_x_cl = $_x_lines[$_x_k] $_x_mc += $_x_cl + "`n" $_x_bc += ($_x_cl.ToCharArray() | Where-Object { $_ -eq '{' }).Count $_x_bc -= ($_x_cl.ToCharArray() | Where-Object { $_ -eq '}' }).Count $_x_bk += ($_x_cl.ToCharArray() | Where-Object { $_ -eq '[' }).Count $_x_bk -= ($_x_cl.ToCharArray() | Where-Object { $_ -eq ']' }).Count if ($_x_bc -eq 0 -and $_x_bk -eq 0 -and $_x_k -ge $_x_declare) { if ($_x_type -eq 'func' -and $_x_cl.TrimEnd().EndsWith('=>')) { $_x_k++ while ($_x_k -lt $_x_lines.Length -and [string]::IsNullOrWhiteSpace($_x_lines[$_x_k])) { $_x_k++ } $_x_fp = 0 while ($_x_k -lt $_x_lines.Length) { $_x_fl = $_x_lines[$_x_k] $_x_mc += $_x_fl + "`n" $_x_fp += ($_x_fl.ToCharArray() | Where-Object { $_ -eq '(' }).Count $_x_fp -= ($_x_fl.ToCharArray() | Where-Object { $_ -eq ')' }).Count $_x_nk = $_x_k + 1 while ($_x_nk -lt $_x_lines.Length -and [string]::IsNullOrWhiteSpace($_x_lines[$_x_nk])) { $_x_nk++ } $_x_nt = if ($_x_nk -lt $_x_lines.Length) { $_x_lines[$_x_nk].Trim() } else { '' } if (($_x_fp -le 0) -and -not ($_x_nt.StartsWith('?') -or $_x_nt.StartsWith(':'))) { break } $_x_k++ } $_x_ei = $_x_k; break } if ($_x_type -eq 'func' -and $_x_cl.Contains('=>')) { $_x_ei = $_x_k; break } if ($_x_cl.Trim().EndsWith('}') -or $_x_cl.Trim().EndsWith(']') -or ($_x_cl.Trim() -eq '' -and $_x_mc.Contains('{'))) { $_x_ei = $_x_k; break } if ($_x_type -eq 'var' -and -not $_x_cl.Contains('{') -and -not $_x_cl.Contains('[')) { $_x_ei = $_x_k; break } } } return @{ Content = $_x_mc; EndLine = $_x_ei } } # ── Pre-scan: build a full index of every member in the file so we can # resolve dependencies when emitting. Stores { Content; EndLine } per name. $fileIndex = @{} $_pi = 0 while ($_pi -lt $fileLines.Length) { $_pm = [regex]::Match($fileLines[$_pi], '^\s*(type|func|var)\s+([a-zA-Z0-9_]+)') if ($_pm.Success) { $_pName = $_pm.Groups[2].Value $_pType = $_pm.Groups[1].Value $_pStart = $_pi for ($_pj = $_pi - 1; $_pj -ge 0; $_pj--) { if ($fileLines[$_pj].Trim().StartsWith('@')) { $_pStart = $_pj } elseif ($fileLines[$_pj].Trim() -eq '' -or $fileLines[$_pj].Trim().StartsWith('//')) { } else { break } } $_pExt = & $extractOneMember $fileLines $_pStart $_pi $_pType if ($_pExt.EndLine -ne -1 -and -not $fileIndex.ContainsKey($_pName)) { $fileIndex[$_pName] = $_pExt } if ($_pExt.EndLine -ne -1) { $_pi = $_pExt.EndLine } } $_pi++ } # ── Dependency-aware emit: emits array-type dependencies before the member itself. # The regex captures the identifier immediately before '[', which covers: # type Alias = ElementType[] (array alias) # prop: ElementType[]? (inline array property) $bicepBuiltins = [System.Collections.Generic.HashSet[string]]::new( [string[]]@('string', 'int', 'bool', 'object', 'array', 'any', 'null', 'true', 'false') ) $emitWithDeps = $null $emitWithDeps = { param([string]$_memberName) $_mc = $fileIndex[$_memberName].Content # Find types referenced in array positions and emit them first [regex]::Matches($_mc, '\b([a-zA-Z][a-zA-Z0-9]*)\s*\[') | ForEach-Object { $_.Groups[1].Value } | Where-Object { -not $bicepBuiltins.Contains($_) -and $fileIndex.ContainsKey($_) } | Select-Object -Unique | ForEach-Object { $_depKey = "$($resolvedPath.Path)::$_" if ($emittedMembers.Add($_depKey)) { & $emitWithDeps $_ } } [void]$collatedContent.Append($_mc + "`n") } # ── Main scan: emit requested members in file order, with dependencies injected first. $i = 0 while ($i -lt $fileLines.Length) { $line = $fileLines[$i] $memberMatch = [regex]::Match($line, '^\s*(type|func|var)\s+([a-zA-Z0-9_]+)') if ($memberMatch.Success) { $memberType = $memberMatch.Groups[1].Value $memberName = $memberMatch.Groups[2].Value if ($null -eq $members -or $memberName -in $members) { $memberKey = "$($resolvedPath.Path)::$memberName" if ($emittedMembers.Add($memberKey)) { & $emitWithDeps $memberName } # Advance past the member using the pre-built index if ($fileIndex.ContainsKey($memberName) -and $fileIndex[$memberName].EndLine -ne -1) { $i = $fileIndex[$memberName].EndLine } } } $i++ } # Warn about any named members that were not found in the file if ($null -ne $members) { foreach ($requestedMember in $members) { $memberKey = "$($resolvedPath.Path)::$requestedMember" if (-not $emittedMembers.Contains($memberKey)) { Write-Warning "Import-Bicep: Member '$requestedMember' was not found in '$filePath'" } } } } } } end { return $collatedContent.ToString() } } function Invoke-BicepExpression { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [Alias('b')] [string]$BicepImports = '', [Parameter(Mandatory = $true)] [Alias('e')] [string]$Expression, [Parameter(Mandatory = $false)] [Alias('s')] [string[]]$SetupDeclarations = @() ) process { # Strip all decorators - they are not valid in bicep console interactive mode $fullBicepImports = ($BicepImports -split '\r?\n' | Where-Object { $_ -notmatch '^\s*@' }) -join "`n" # Bicep console processes input line-by-line and only waits for more input when braces # are unbalanced. Arrow functions whose expression body starts on the next line (e.g. # multi-line ternaries) must be collapsed to a single line so the console sees them as # one complete statement. Block bodies (=> {}) are left untouched. $collapsedLines = [System.Collections.ArrayList]::new() $importLines = $fullBicepImports -split '\r?\n' $li = 0 while ($li -lt $importLines.Length) { $importLine = $importLines[$li] if ($importLine.TrimEnd().EndsWith('=>')) { # Find the first non-blank body line $bodyStart = $li + 1 while ($bodyStart -lt $importLines.Length -and [string]::IsNullOrWhiteSpace($importLines[$bodyStart])) { $bodyStart++ } if ($bodyStart -lt $importLines.Length -and -not $importLines[$bodyStart].Trim().StartsWith('{')) { # Expression body — merge all continuation lines onto the => line. # When inside an object literal ({ }), add commas between properties so # the collapsed single-line form is valid Bicep (e.g. { a: 1, b: 2 }). $merged = $importLine.TrimEnd() $parenDepth = 0 $localBraceDepth = 0 $bi = $bodyStart while ($bi -lt $importLines.Length) { if ([string]::IsNullOrWhiteSpace($importLines[$bi])) { $bi++; continue } $bl = $importLines[$bi].Trim() # Inside an object literal, consecutive property lines need a comma separator. # A property line matches: identifier: value OR 'quoted-key': value $isObjProp = $localBraceDepth -gt 0 -and ($bl -match "^([a-zA-Z_][a-zA-Z0-9_]*|'[^']*')\s*:") -and -not $merged.TrimEnd().EndsWith('{') -and -not $merged.TrimEnd().EndsWith(',') if ($isObjProp) { $merged += ', ' + $bl } else { $merged += ' ' + $bl } $parenDepth += ($bl.ToCharArray() | Where-Object { $_ -eq '(' }).Count $parenDepth -= ($bl.ToCharArray() | Where-Object { $_ -eq ')' }).Count $localBraceDepth += ($bl.ToCharArray() | Where-Object { $_ -eq '{' }).Count $localBraceDepth -= ($bl.ToCharArray() | Where-Object { $_ -eq '}' }).Count $peekIdx = $bi + 1 while ($peekIdx -lt $importLines.Length -and [string]::IsNullOrWhiteSpace($importLines[$peekIdx])) { $peekIdx++ } $peekLine = if ($peekIdx -lt $importLines.Length) { $importLines[$peekIdx].Trim() } else { '' } if (($parenDepth -le 0) -and -not ($peekLine.StartsWith('?') -or $peekLine.StartsWith(':'))) { break } $bi++ } [void]$collapsedLines.Add($merged) $li = $bi + 1 continue } } [void]$collapsedLines.Add($importLine) $li++ } $fullBicepImports = $collapsedLines -join "`n" # Guard: ensure bicep CLI is available $bicepCmd = Get-Command bicep -ErrorAction SilentlyContinue if (-not $bicepCmd) { throw "bicep CLI not found. Install from: https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install" } # Start the Bicep console and pass the imports, declarations, and expression to it $bicepPath = $bicepCmd.Source $processInfo = New-Object System.Diagnostics.ProcessStartInfo $processInfo.FileName = $bicepPath $processInfo.Arguments = "console" $processInfo.RedirectStandardInput = $true $processInfo.RedirectStandardOutput = $true $processInfo.RedirectStandardError = $true $processInfo.UseShellExecute = $false $processInfo.CreateNoWindow = $true # Explicitly request UTF-8 for stdout/stderr so output is decoded correctly on # Windows PowerShell 5.1, where the default is the system OEM code page. $processInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $processInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $process = New-Object System.Diagnostics.Process $process.StartInfo = $processInfo try { $process.Start() | Out-Null $inputStream = $process.StandardInput # Write the imported declarations, any setup declarations, then the main expression $inputStream.WriteLine($fullBicepImports) foreach ($setup in $SetupDeclarations) { $inputStream.WriteLine($setup) } $inputStream.WriteLine($Expression) $inputStream.Close() $output = $process.StandardOutput.ReadToEnd() $stderrOutput = $process.StandardError.ReadToEnd() # drain stderr to prevent buffer deadlock $completed = $process.WaitForExit(30000) # 30-second timeout guards against a hung console if (-not $completed) { $process.Kill() throw "Bicep console timed out after 30 seconds evaluating: $Expression" } # Bicep console writes errors to stdout as: # <offending expression echoed> # ~~~~ <error message> $outputLines = $output -split '\r?\n' $errorLineIndex = ($outputLines | Select-String -Pattern '^\s*[~\^]+\s+\S').LineNumber | Select-Object -First 1 if ($errorLineIndex) { $tildeLineIdx = $errorLineIndex - 1 $cleanMessage = ($outputLines[$tildeLineIdx] -replace '^\s*[~\^]+\s*', '').Trim() $echoedExpr = if ($tildeLineIdx -ge 1) { $outputLines[$tildeLineIdx - 1].Trim() } else { '' } throw "Bicep console error on '$echoedExpr': $cleanMessage" } # Surface any process-level stderr (e.g. crash, missing DLL) that was not expressed # as a bicep-format error on stdout, so it is not silently swallowed. if (-not [string]::IsNullOrWhiteSpace($stderrOutput)) { Write-Verbose "Bicep console stderr: $($stderrOutput.Trim())" if ([string]::IsNullOrWhiteSpace($output)) { throw "Bicep console process error: $($stderrOutput.Trim())" } } # Return the full trimmed output (bicep console outputs the result directly to stdout) return $output.Trim() } finally { $process.Dispose() } } } # Private helper — not exported. Recurses through a PS value and renders it as the string # that the Bicep console would produce for an equivalent Bicep value. function Format-BicepValue { param( $Value, [int]$Depth = 0 ) # null if ($null -eq $Value) { return 'null' } # bool — must be checked before numeric types if ($Value -is [bool]) { if ($Value) { return 'true' } else { return 'false' } } # numeric types if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [float] -or $Value -is [decimal] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [uint32] -or $Value -is [uint64]) { return "$Value" } # string — wrap in single quotes; escape internal single quotes as '' if ($Value -is [string]) { $escaped = $Value -replace "'", "''" return "'$escaped'" } # unordered hashtable — reject; key order is not guaranteed and would produce # non-deterministic output that may not match the Bicep console result if ($Value -is [hashtable]) { throw "BicepConsoleTTK: Unordered [hashtable] detected. Use [ordered]@{} or [pscustomobject]@{} to preserve property order, which is required to match Bicep console output." } # [ordered]@{} — System.Collections.Specialized.OrderedDictionary if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { if ($Value.Count -eq 0) { return '{}' } $indent = ' ' * ($Depth + 1) $closingIndent = ' ' * $Depth $sb = [System.Text.StringBuilder]::new() [void]$sb.Append("{`n") foreach ($key in $Value.Keys) { $formattedVal = Format-BicepValue -Value $Value[$key] -Depth ($Depth + 1) [void]$sb.Append("$indent${key}: $formattedVal`n") } [void]$sb.Append("$closingIndent}") return $sb.ToString() } # [pscustomobject] — iterate NoteProperty members in declaration order if ($Value -is [pscustomobject]) { $props = @($Value.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' }) if ($props.Count -eq 0) { return '{}' } $indent = ' ' * ($Depth + 1) $closingIndent = ' ' * $Depth $sb = [System.Text.StringBuilder]::new() [void]$sb.Append("{`n") foreach ($prop in $props) { $formattedVal = Format-BicepValue -Value $prop.Value -Depth ($Depth + 1) [void]$sb.Append("$indent$($prop.Name): $formattedVal`n") } [void]$sb.Append("$closingIndent}") return $sb.ToString() } # array / any other IEnumerable (strings and dictionaries already handled above) if ($Value -is [System.Collections.IEnumerable]) { $items = @($Value) if ($items.Count -eq 0) { return '[]' } $indent = ' ' * ($Depth + 1) $closingIndent = ' ' * $Depth $sb = [System.Text.StringBuilder]::new() [void]$sb.Append("[`n") foreach ($item in $items) { $formattedItem = Format-BicepValue -Value $item -Depth ($Depth + 1) [void]$sb.Append("$indent$formattedItem`n") } [void]$sb.Append("$closingIndent]") return $sb.ToString() } throw "BicepConsoleTTK: Unsupported type '$($Value.GetType().FullName)'. Supported input types: null, bool, numeric, string, [ordered]@{}, [pscustomobject], array." } function ConvertTo-BicepConsoleResult { <# .SYNOPSIS Converts a PowerShell value to the string format returned by the Bicep console REPL. .DESCRIPTION Transforms a PowerShell null, bool, number, string, ordered hashtable, PSCustomObject, or array into the exact string that Invoke-BicepExpression returns for an equivalent Bicep value. Use the result directly in a Pester Should -Be assertion instead of hand-crafting multi-line escape sequences. Objects must be passed as [ordered]@{} or [pscustomobject]@{} — regular [hashtable] inputs (unordered @{}) are rejected because their non-deterministic key order would produce output that may not match the Bicep console. .EXAMPLE $result = Invoke-BicepExpression -b $imports -e "newCoreParams('uksouth','uks','dev','myapp')" $expected = ConvertTo-BicepConsoleResult ([ordered]@{ location = 'uksouth' locationShortName = 'uks' environment = 'dev' projectPrefix = 'myapp' }) $result | Should -Be $expected .EXAMPLE # Pipeline input is supported $expected = 'my-value' | ConvertTo-BicepConsoleResult # returns "'my-value'" #> [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowNull()] $Value ) process { return Format-BicepValue -Value $Value -Depth 0 } } function Read-BicepLiteral { <# .SYNOPSIS Reads a JSON file and returns the Bicep literal expression string for its content. .DESCRIPTION Loads a JSON file from the specified path, deserialises it with ConvertFrom-Json (which preserves JSON key order as a PSCustomObject on all supported PowerShell versions), then passes it through the same Format-BicepValue private helper used by ConvertTo-BicepLiteral. The result is the Bicep literal string for the file's content — exactly the value portion that appears after = in a Bicep declaration. Use it inside a PowerShell subexpression when building SetupDeclarations so large or shared fixture objects can be stored in a separate JSON file rather than inlined in the test: `$setup = @( "var op apiOperationDefinition = `$(Read-BicepLiteral '`$PSScriptRoot/test-data/apim-op.json')" ) The -Path parameter is resolved relative to the caller's current location. A clear 'Read-BicepLiteral: File not found' error is thrown when the file is missing, consistent with the error style of Import-Bicep. .EXAMPLE `$setup = @( "var op apiOperationDefinition = `$(Read-BicepLiteral '`$PSScriptRoot/test-data/apim-op.json')" ) Invoke-BicepExpression -BicepImports `$imports -SetupDeclarations `$setup -Expression "op" .EXAMPLE # Simple scalar JSON: file content is just a JSON string # apim-name.json: "get-customer" `$literal = Read-BicepLiteral '`$PSScriptRoot/test-data/apim-name.json' # returns "'get-customer'" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path ) process { $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if (-not $resolved) { throw "Read-BicepLiteral: File not found: $Path" } $content = Get-Content -Path $resolved -Raw -Encoding UTF8 $data = $content | ConvertFrom-Json return Format-BicepValue -Value $data -Depth 0 } } function ConvertTo-BicepLiteral { <# .SYNOPSIS Converts a PowerShell value to a Bicep literal expression string. .DESCRIPTION Transforms a PowerShell null, bool, number, string, ordered hashtable, PSCustomObject, or array into the Bicep literal string for that value — exactly the value portion that appears after the = in a Bicep declaration. Use it inside a PowerShell subexpression when building setup declarations for Invoke-BicepExpression, so complex typed values can be written as structured PowerShell data rather than hand-crafted single-line strings. The output format is identical to ConvertTo-BicepConsoleResult (multi-line, indented). Both functions share the private Format-BicepValue helper. The Bicep console tolerates multi-line setup declarations because it waits for braces to balance before evaluating. Objects must be passed as [ordered]@{} or [pscustomobject]@{} — regular [hashtable] inputs (unordered @{}) are rejected because their non-deterministic key order would produce output that cannot reliably match the Bicep console. .EXAMPLE $setup = @( "var op apiOperationDefinition = `$(ConvertTo-BicepLiteral ([ordered]@{ name = 'get-customer' properties = [ordered]@{ displayName = 'Get Customer' method = 'GET' urlTemplate = '/customers/{customerId}' description = 'Retrieves a customer by ID' templateParameters = @( [ordered]@{ name = 'customerId'; type = 'string'; required = `$true } ) request = [ordered]@{ queryParameters = @( [ordered]@{ name = 'includeOrders'; type = 'bool'; required = `$false } ) headers = @( [ordered]@{ name = 'x-correlation-id'; type = 'string'; required = `$false; values = @('abc', 'def') } ) } responses = @( [ordered]@{ statusCode = 200 } [ordered]@{ statusCode = 404 } ) } }))" ) Invoke-BicepExpression -BicepImports `$imports -SetupDeclarations `$setup -Expression "op" .EXAMPLE # Pipeline input is supported $literal = 'my-value' | ConvertTo-BicepLiteral # returns "'my-value'" #> [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowNull()] $Value ) process { return Format-BicepValue -Value $Value -Depth 0 } } |