src/scriptobject/dsl/ClassDsl.ps1
# Copyright 2019, Adam Edwards # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. class ClassDefinitionContext { ClassDefinitionContext([ClassDefinition] $classDefinition, $module, $staticModule) { $this.classDefinition = $classDefinition $this.module = $module $this.staticModule = $staticModule } [ClassDefinition] $classDefinition [PSModuleInfo] $module [PSModuleInfo] $staticModule } # This implements the language used to specify the definition of a class. This implementation # is embedded within the PowerShell language, particularly as it can be executed within a # PowerShell script block, a form of anonymous function class ClassDsl { ClassDsl([bool] $staticScope, [HashTable] $injectedMethodBlocks, [string] $constructorMethodName) { $this.staticScope = $staticScope $this.constructorMethodName = $constructorMethodName $this.excludedFunctions = $this.languageElements.keys if ( $injectedMethodBlocks ) { foreach ( $methodName in $injectedMethodBlocks.keys ) { $method = [Method]::new($methodName, $injectedMethodBlocks[$methodName], $false, $true) $this.injectedMethods += $method } } } [ClassDefinitionContext] NewClassDefinitionContext([string] $className, [ScriptBlock] $classBlock, [object[]] $classArguments, [HashTable] $variablesToInclude) { $this.InitializeProcessingState($classBlock) $injectedVariables = @() if ( $variablesToInclude ) { $variablesToInclude.GetEnumerator() | foreach { $injectedVariables += [PSVariable]::new($_.name, $_.value) } } # Add the class collection variable -- without this, modules that nest this module # will not see this variable in static methods... $collectionVariableName = [ScriptClassSpecification]::Parameters.Language.ClassCollectionName $injectedVariables += [PSVariable]::new($collectionVariableName, (get-variable $collectionVariableName -value)) $classObject = new-module -AsCustomObject $this::inspectionBlock -argumentlist $this, $classBlock, $classArguments, $this.injectedMethods, $injectedVariables if ( $this.definitionProcessingState.exception ) { throw $this.definitionProcessingState.exception } if ( ! $classObject ) { throw "Internal exception defining class" } $staticContext = $this.ProcessStaticBlocks() $instanceMethodList = $this.GetMethods($classObject, $false) $staticMethodList = $this.GetMethods($classObject, $true) $instancePropertyList = $this.GetProperties($classObject, $false) $staticPropertyList = $this.GetProperties($classObject, $true) $classDefinition = [ClassDefinition]::new($className, $instanceMethodList, $staticMethodList, $instancePropertyList, $staticPropertyList, $this.constructorMethodName) $staticModule = if ( $staticContext ) { $staticContext.module } return [ClassDefinitionContext]::new($classDefinition, $this.definitionProcessingState.executingInspectionModule, $staticModule) } hidden [Method[]] GetMethods([PSCustomObject] $classObject, $staticScope) { $injectedMethodNames = $this.injectedMethods.name $methods = if ( $staticScope ) { $this.definitionProcessingState.staticMethods.values | foreach { [Method]::new($_.name, $_.block, $true, $false) } } else { $classObject.psobject.methods | where membertype -eq scriptmethod | where name -notin $this.excludedFunctions | foreach { if ( $_.name -notin $injectedMethodNames ) { [Method]::new($_.name, $_.script, $false, $false) } } } return $methods } hidden [Property[]] GetProperties([PSCustomObject] $classObject, $staticScope) { $properties = if ( $staticScope ) { $this.definitionProcessingState.staticProperties.values | foreach { $normalizedValue = if ( $_.type ) { [TypedValue]::new($_.type, $_.value) } else { $_.value } [Property]::new($_.name, $normalizedValue, $true, $false, $_.isReadOnly) } } else { $classObject.psobject.properties | where membertype -eq noteproperty | where name -notin $this.definitionProcessingState.excludedVariables | foreach { [Property]::new($_.name, $_.value, $false, $false, ! $_.IsSettable) } } return $properties } hidden [ClassDefinitionContext] ProcessStaticBlocks() { if ( $this.staticScope ) { return $null } $blocks = @({}) $blocks += $this.definitionProcessingState.staticBlocks $methodTable = @{} # This is required to ensure these methods are available # for invocation by other static methods in the class $this.injectedMethods | foreach { $methodTable.Add($_.name, $_.block) } # Combine all the static blocks into one block to avoid # having multiple modules (each block is a module) -- this # results in exactly one module for all static methods and properties $combinedBlock = { param([ScriptBlock[]] $staticBlocks) $staticBlocks | foreach { . {}.module.newboundscriptblock($_) } } $dsl = [ClassDsl]::new($true, $methodTable, $null) $staticDefinitionContext = $dsl.NewClassDefinitionContext($null, $combinedBlock, (,$blocks), $this.definitionProcessingState.classBlockParameters) $staticDefinitionContext.classDefinition.GetInstanceMethods() | foreach { $this.definitionProcessingState.staticMethods.Add($_.name, $_) } $staticDefinitionContext.classDefinition.GetInstanceProperties() |foreach { $this.definitionProcessingState.staticProperties.Add($_.name, $_) } return $staticDefinitionContext } hidden [object[]] GetClassBlockParameters([ScriptBlock] $classBlock) { $parameterNames = @() $blockParameters = if ( $classBlock.ast.paramblock ) { $classBlock.ast.paramblock.parameters } if ( $blockParameters ) { $variableNames = $blockParameters.name | foreach { $parameterNames += $_.variablepath.userpath } } return $parameterNames } hidden [void] InitializeProcessingState($classBlock) { $this.definitionProcessingState = @{ staticProcessed = $false staticBlocks = @() staticMethods = @{} staticProperties = @{} classBlockParameters = $null classBlockParameterNames = $this.GetClassBlockParameters($classBlock) excludedVariables = @() exception = $null executingInspectionModule = $null } $this.definitionProcessingState.excludedVariables += $this.definitionProcessingState.classBlockParameterNames } [bool] $staticScope = $false $constructorMethodName = $null $excludedFunctions = $null $injectedMethods = @() $definitionProcessingState = $null $languageElements = @{ [ScriptClassSpecification]::Parameters.Language.StrictTypeKeyword = @{ Alias = $null Script = { param( [parameter(mandatory=$true)] $Type, $Value = $null ) if (! $type -is [string] -and ! $type -is [Type]) { throw "The 'type' argument of type '$($type.gettype())' specified for strict-val must be of type [String] or [Type]" } $propType = if ( $type -is [Type] ) { $type } elseif ( $type.startswith('[') -and $type.endswith(']')) { iex $type } else { throw "Specified type '$propTypeName' was not of the form '[typename]'" } [TypedValue]::new($propType, $value) } } [ScriptClassSpecification]::Parameters.Language.StaticKeyword = @{ Alias = $null Script = { param($StaticBlock) if ( $this.staticScope ) { throw 'Invalid static syntax' } if ( ! $this.definitionProcessingState.staticProcessed ) { $classBlockParameters = @{} $this.definitionProcessingState.classBlockParameterNames | foreach { $name = $_ $value = ((get-pscallstack)[2].getframevariables()['psboundparameters']).value[$name][0] $classBlockParameters.Add($name, $value) } $this.definitionProcessingState.classBlockParameters = $classBlockParameters $methodTable = @{} $this.definitionProcessingState.staticProcessed = $true } $this.definitionProcessingState.staticBlocks += $staticBlock } } [ScriptClassSpecification]::Parameters.Language.ConstantKeyword = @{ Alias = [ScriptClassSpecification]::Parameters.Language.ConstantAlias Script = { param( [parameter(mandatory=$true)] $name, [parameter(mandatory=$true)] $value ) $existingVariable = . $this.definitionProcessingState.executingInspectionModule.NewBoundScriptBlock({param($___variableName) get-variable -name $___variableName -scope local -erroraction ignore}) $name $value if ( $existingVariable -eq $null ) { . $this.definitionProcessingState.executingInspectionModule.NewBoundScriptBlock({param($___variableName, $___variableValue) new-variable -name $___variableName -scope local -value $___variableValue -option readonly; remove-variable ___variableName, ___variableValue}) $name $value } elseif ($existingVariable.value -ne $value) { throw "Attempt to redefine constant '$name' from value '$($existingVariable.value) to '$value'" } } } } static $inspectionBlock = { param($___dsl, $___classBlock, $___classArguments, $___injectedMethods, [object[]] $___importedVariables) set-strictmode -version 2 $___dsl.definitionProcessingState.executingInspectionModule = {}.Module if ( $___importedVariables ) { $___importedVariables | foreach { new-variable -name $_.name -value $_.value $___dsl.definitionProcessingState.excludedVariables += $_.name } } foreach ( $___elementName in $___dsl.languageElements.keys ) { new-item function:/$___elementName -value $___dsl.languageElements[$___elementName].script | out-null if ( $___dsl.languageElements[$___elementName].Alias ) { set-alias $___dsl.languageElements[$___elementName].Alias $___elementName } } foreach ( $___method in $___injectedMethods ) { new-item "function:/$($___method.name)" -value {}.Module.NewBoundScriptBlock($___method.block) | out-null } $___variables = @() get-variable | where { $_.name.StartsWith('___') } | foreach { $___variables += $_ } try { . {}.module.newboundscriptblock($___classBlock) @___classArguments | out-null } catch { $___dsl.definitionProcessingState.exception = $_.exception throw } $___dsl.definitionProcessingState.excludedVariables += @('this', 'foreach') # TODO: Remove this as it is currently redundant or make parameterized $___dsl.definitionProcessingState.excludedFunctions = $___dsl.languageElements.keys $___variables | foreach { $_ | remove-variable } export-modulemember -function * -variable * } } |