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() } } } |