Transpilers/Core/PipeScript.Template.psx.ps1
<# .Synopsis Template Transpiler .Description The PipeScript Core Template Transpiler. This allows PipeScript to generate many other languages. Regardless of the underlying language, the core template transpiler works in a fairly straightforward way. A language will contain PipeScript within the file (usually in comments). If a Regular Expression can match each section, then the content in each section can be replaced. When a file that can be transpiled is encountered, the template transpiler for that file type will call the core template transpiler. When templates are used as a keyword, the template transpiler will produce an object that can evaluate the template on demand. #> [ValidateScript({ $validating = $_ if ($validating -isnot [Management.Automation.Language.CommandAst]) { # Leave non-AST commands alone for the moment. } else { # For CommandASTs, make a list of the barewords $barewords = @(foreach ($cmdElement in $validating.CommandElements) { if ($cmdElement.StringConstantType -ne 'Bareword') { break } $cmdElement.Value }) # If one of the first two barewords was 'template if ($barewords[0..1] -eq 'template') { return $true # return true (and run this transpiler) } # Otherwise, return false. return $false } })] [Reflection.AssemblyMetaData('Order', -1)] param( # A string containing the text contents of the file [Parameter()] [Alias('TemplateText')] [string] $SourceText, [Alias('Replace')] [ValidateScript({ if ($_.GetGroupNames() -notcontains 'PS' -and $_.GetGroupNames() -notcontains 'PipeScript' ) { throw "Group Name PS or PipeScript required" } return $true })] [regex] $ReplacePattern, # The timeout for a replacement. By default, 15 seconds. [timespan] $ReplaceTimeout = '00:00:15', # The name of the template. This can be implied by the pattern. [Alias('Name')] $TemplateName, # The Start Pattern. # This indicates the beginning of what should be considered PipeScript. # An expression will match everything until -EndPattern [Alias('StartRegex')] [Regex] $StartPattern, # The End Pattern # This indicates the end of what should be considered PipeScript. [Alias('EndRegex')] [Regex] $EndPattern, # A custom replacement evaluator. # If not provided, will run any embedded scripts encountered. # The output of these scripts will be the replacement text. [Alias('Replacer')] [ScriptBlock] $ReplacementEvaluator, # If set, will not transpile script blocks. [switch] $NoTranspile, # The path to the source file. [string] $SourceFile, # A Script Block that will be injected before each inline is run. [ScriptBlock] $Begin, # A Script Block that will be piped to after each output. [Alias('Process')] [ScriptBlock] $ForeachObject, # A Script Block that will be injected after each inline script is run. [ScriptBlock] $End, # A collection of parameters [Collections.IDictionary] $Parameter = @{}, # An argument list. [Alias('Args')] [PSObject[]] $ArgumentList = @(), # Some languages only allow single-line comments. # To work with these languages, provide a -LinePattern indicating what makes a comment # Only lines beginning with this pattern within -StartPattern and -EndPattern will be considered a script. [Regex] $LinePattern, [switch] $AsScriptBlock, # The Command Abstract Syntax Tree. If this is provided, we are transpiling a template keyword. [Parameter(ValueFromPipeline)] [Management.Automation.Language.CommandAst] $CommandAst ) begin { $GetInlineScript = { param($match) $pipeScriptText = if ($Match.Groups["PipeScript"].Value) { $Match.Groups["PipeScript"].Value } elseif ($match.Groups["PS"].Value) { $Match.Groups["PS"].Value } if (-not $pipeScriptText) { return } if ($this.LinePattern) {$LinePattern = $this.LinePattern} if ($LinePattern -and $match.Groups["IsSingleLine"].Value) { $pipeScriptLines = @($pipeScriptText -split '(?>\r\n|\n)' -ne '') if ($pipeScriptLines.Length -gt 1) { $firstLine, $restOfLines = $pipeScriptLines $restOfLines = @($restOfLines) $pipeScriptText = @(@($firstLine) + $restOfLines -match $LinePattern -replace $LinePattern) -join [Environment]::Newline } else { $pipeScriptText = @($pipeScriptLines -match $LinePattern -replace $LinePattern) -join [Environment]::Newline } } $InlineScriptBlock = [scriptblock]::Create($pipeScriptText) if (-not $InlineScriptBlock) { return } if ((-not $NoTranspile) -and $ExecutionContext.SessionState.InvokeCommand.GetCommand('.>PipeScript','Alias') ) { $TranspiledOutput = $InlineScriptBlock | .>Pipescript if ($TranspiledOutput -is [ScriptBlock]) { $InlineScriptBlock = $TranspiledOutput } } $InlineScriptBlock } } process { #region Finding Template Transpiler if ($CommandAst) { # Get command transpilers $LanguageCommands = @(Get-PipeScript -PipeScriptType Language) # Collect all of the bareword arguments $barewords = @(foreach ($cmdElement in $CommandAst.CommandElements) { if ( $cmdElement -isnot [Management.Automation.Language.StringConstantExpressionAst] -or $cmdElement.StringConstantType -ne 'Bareword' ) { break } $cmdElement.Value }) # Find a matching template transpiler. $foundTemplateTranspiler = :nextTranspiler foreach ($LanguageCommand in $LanguageCommands) { $langName = $LanguageCommand.Name -replace 'Language\p{P}' -replace 'ps1$' if (-not $langName) { continue } if ($barewords -contains $langName) { $LanguageCommand continue } if ($CommandAst.CommandElements[1] -is [Management.Automation.Language.MemberExpressionAst] -and $CommandAst.CommandElements[1].Member.Value -eq $langName) { $LanguageCommand continue } $attrList = if ($LanguageCommand.ScriptBlock.Attributes) { $LanguageCommand.ScriptBlock.Attributes } $languageCmd = $LanguageCommand if (-not $ReplaceTimeout) { $ReplaceTimeout = [timespan]"00:00:15" } $languageDefinition = & $languageCmd if ($languageDefinition.FilePattern) { $regexPattern = [Regex]::new($languageDefinition.FilePattern, "IgnoreCase,IgnorePatternWhitespace", $ReplaceTimeout) for ($barewordIndex = 0 ; $barewordIndex -lt 3; $barewordIndex++) { if (-not $barewords[$barewordIndex]) { continue } if ($regexPattern.Match($barewords[$barewordIndex]).Success) { $templateName = $barewords[$barewordIndex] $LanguageCmd continue nextTranspiler } } } foreach ($attr in $attrList) { if ($attr -isnot [Management.Automation.ValidatePatternAttribute]) { continue } $regexPattern = [Regex]::new($attr.RegexPattern, $attr.Options, $ReplaceTimeout) break for ($barewordIndex = 0 ; $barewordIndex -lt 3; $barewordIndex++) { if (-not $barewords[$barewordIndex]) { continue } if ($regexPattern.Match($barewords[$barewordIndex]).Success) { $templateName = $barewords[$barewordIndex] $languageCmd continue nextTranspiler } } if ($CommandAst.CommandElements[1] -is [Management.Automation.Language.MemberExpressionAst] -and $regexPattern.Match(('.' + $CommandAst.CommandElements[1].Member)).Success) { $languageCmd continue nextTranspiler } } } # If we found a template transpiler # we'll want to effectively pack the transpilation engine into an object if ($foundTemplateTranspiler) { if (-not $foundTemplateTranspiler.Parameters.AsTemplateObject -and -not ($foundTemplateTranspiler.pstypenames -contains 'Language.Command')) { Write-Error "$($foundTemplateTranspiler) does not support dynamic use" return } if ($foundTemplateTranspiler.pstypenames -contains 'Language.Command') { $languageDef = & $foundTemplateTranspiler foreach ($kv in $languageDef.psobject.properties) { $ExecutionContext.SessionState.PSVariable.Set($kv.Name, $kv.Value) $PSBoundParameters[$kv.Name] = $kv.Value } } else { $Splat = & $foundTemplateTranspiler -AsTemplateObject foreach ($kv in $splat.GetEnumerator()) { $ExecutionContext.SessionState.PSVariable.Set($kv.Key, $kv.Value) $PSBoundParameters[$kv.Key] = $kv.Value } } } } #endregion Finding Template Transpiler if ($barewords -contains 'function') { $AsScriptBlock = $true } if ($StartPattern -and $EndPattern) { if (-not $ReplaceTimeout) { $ReplaceTimeout = [timespan]"00:00:15" } # If the Source Start and End were provided, # create a replacepattern that matches all content until the end pattern. $ReplacePattern = [Regex]::New(" # Match the PipeScript Start $StartPattern # Match until the PipeScript end. This will be PipeScript (?<PipeScript> (?:.|\s){0,}?(?=\z|$endPattern) ) # Then Match the PipeScript End $EndPattern ", 'IgnoreCase, IgnorePatternWhitespace', $ReplaceTimeout) # Now switch the parameter set to SourceTextReplace $psParameterSet = 'SourceTextReplace' } $newModuleSplat = @{ScriptBlock={}} if ($SourceFile) { $newModuleSplat.Name = $SourceFile } # See if we have a replacement evaluator. if (-not $PSBoundParameters["ReplacementEvaluator"]) { # If we don't, create one. $ReplacementEvaluator = { param($match) $InlineScriptBlock = if ($this.GetInlineScript) { $this.GetInlineScript($match) } else { & $GetInlineScript $match } if (-not $InlineScriptBlock) { return } $inlineAstString = $InlineScriptBlock.Ast.Extent.ToString() if ($InlineScriptBlock.Ast.ParamBlock) { $inlineAstString = $inlineAstString.Replace($InlineScriptBlock.Ast.ParamBlock.Extent.ToString(), '') } $inlineAstString = $inlineAstString $ForeachObject = if ("$($this.ForeachObject)" -notmatch "(?>^\s{0,}$|^\s{0,}\{\s{0,}\}\s{0,}$)") { $this.ForeachObject } else { $ForeachObject } $begin = if ($this.Begin) { $this.Begin } else { $begin } $end = if ($this.End) { $this.End } else { $end } $AddForeach = $( if ($ForeachObject) { '|' + [Environment]::NewLine @(foreach ($foreachStatement in $ForeachObject) { if ($foreachStatement.Ast.ProcessBlock -or $foreachStatement.Ast.BeginBlock) { ". {$ForeachStatement}" } elseif ($foreachStatement.Ast.EndBlock.Statements -and $foreachStatement.Ast.EndBlock.Statements[0].PipelineElements -and $foreachStatement.Ast.EndBlock.Statements[0].PipelineElements[0].CommandElements -and $foreachStatement.Ast.EndBlock.Statements[0].PipelineElements[0].CommandElements.Value -in 'Foreach-Object', '%') { "$ForeachStatement" } else { "Foreach-Object {$ForeachStatement}" } }) -join (' |' + [Environment]::NewLine) } ) $statements = @( if ($begin) { "$begin" } if ($AddForeach) { "@($inlineAstString)" + $AddForeach.Trim() } else { $inlineAstString } if ($end) { "$end" } ) $codeToRun = [ScriptBlock]::Create($statements -join [Environment]::Newline) $context = if ($this.Context) { $this.Context } elseif ($FileModuleContext) { $FileModuleContext } . $context { $match = $($args)} $match "$(. $context $codeToRun)" } } #region Template Keyword if ($CommandAst) { # This object will need to be able to evaluate itself. $EvaluateTemplate = { param() $fileText = $this.Template # Collect arguments for our template $ArgumentList = @() $Parameter = [Ordered]@{} foreach ($arg in $args) { if ($arg -is [Collections.IDictionary]) { foreach ($kv in $arg.GetEnumerator()) { $Parameter[$kv.Key] = $kv.Value } } else { $ArgumentList += $arg } } if (-not $ReplaceTimeout) { $ReplaceTimeout = [timespan]"00:00:15" } $ReplacePattern = [Regex]::new($this.Pattern,'IgnoreCase,IgnorePatternwhitespace',$ReplaceTimeout) # Walk thru each match before we replace it foreach ($match in $ReplacePattern.Matches($fileText)) { # get the inline script block $inlineScriptBlock = $this.GetInlineScript($match) if (-not $inlineScriptBlock -or # If there was no block or # there were no parameters, -not $inlineScriptBlock.Ast.ParamBlock.Parameters ) { continue # skip. } # Create a script block out of just the parameter block $paramScriptBlock = [ScriptBlock]::Create( $inlineScriptBlock.Ast.ParamBlock.Extent.ToString() ) # Dot that script into the file's context. # This is some wonderful PowerShell magic. # By doing this, the variables are defined with strong types and values. . $this.Context $paramScriptBlock @Parameter @ArgumentList } $ReplacementEvaluator = if ($this.Evaluator.Script) { $this.Evaluator.Script } elseif ($ReplacementEvaluator) { $ReplacementEvaluator } else { {} } return $ReplacePattern.Replace($fileText, $ReplacementEvaluator) } $templateToString = { param() if ($args) { $this.Evaluate($args) } else { $this.Evaluate() } } $SaveTemplate = { param() if ($this.Name -eq $this.Language) { $name, $evalArgs = $args } else { if ($args -and $args[0] -is [string] -and $args[0] -match '[\\/\.]') { $name, $evalArgs = $args } else { $name = $this.Name $evalArgs = $args } } if (-not $name) { throw "Must provide a .Name or the first argument must be a name" } $evaluated = if ($evalArgs) { $this.Evaluate($evalArgs) } else { $this.Evaluate() } if (-not (Test-Path $Name)) { $null = New-Item -ItemType File -Path $name -Force } $evaluated | Set-Content -Path $name Get-Item -Path $name } $filePattern = foreach ($attr in $foundTemplateTranspiler.ScriptBlock.Attributes) { if ($attr -is [ValidatePattern]) { $attr.RegexPattern break } } $mySentence = $CommandAst.AsSentence($MyInvocation.MyCommand) if ($mySentence.Parameter.Count) { foreach ($clause in $mySentence.Clauses) { if ($clause.ParameterName) { $ExecutionContext.SessionState.PSVariable.Set($clause.ParameterName, $mySentence.Parameter[$clause.Name]) } } } $null, $templateElements = foreach ($sentenceArg in $mySentence.ArgumentList) { if ($sentenceArg.StringConstantType -eq 'Bareword' -and $sentenceArg.Value -in 'template', 'function') { continue } if ($sentenceArg -in 'template', 'function') { continue } $convertedAst = if ($sentenceArg.ConvertFromAst) { $sentenceArg.ConvertFromAst() } else { $sentenceArg } if ($convertedAst -is [string]) { $convertedAst = "'$($convertedAst -replace "'","''")'" } if ($convertedAst -is [ScriptBlock]) { $convertedAst = "{$ConvertedAst}" } $convertedAst } if (-not $templateElements) { $templateElements = "''"} $languageString = $( if ($foundTemplateTranspiler.pstypenames -contains 'Language.Command') { $foundTemplateTranspiler.Name -replace 'Language\p{P}' -replace 'ps1$' } else { $foundTemplateTranspiler.DisplayName -replace '(?>Template|Inline)\.' -replace '^\.' } ) if (-not $TemplateName) { $TemplateName = $languageString } $createdSb = [scriptblock]::Create(@" `$( `$templateObject = [PSCustomObject][Ordered]@{ PSTypeName = 'PipeScript.Template' Name = '$($TemplateName -replace "'", "''")' Language = '$languageString' $(if ($LinePattern) { "LinePattern = '$LinePattern'" }) SourceFile = '' FilePattern = '$($filePattern -replace "'", "''")' Pattern = [regex]::new(@' $replacePattern '@,'IgnoreCase,IgnorePatternWhitespace', '00:00:05') Context = New-Module -ScriptBlock {} ForeachObject = $(if ($ForeachObject) { "{ $foreachObject }"} else {'{}'}) Begin = $(if ($Begin) { "{ $begin }"} else {'{}'}) End = $(if ($End) { "{ $end }"} else {'{}'}) Template = $templateElements } `$templateObject.psobject.members.Add([PSScriptMethod]::new( 'Evaluator', { $replacementEvaluator } ), `$true) `$templateObject.psobject.members.Add([PSScriptMethod]::new( 'Evaluate', { $evaluateTemplate } ), `$true) `$templateObject.psobject.members.Add([PSScriptMethod]::new( 'GetInlineScript', { $GetInlineScript } ), `$true) `$templateObject.psobject.members.Add([PSScriptMethod]::new( 'EvaluateTemplate', { $EvaluateTemplate } ), `$true) `$templateObject.psobject.members.Add([PSScriptMethod]::new( 'Save', { $SaveTemplate } ), `$true) `$templateObject.psobject.members.Add([PSScriptMethod]::new( 'ToString', { $TemplateToString } ), `$true) `$templateObject ) "@ ) if (-not ($mySentence.ArgumentList -contains 'function')) { $createdSb return } } #endregion Template Keyword $FileModuleContext = New-Module @newModuleSplat # There are a couple of paths we could take from here: # We could replace inline, and keep a context for variables # Or we can turn the whole thing into a `[ScriptBlock]` if ($AsScriptBlock) { $index = 0 $fileText = $SourceText if ((-not $fileText) -and $CommandAst) { $firstElement, $templateContent = $CommandAst.CommandElements -notmatch '^(?>template|function)$' $OptimizedTemplateElement = $null $fileText = @(foreach ($contentElement in $templateContent) { if ($contentElement -is [Management.Automation.Language.StringConstantExpressionAst]) { $contentElement.Value } elseif ($contentElement -is [Management.Automation.Language.ExpandableStringExpressionAst]) { $OptimizedTemplateElement = $contentElement } elseif ($contentElement -is [Management.Automation.Language.ScriptBlockExpressionAst]) { $OptimizedTemplateElement = $contentElement } }) -join ' ' } if ($OptimizedTemplateElement) { $templateScriptBlock = @(foreach ($optimizedElement in $OptimizedTemplateElement) { if ($OptimizedTemplateElement -is [Management.Automation.Language.ExpandableStringExpressionAst]) { New-PipeScript -AutoParameter -Process ([scriptblock]::Create($OptimizedTemplateElements)) } elseif ($optimizedElement -is [Management.Automation.Language.ScriptBlockExpressionAst]) { $optimizedElement.AsScriptBlock() } }) | Join-PipeScript $templatePreCompiledString = @("template function $TemplateName {", $templateScriptBlock,"}") -join [Environment]::newLine [ScriptBlock]::Create("$templatePreCompiledString") | Use-PipeScript return } if (-not $fileText) { return } $hasParameters = $false $allInlineScripts = @() $newContent = @( foreach ($match in $ReplacePattern.Matches($fileText)) { if ($match.Index -gt $index) { "@'" + [Environment]::NewLine + ( $fileText.Substring($index, $match.Index - $index) -replace "'@", "''@" -replace "@'", "@''" ) + [Environment]::NewLine + "'@" + { -replace "''@", "'@" -replace "@''", "'@"} + [Environment]::NewLine } $inlineScriptBlock = & $GetInlineScript $match if (-not $inlineScriptBlock) { continue # skip. } $allInlineScripts += $inlineScriptBlock $inlineScriptBlock = if ($inlineScriptBlock.Ast.ParamBlock) { $hasParameters = $true "$inlineScriptBlock".Substring($inlineScriptBlock.Ast.ParamBlock.Extent.ToString().Length) } else { "$inlineScriptBlock" } if ($Begin) { "$Begin" } if ($ForeachObject) { "@($inlineScriptBlock)" + $( if ($ForeachObject) { '|' + [Environment]::NewLine @(foreach ($foreachStatement in $ForeachObject) { if ($foreachStatement.Ast.ProcessBlock -or $foreachStatement.Ast.BeginBlock) { ". {$ForeachStatement}" } elseif ($foreachStatement.Ast.EndBlock.Statements -and $foreachStatement.Ast.EndBlock.Statements[0].PipelineElements -and $foreachStatement.Ast.EndBlock.Statements[0].PipelineElements[0].CommandElements -and $foreachStatement.Ast.EndBlock.Statements[0].PipelineElements[0].CommandElements.Value -in 'Foreach-Object', '%') { "$ForeachStatement" } else { "Foreach-Object {$ForeachStatement}" } }) -join (' |' + [Environment]::NewLine) } ) } else { $inlineScriptBlock } if ($end) { "$end" } $index = $match.Index + $match.Length } if ($index -lt $fileText.Length) { "@'" + [Environment]::NewLine + ( $fileText.Substring($index) -replace "'@", "''@" ) + [Environment]::NewLine + "'@" + { -replace "''@", "'@" -replace "@''", "'@"} + [Environment]::NewLine } ) $templateScriptBlock = if ($hasParameters) { $combinedParamBlock = $allInlineScripts | Join-ScriptBlock -IncludeBlockType param, header, help $combinedParamBlock, ([ScriptBlock]::Create($newContent -join [Environment]::NewLine)) | Join-PipeScript } else { ([ScriptBlock]::Create($newContent -join [Environment]::NewLine)) } if ($templateScriptBlock -and $barewords -contains "function") { $templatePreCompiledString = @("template function $TemplateName {", $templateScriptBlock,"}") -join [Environment]::newLine [ScriptBlock]::Create("$templatePreCompiledString") | Use-PipeScript return } $null = $null } # If the parameter set was SourceTextReplace if ($ReplacePattern) { $fileText = $SourceText # Walk thru each match before we replace it foreach ($match in $ReplacePattern.Matches($fileText)) { # get the inline script block $inlineScriptBlock = & $GetInlineScript $match if (-not $inlineScriptBlock -or # If there was no block or # there were no parameters, -not $inlineScriptBlock.Ast.ParamBlock.Parameters ) { continue # skip. } # Create a script block out of just the parameter block $paramScriptBlock = [ScriptBlock]::Create( $inlineScriptBlock.Ast.ParamBlock.Extent.ToString() ) # Dot that script into the file's context. # This is some wonderful PowerShell magic. # By doing this, the variables are defined with strong types and values. . $FileModuleContext $paramScriptBlock @Parameter @ArgumentList } # Now, we run the replacer. # This should run each inline script and replace the text. $replacement = try { $ReplacePattern.Replace($fileText, $ReplacementEvaluator) } catch { $ex = $_ Write-Error -ErrorRecord $ex # $PSCmdlet.WriteError($ex) } return $replacement } } |