Indented.Build.psm1
function GetBuildSystem { [OutputType([String])] param ( ) if ($env:APPVEYOR -eq $true) { return 'AppVeyor' } if ($env:JENKINS_URL) { return 'Jenkins' } return 'Desktop' } function Add-PesterTemplate { <# .SYNOPSIS Add a pester template file for each function or class in the module. .DESCRIPTION Add a pester template file for each function or class in the module. Adds new files only. #> [CmdletBinding()] param ( # BuildInfo is used to determine the source path. [Parameter(ValueFromPipeline)] [PSTypeName('Indented.BuildInfo')] [PSObject]$BuildInfo = (Get-BuildInfo) ) begin { $header = @( '#region:TestFileHeader' 'param (' ' [Boolean]$UseExisting' ')' '' 'if (-not $UseExisting) {' ' $moduleBase = $psscriptroot.Substring(0, $psscriptroot.IndexOf("\test"))' ' $stubBase = Resolve-Path (Join-Path $moduleBase "test*\stub\*")' ' if ($null -ne $stubBase) {' ' $stubBase | Import-Module -Force' ' }' '' ' Import-Module $moduleBase -Force' '}' '#endregion' ) -join ([Environment]::NewLine) } process { $testPath = Join-Path $buildInfo.Path.Source.Module 'test*' if (Test-Path $testPath) { $testPath = Resolve-Path $testPath } else { $testPath = (New-Item (Join-Path $buildInfo.Path.Source.Module 'test') -ItemType Directory).FullName } foreach ($file in $buildInfo | Get-BuildItem -Type ShouldMerge) { $relativePath = $file.FullName -replace ([Regex]::Escape($buildInfo.Path.Source.Module)) -replace '^\\' -replace '\.ps1$' $fileTestPath = Join-Path $testPath ('{0}.tests.ps1' -f $relativePath) $script = [System.Text.StringBuilder]::new() if (-not (Test-Path $fileTestPath)) { $null = $script.AppendLine($header). AppendLine(). AppendFormat('InModuleScope {0} {{', $buildInfo.ModuleName).AppendLine() foreach ($function in $file | Get-FunctionInfo) { $null = $script.AppendFormat(' Describe {0} -Tag CI {{', $function.Name).AppendLine(). AppendLine(' }'). AppendLine() } $null = $script.AppendLine('}') $parent = Split-Path $fileTestPath -Parent if (-not (Test-Path $parent)) { $null = New-Item $parent -ItemType Directory -Force } Set-Content -Path $fileTestPath -Value $script.ToString().Trim() } } } } function BuildTask { <# .SYNOPSIS Create a build task object. .DESCRIPTION A build task is a predefined task used to build well-structured PowerShell projects. #> [CmdletBinding()] [OutputType('Indented.BuildTask')] param ( # The name of the task. [Parameter(Mandatory)] [String]$Name, # The stage during which the task will be invoked. [Parameter(Mandatory)] [String]$Stage, # Where the task should appear in the build order respective to the stage. [Int32]$Order = 1024, # The task will only be invoked if the filter condition is true. [ScriptBlock]$If = { $true }, # The task implementation. [Parameter(Mandatory)] [ScriptBlock]$Definition ) [PSCustomObject]@{ Name = $Name Stage = $Stage If = $If Order = $Order Definition = $Definition PSTypeName = 'Indented.BuildTask' } } function Convert-CodeCoverage { <# .SYNOPSIS Converts code coverage line and file reference from root module to file. .DESCRIPTION When tests are executed against a merged module, all lines are relative to the psm1 file. This command updates line references to match the development file set. #> [CmdletBinding()] param ( # The original code coverage report. [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)] [PSObject]$CodeCoverage, # The output from Get-BuildInfo for this project. [Parameter(Mandatory)] [PSTypeName('Indented.BuildInfo')] [PSObject]$BuildInfo, # Write missed commands using format table as they are discovered. [Switch]$Tee ) begin { $buildFunctions = $BuildInfo.Path.Build.RootModule | Get-FunctionInfo | Group-Object Name -AsHashTable $sourceFunctions = $BuildInfo | Get-BuildItem -Type ShouldMerge | Get-FunctionInfo | Group-Object Name -AsHashTable $buildClasses = $BuildInfo.Path.Build.RootModule | Get-ClassInfo | Group-Object Name -AsHashTable $sourceClasses = $BuildInfo | Get-BuildItem -Type ShouldMerge | Get-ClassInfo | Group-Object Name -AsHashTable } process { foreach ($category in 'MissedCommands', 'HitCommands') { foreach ($command in $CodeCoverage.$category) { if ($command.Class) { if ($buildClasses.ContainsKey($command.Class)) { $buildExtent = $buildClasses[$command.Class].Extent $sourceExtent = $sourceClasses[$command.Class].Extent } } else { if ($buildFunctions.Contains($command.Function)) { $buildExtent = $buildFunctions[$command.Function].Extent $sourceExtent = $sourceFunctions[$command.Function].Extent } } if ($buildExtent -and $sourceExtent) { $command.File = $sourceExtent.File $command.StartLine = $command.Line = $command.StartLine - $buildExtent.StartLineNumber + $sourceExtent.StartLineNumber $command.EndLine = $command.EndLine - $buildExtent.StartLineNumber + $sourceExtent.StartLineNumber } } if ($Tee -and $category -eq 'MissedCommands') { $CodeCoverage.$category | Format-Table @( @{ Name = 'File'; Expression = { if ($_.File -eq $buildInfo.Path.Build.RootModule) { $buildInfo.Path.Build.RootModule.Name } else { ($_.File -replace ([Regex]::Escape($buildInfo.Path.Source.Module))).TrimStart('\') } }} @{ Name = 'Name'; Expression = { if ($_.Class) { '{0}\{1}' -f $_.Class, $_.Function } else { $_.Function } }} 'Line' 'Command' ) } } } } function ConvertTo-ChocoPackage { <# .SYNOPSIS Convert a PowerShell module into a chocolatey package. .DESCRIPTION Convert a PowerShell module into a chocolatey package. .EXAMPLE Find-Module pester | ConvertTo-ChocoPackage Find the module pester on a PS repository and convert the module to a chocolatey package. .EXAMPLE Get-Module SqlServer -ListAvailable | ConvertTo-ChocoPackage Get the installed module SqlServer and convert the module to a chocolatey package. .EXAMPLE Find-Module VMware.PowerCli | ConvertTo-ChocoPackage Find the module VMware.PowerCli on a PS repository and convert the module, and all dependencies, to chocolatey packages. #> [CmdletBinding()] param ( # The module to package. [Parameter(Mandatory, ValueFromPipeline)] [ValidateScript( { if ($_ -is [System.Management.Automation.PSModuleInfo] -or $_ -is [Microsoft.PackageManagement.Packaging.SoftwareIdentity] -or $_.PSTypeNames[0] -eq 'Microsoft.PowerShell.Commands.PSRepositoryItemInfo') { $true } else { throw 'InputObject must be a PSModuleInfo, SoftwareIdentity, or PSRepositoryItemInfo object.' } } )] [Object]$InputObject, # Write the generated nupkg file to the specified folder. [String]$Path = '.', # A temporary directory used to stage the choco package content before packing. [String]$CacheDirectory = (Join-Path $env:TEMP (New-Guid)) ) begin { $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path) try { $null = New-Item $CacheDirectory -ItemType Directory } catch { $pscmdlet.ThrowTerminatingError($_) } } process { try { $erroractionpreference = 'Stop' $packagePath = Join-Path $CacheDirectory $InputObject.Name.ToLower() $toolsPath = New-Item (Join-Path $packagePath 'tools') -ItemType Directory switch ($InputObject) { { $_ -is [System.Management.Automation.PSModuleInfo] } { Write-Verbose ('Building {0} from PSModuleInfo' -f $InputObject.Name) $dependencies = $InputObject.RequiredModules $null = $psboundparameters.Remove('InputObject') # Package dependencies as well foreach ($dependency in $dependencies) { Get-Module $dependency.Name -ListAvailable | Where-Object Version -eq $dependency.Version | ConvertTo-ChocoPackage @psboundparameters } if ((Split-Path $InputObject.ModuleBase -Leaf) -eq $InputObject.Version) { $destination = New-Item (Join-Path $toolsPath $InputObject.Name) -ItemType Directory } else { $destination = $toolsPath } Copy-Item $InputObject.ModuleBase -Destination $destination -Recurse break } { $_ -is [Microsoft.PackageManagement.Packaging.SoftwareIdentity] } { Write-Verbose ('Building {0} from SoftwareIdentity' -f $InputObject.Name) $dependencies = $InputObject.Dependencies | Select-Object @{n='Name';e={ $_ -replace 'powershellget:|/.+$' }}, @{n='Version';e={ $_ -replace '^.+?/|#.+$' }} [Xml]$swidTagText = $InputObject.SwidTagText $InputObject = [PSCustomObject]@{ Name = $InputObject.Name Version = $InputObject.Version Author = $InputObject.Entities.Where{ $_.Role -eq 'author' }.Name Copyright = $swidTagText.SoftwareIdentity.Meta.copyright Description = $swidTagText.SoftwareIdentity.Meta.summary } if ((Split-Path $swidTagText.SoftwareIdentity.Meta.InstalledLocation -Leaf) -eq $InputObject.Version) { $destination = New-Item (Join-Path $toolsPath $InputObject.Name) -ItemType Directory } else { $destination = $toolsPath } Copy-Item $swidTagText.SoftwareIdentity.Meta.InstalledLocation -Destination $destination -Recurse break } { $_.PSTypeNames[0] -eq 'Microsoft.PowerShell.Commands.PSRepositoryItemInfo' } { Write-Verbose ('Building {0} from PSRepositoryItemInfo' -f $InputObject.Name) $dependencies = $InputObject.Dependencies | Select-Object @{n='Name';e={ $_['Name'] }}, @{n='Version';e={ $_['MinimumVersion'] }} $null = $psboundparameters.Remove('InputObject') $params = @{ Name = $InputObject.Name RequiredVersion = $InputObject.Version Source = $InputObject.Repository ProviderName = 'PowerShellGet' Path = New-Item (Join-Path $CacheDirectory 'savedPackages') -ItemType Directory -Force } Save-Package @params | ConvertTo-ChocoPackage @psboundparameters # The current module will be last in the chain. Prevent packaging of this iteration. $InputObject = $null break } } if ($InputObject) { # Inject chocolateyInstall.ps1 $install = @( 'Get-ChildItem $psscriptroot -Directory |' ' Copy-Item -Destination "C:\Program Files\WindowsPowerShell\Modules" -Recurse -Force' ) | Out-String Set-Content (Join-Path $toolsPath 'chocolateyInstall.ps1') -Value $install # Inject chocolateyUninstall.ps1 $uninstall = @( 'Get-Module {0} -ListAvailable |' ' Where-Object {{ $_.Version -eq "{1}" -and $_.ModuleBase -match "Program Files\\WindowsPowerShell\\Modules" }} |' ' Select-Object -ExpandProperty ModuleBase |' ' Remove-Item -Recurse -Force' ) | Out-String $uninstall = $uninstall -f $InputObject.Name, $InputObject.Version Set-Content (Join-Path $toolsPath 'chocolateyUninstall.ps1') -Value $uninstall # Inject nuspec $nuspecPath = Join-Path $packagePath ('{0}.nuspec' -f $InputObject.Name) $nuspec = @( '<?xml version="1.0" encoding="utf-8"?>' '<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">' ' <metadata>' ' <version>{0}</version>' ' <title>{1}</title>' ' <authors>{2}</authors>' ' <copyright>{3}</copyright>' ' <id>{1}</id>' ' <summary>{1} PowerShell module</summary>' ' <description>{4}</description>' ' </metadata>' '</package>' ) | Out-String $nuspec = [Xml]($nuspec -f @( $InputObject.Version, $InputObject.Name, $InputObject.Author, $InputObject.Copyright, $InputObject.Description )) if ($dependencies) { $fragment = [System.Text.StringBuilder]::new('<dependencies>') $null = foreach ($dependency in $dependencies) { $fragment.AppendFormat('<dependency id="{0}"', $dependency.Name) if ($dependency.Version) { $fragment.AppendFormat(' version="{0}"', $dependency.Version) } $fragment.Append(' />').AppendLine() } $null = $fragment.AppendLine('</dependencies>') $xmlFragment = $nuspec.CreateDocumentFragment() $xmlFragment.InnerXml = $fragment.ToString() $null = $nuspec.package.metadata.AppendChild($xmlFragment) } $nuspec.Save($nuspecPath) choco pack $nuspecPath --out=$Path } } catch { Write-Error -ErrorRecord $_ } finally { Remove-Item $packagePath -Recurse -Force } } end { Remove-Item $CacheDirectory -Recurse -Force } } function Enable-Metadata { <# .SYNOPSIS Enable a metadata property which has been commented out. .DESCRIPTION This function is derived Get and Update-Metadata from PoshCode\Configuration. A boolean value is returned indicating if the property is available in the metadata file. If the property does not exist, or exists more than once within the specified file this command will return false. .INPUTS System.String .EXAMPLE Enable-Metadata .\module.psd1 -PropertyName RequiredAssemblies Enable an existing (commented) RequiredAssemblies property within the module.psd1 file. #> [CmdletBinding()] [OutputType([Boolean])] param ( # A valid metadata file or string containing the metadata. [Parameter(ValueFromPipelineByPropertyName, Position = 0)] [ValidateScript( { Test-Path $_ -PathType Leaf } )] [Alias("PSPath")] [String]$Path, # The property to enable. [String]$PropertyName ) process { # If the element can be found using Get-Metadata leave it alone and return true $shouldEnable = $false try { $null = Get-Metadata @psboundparameters -ErrorAction Stop } catch [System.Management.Automation.ItemNotFoundException] { # The function will only execute where the requested value is not present $shouldEnable = $true } catch { # Ignore other errors which may be raised by Get-Metadata except path not found. if ($_.Exception.Message -eq 'Path must point to a .psd1 file') { $pscmdlet.ThrowTerminatingError($_) } } if (-not $shouldEnable) { return $true } $manifestContent = Get-Content $Path -Raw $tokens = $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput( $manifestContent, $Path, [Ref]$tokens, [Ref]$parseErrors ) # Attempt to find a comment which matches the requested property $regex = '^ *# *({0}) *=' -f $PropertyName $existingValue = @($tokens | Where-Object { $_.Kind -eq 'Comment' -and $_.Text -match $regex }) if ($existingValue.Count -eq 1) { $manifestContent = $ast.Extent.Text.Remove( $existingValue.Extent.StartOffset, $existingValue.Extent.EndOffset - $existingValue.Extent.StartOffset ).Insert( $existingValue.Extent.StartOffset, $existingValue.Extent.Text -replace '^# *' ) try { Set-Content -Path $Path -Value $manifestContent -NoNewline -ErrorAction Stop $true } catch { $false } } elseif ($existingValue.Count -eq 0) { # Item not found Write-Warning "Cannot find disabled property '$PropertyName' in $Path" $false } else { # Ambiguous match Write-Warning "Found more than one '$PropertyName' in $Path" $false } } } function Export-BuildScript { <# .SYNOPSIS Export a build script for use with Invoke-Build. .DESCRIPTION Export a build script for use with Invoke-Build. .INPUTS BuildInfo (from Get-BuildInfo) #> [CmdletBinding()] [OutputType([String])] param ( # The build information object is used to determine which tasks are applicable. [Parameter(ValueFromPipeline)] [PSTypeName('Indented.BuildInfo')] [PSObject]$BuildInfo = (Get-BuildInfo), # By default the build system is automatically discovered. The BuildSystem parameter overrides any automatically discovered value. Tasks associated with the build system are added to the generated script. [String]$BuildSystem, # The build script will be written to the the specified path. [String]$Path = '.build.ps1' ) if ($BuildSystem) { $BuildInfo.BuildSystem = $BuildSystem } $script = [System.Text.StringBuilder]::new() $null = $script.AppendLine('param ('). AppendLine(' [String]$ModuleName,'). AppendLine(). AppendLine(' [PSTypeName("Indented.BuildInfo")]'). AppendLine(' [ValidateCount(1, 1)]'). AppendLine(' [PSObject[]]$BuildInfo'). AppendLine(')'). AppendLine(). AppendLine('Set-Alias MSBuild (Resolve-MSBuild) -ErrorAction SilentlyContinue'). AppendLine() $tasks = $BuildInfo | Get-BuildTask | Sort-Object { switch ($_.Stage) { 'Setup' { 1; break } 'Build' { 2; break } 'Test' { 3; break } 'Pack' { 4; break } 'Publish' { 5; break } } }, Order, Name # Build the wrapper tasks and insert the block at the top of the script $taskSets = [System.Text.StringBuilder]::new() $taskStages = ($tasks | Group-Object Stage -NoElement).Name # Add a default task set $null = $taskSets.AppendFormat('task default {0},', $taskStages[0]).AppendLine() for ($i = 1; $i -lt $taskStages.Count; $i++) { $null = $taskSets.AppendFormat( ' {0}{1}', $taskStages[$i], @(',', '')[$i -eq $taskStages.Count - 1] ).AppendLine() } $null = $taskSets.AppendLine() $tasks | Group-Object Stage | ForEach-Object { $indentLength = 'task '.Length + $_.Name.Length $null = $taskSets.AppendFormat('task {0} {1}', $_.Name, $_.Group[0].Name) foreach ($task in $_.Group | Select-Object -Skip 1) { $null = $taskSets.Append(','). AppendLine(). AppendFormat('{0} {1}', (' ' * $indentLength), $task.Name) } $null = $taskSets.AppendLine(). AppendLine() } $null = $script.Append($taskSets.ToString()) # Add supporting functions to create the BuildInfo object. (Get-Command Get-BuildInfo).ScriptBlock.Ast.FindAll( { param ( $ast ) $ast -is [System.Management.Automation.Language.CommandAst] }, $true ) | ForEach-Object GetCommandName | Select-Object -Unique | Sort-Object | ForEach-Object { $commandInfo = Get-Command $_ if ($commandInfo.Source -eq $myinvocation.MyCommand.ModuleName) { $null = $script.AppendFormat('function {0} {{', $commandInfo.Name). Append($commandInfo.Definition). AppendLine('}'). AppendLine() } } @( 'Convert-CodeCoverage' 'ConvertTo-ChocoPackage' 'Enable-Metadata' 'Get-Ast' 'Get-BuildInfo' 'Get-BuildItem' 'Get-FunctionInfo' 'Get-LevenshteinDistance' 'Get-MethodInfo' ) | ForEach-Object { $null = $script.AppendFormat('function {0} {{', $_). Append((Get-Command $_).Definition). AppendLine('}'). AppendLine() } # Add a generic task which allows BuildInfo to be retrieved $null = $script.AppendLine('task GetBuildInfo {'). AppendLine(' Get-BuildInfo'). AppendLine('}'). AppendLine() # Add a task that allows all all build jobs within the current project to run $null = $script.AppendLine('task BuildAll {'). AppendLine(' [String[]]$task = ${*}.Task.Name'). AppendLine(). AppendLine(' # Re-submit the build request without the BuildAll task'). AppendLine(' if ($task.Count -eq 1 -and $task[0] -eq "BuildAll") {'). AppendLine(' $task = "default"'). AppendLine(' } else {'). AppendLine(' $task = $task -ne "BuildAll"'). AppendLine(' }'). AppendLine(). AppendLine(' Get-BuildInfo | ForEach-Object {'). AppendLine(' Write-Host'). AppendLine(' "Building {0} ({1})" -f $_.ModuleName, $_.Version | Write-Host -ForegroundColor Green'). AppendLine(' Write-Host'). AppendLine(' Invoke-Build -BuildInfo $_ -Task $task'). AppendLine(' }'). AppendLine('}'). AppendLine() $tasks | ForEach-Object { $null = $script.AppendFormat('task {0}', $_.Name) if ($_.If -and $_.If.ToString().Trim() -ne '$true') { $null = $script.AppendLine(' -If ('). AppendLine($_.If.ToString().Trim()). Append(')') } $null = $script.AppendLine(' {'). AppendLine($_.Definition.ToString().Trim("`r`n")). AppendLine('}'). AppendLine() } $script.ToString() | Set-Content $Path } function Get-Ast { <# .SYNOPSIS Get the abstract syntax tree for either a file or a scriptblock. .DESCRIPTION Get the abstract syntax tree for either a file or a scriptblock. #> [CmdletBinding(DefaultParameterSetName = 'FromPath')] [OutputType([System.Management.Automation.Language.ScriptBlockAst])] param ( # The path to a file containing one or more functions. [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPath')] [Alias('FullName')] [String]$Path, # A script block containing one or more functions. [Parameter(ParameterSetName = 'FromScriptBlock')] [ScriptBlock]$ScriptBlock, [Parameter(DontShow, ValueFromRemainingArguments)] $Discard ) process { if ($pscmdlet.ParameterSetName -eq 'FromPath') { $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path) try { $tokens = $errors = @() $ast = [System.Management.Automation.Language.Parser]::ParseFile( $Path, [Ref]$tokens, [Ref]$errors ) if ($errors[0].ErrorId -eq 'FileReadError') { throw [InvalidOperationException]::new($errors[0].Message) } } catch { $errorRecord = @{ Exception = $_.Exception.GetBaseException() ErrorId = 'AstParserFailed' Category = 'OperationStopped' } Write-Error @ErrorRecord } } else { $ast = $ScriptBlock.Ast } $ast } } function Get-BuildInfo { <# .SYNOPSIS Get properties required to build the project. .DESCRIPTION Get the properties required to build the project, or elements of the project. .EXAMPLE Get-BuildInfo Get build information for the current or any child directories. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] [CmdletBinding()] [OutputType('Indented.BuildInfo')] param ( [String]$ModuleName = '*', # Generate build information for the specified path. [ValidateScript( { Test-Path $_ -PathType Container } )] [String]$ProjectRoot = $pwd.Path ) $ProjectRoot = $pscmdlet.GetUnresolvedProviderPathFromPSPath($ProjectRoot) Get-ChildItem $ProjectRoot\*\*.psd1 | Where-Object { ($_.Directory.Name -match 'source|src' -or $_.Directory.Name -eq $_.BaseName) -and ($moduleManifest = Test-ModuleManifest $_.FullName -ErrorAction SilentlyContinue) } | ForEach-Object { $configOverridePath = Join-Path $_.Directory.FullName 'buildConfig.psd1' if (Test-Path $configOverridePath) { $config = Import-PowerShellDataFile $configOverridePath } else { $config = @{} } try { [PSCustomObject]@{ ModuleName = $moduleName = $_.BaseName Version = $version = $moduleManifest.Version Config = [PSCustomObject]@{ CodeCoverageThreshold = (0.8, $config.CodeCoverageThreshold)[$null -ne $config.CodeCoverageThreshold] EndOfLineChar = ([Environment]::NewLine, $config.EndOfLineChar)[$null -ne $config.EndOfLineChar] License = ('MIT', $config.License)[$null -ne $config.License] CreateChocoPackage = ($false, $config.CreateChocoPackage)[$null -ne $config.CreateChocoPackage] } Path = [PSCustomObject]@{ ProjectRoot = $ProjectRoot Source = [PSCustomObject]@{ Module = $_.Directory Manifest = $_ } Build = [PSCustomObject]@{ Module = $module = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ProjectRoot, 'build', $moduleName, $version) Manifest = [System.IO.FileInfo](Join-Path $module ('{0}.psd1' -f $moduleName)) RootModule = [System.IO.FileInfo](Join-Path $module ('{0}.psm1' -f $moduleName)) Output = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ProjectRoot, 'build\output', $moduleName) Package = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ProjectRoot, 'build\packages') } } BuildSystem = GetBuildSystem PSTypeName = 'Indented.BuildInfo' } } catch { Write-Error -ErrorRecord $_ } } | Where-Object ModuleName -like $ModuleName } function Get-BuildItem { <# .SYNOPSIS Get source items. .DESCRIPTION Get items from the source tree which will be consumed by the build process. This function centralises the logic required to enumerate files and folders within a project. #> [CmdletBinding()] [OutputType([System.IO.FileInfo], [System.IO.DirectoryInfo])] param ( # Gets items by type. # # ShouldMerge - *.ps1 files from enum*, class*, priv*, pub* and InitializeModule if present. # Static - Files which are not within a well known top-level folder. Captures help content in en-US, format files, configuration files, etc. [Parameter(Mandatory)] [ValidateSet('ShouldMerge', 'Static')] [String]$Type, # BuildInfo is used to determine the source path. [Parameter(Mandatory, ValueFromPipeline)] [PSTypeName('Indented.BuildInfo')] [PSObject]$BuildInfo, # Exclude script files containing PowerShell classes. [Switch]$ExcludeClass ) try { Push-Location $buildInfo.Path.Source.Module $itemTypes = [Ordered]@{ enumeration = 'enum*' class = 'class*' private = 'priv*' public = 'pub*' initialisation = 'InitializeModule.ps1' } if ($Type -eq 'ShouldMerge') { foreach ($itemType in $itemTypes.Keys) { if ($itemType -ne 'class' -or ($itemType -eq 'class' -and -not $ExcludeClass)) { Get-ChildItem $itemTypes[$itemType] -Recurse -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer -and $_.Extension -eq '.ps1' -and $_.Length -gt 0 } | Add-Member -NotePropertyName 'BuildItemType' -NotePropertyValue $itemType -PassThru } } } elseif ($Type -eq 'Static') { [String[]]$exclude = $itemTypes.Values + '*.config', 'test*', 'doc*', 'help', '.build*.ps1', 'build.psd1' foreach ($item in Get-ChildItem) { $shouldExclude = $false foreach ($exclusion in $exclude) { if ($item.Name -like $exclusion) { $shouldExclude = $true } } if (-not $shouldExclude) { $item } } } } catch { $pscmdlet.ThrowTerminatingError($_) } finally { Pop-Location } } function Get-BuildTask { <# .SYNOPSIS Get build tasks. .DESCRIPTION Get the build tasks deemed to be applicable to this build. If the ListAvailable parameter is supplied, all available tasks will be returned. #> [CmdletBinding(DefaultParameterSetName = 'ForBuild')] [OutputType('BuildTask')] param ( # A build information object used to determine which tasks will apply to the current build. [Parameter(Mandatory, Position = 1, ValueFromPipeline, ParameterSetName = 'ForBuild')] [PSTypeName('Indented.BuildInfo')] [PSObject]$BuildInfo, # Filter tasks by task name. [String]$Name = '*', # List all available tasks, irrespective of conditions applied to the task. [Parameter(Mandatory, ParameterSetName = 'List')] [Switch]$ListAvailable ) begin { if (-not $Name.EndsWith('.ps1') -and -not $Name.EndsWith('*')) { $Name += '.ps1' } $path = Join-Path $myinvocation.MyCommand.Module.ModuleBase 'task' if (-not $Script:buildTaskCache) { $Script:buildTaskCache = @{} Get-ChildItem $path -File -Filter *.ps1 -Recurse | ForEach-Object { $task = . $_.FullName $Script:buildTaskCache.Add($task.Name, $task) } } } process { if ($buildInfo) { Push-Location $buildInfo.Path.Source.Module } try { $Script:buildTaskCache.Values | Where-Object { Write-Verbose ('Evaluating {0}' -f $_.Name) $_.Name -like $Name -and ($ListAvailable -or (& $_.If)) } } catch { Write-Error -Message ('Failed to evaluate task condition: {0}' -f $_.Exception.Message) -ErrorId 'ConditionEvaluationFailed' } if ($buildInfo) { Pop-Location } } } function Get-ClassInfo { <# .SYNOPSIS Get information about a class implemented in PowerShell. .DESCRIPTION Get information about a class implemented in PowerShell. .EXAMPLE Get-ChildItem -Filter *.psm1 | Get-ClassInfo Get all classes declared within the *.psm1 file. #> [CmdletBinding(DefaultParameterSetName = 'FromPath')] [OutputType('Indented.ClassInfo')] param ( # The path to a file containing one or more functions. [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPath')] [Alias('FullName')] [String]$Path, # A script block containing one or more functions. [Parameter(ParameterSetName = 'FromScriptBlock')] [ScriptBlock]$ScriptBlock ) process { try { $ast = Get-Ast @psboundparameters $ast.FindAll( { param( $childAst ) $childAst -is [System.Management.Automation.Language.TypeDefinitionAst] }, $IncludeNested ) | ForEach-Object { $ast = $_ [PSCustomObject]@{ Name = $ast.Name Extent = $ast.Extent | Select-Object File, StartLineNumber, EndLineNumber Definition = $ast.Extent.ToString() PSTypeName = 'Indented.ClassInfo' } } } catch { Write-Error -ErrorRecord $_ } } } function Get-FunctionInfo { <# .SYNOPSIS Get an instance of FunctionInfo. .DESCRIPTION FunctionInfo does not present a public constructor. This function calls an internal / private constructor on FunctionInfo to create a description of a function from a script block or file containing one or more functions. .EXAMPLE Get-ChildItem -Filter *.psm1 | Get-FunctionInfo Get all functions declared within the *.psm1 file and construct FunctionInfo. .EXAMPLE Get-ChildItem C:\Scripts -Filter *.ps1 -Recurse | Get-FunctionInfo Get all functions declared in all ps1 files in C:\Scripts. #> [CmdletBinding(DefaultParameterSetName = 'FromPath')] [OutputType([System.Management.Automation.FunctionInfo])] param ( # The path to a file containing one or more functions. [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPath')] [Alias('FullName')] [String]$Path, # A script block containing one or more functions. [Parameter(ParameterSetName = 'FromScriptBlock')] [ScriptBlock]$ScriptBlock, # By default functions nested inside other functions are ignored. Setting this parameter will allow nested functions to be discovered. [Switch]$IncludeNested ) begin { $executionContextType = [PowerShell].Assembly.GetType('System.Management.Automation.ExecutionContext') $constructor = [System.Management.Automation.FunctionInfo].GetConstructor( [System.Reflection.BindingFlags]'NonPublic, Instance', $null, [System.Reflection.CallingConventions]'Standard, HasThis', ([String], [ScriptBlock], $executionContextType), $null ) } process { try { $ast = Get-Ast @psboundparameters $ast.FindAll( { param( $childAst ) $childAst -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $childAst.Parent -isnot [System.Management.Automation.Language.FunctionMemberAst] }, $IncludeNested ) | ForEach-Object { $ast = $_ try { $internalScriptBlock = $ast.Body.GetScriptBlock() } catch { Write-Debug ('{0} :: {1} : {2}' -f $path, $ast.Name, $_.Exception.Message) } if ($internalScriptBlock) { $extent = $ast.Extent | Select-Object File, StartLineNumber, EndLineNumber $constructor.Invoke(([String]$ast.Name, $internalScriptBlock, $null)) | Add-Member -NotePropertyName Extent -NotePropertyValue $extent -PassThru } } } catch { Write-Error -ErrorRecord $_ } } } function Get-LevenshteinDistance { <# .SYNOPSIS Get the Levenshtein distance between two strings. .DESCRIPTION The Levenshtein distance represents the number of changes required to change one string into another. This algorithm can be used to test for typing errors. This command makes use of the Fastenshtein library. Credit for this algorithm goes to Dan Harltey. Converted to PowerShell from https://github.com/DanHarltey/Fastenshtein/blob/master/src/Fastenshtein/StaticLevenshtein.cs. #> [CmdletBinding()] param ( # The reference string. [Parameter(Mandatory)] [String]$ReferenceString, # The different string. [Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyString()] [String]$DifferenceString ) process { if ($DifferenceString.Length -eq 0) { return [PSCustomObject]@{ ReferenceString = $ReferenceString DifferenceString = $DifferenceString Distance = $ReferenceString.Length } } $costs = [Int[]]::new($DifferenceString.Length) for ($i = 0; $i -lt $costs.Count; $i++) { $costs[$i] = $i + 1 } for ($i = 0; $i -lt $ReferenceString.Length; $i++) { $cost = $i $additionCost = $i $value1Char = $ReferenceString[$i] for ($j = 0; $j -lt $DifferenceString.Length; $j++) { $insertionCost = $cost $cost = $additionCost $additionCost = $costs[$j] if ($value1Char -ne $DifferenceString[$j]) { if ($insertionCost -lt $cost) { $cost = $insertionCost } if ($additionCost -lt $cost) { $cost = $additionCost } ++$cost } $costs[$j] = $cost } } [PSCustomObject]@{ ReferenceString = $ReferenceString DifferenceString = $DifferenceString Distance = $costs[$costs.Count - 1] } } } function Get-MethodInfo { <# .SYNOPSIS Get information about a method implemented in PowerShell class. .DESCRIPTION Get information about a method implemented in PowerShell class. .EXAMPLE Get-ChildItem -Filter *.psm1 | Get-MethodInfo Get all methods declared within all classes in the *.psm1 file. #> [CmdletBinding(DefaultParameterSetName = 'FromPath')] [OutputType('Indented.MemberInfo')] param ( # The path to a file containing one or more functions. [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPath')] [Alias('FullName')] [String]$Path, # A script block containing one or more functions. [Parameter(ParameterSetName = 'FromScriptBlock')] [ScriptBlock]$ScriptBlock ) process { try { $ast = Get-Ast @psboundparameters $ast.FindAll( { param( $childAst ) $childAst -is [System.Management.Automation.Language.FunctionMemberAst] }, $IncludeNested ) | ForEach-Object { $ast = $_ [PSCustomObject]@{ Name = $ast.Name FullName = '{0}\{1}' -f $_.Parent.Name, $_.Name Extent = $ast.Extent | Select-Object File, StartLineNumber, EndLineNumber Definition = $ast.Extent.ToString() PSTypeName = 'Indented.MemberInfo' } } } catch { Write-Error -ErrorRecord $_ } } } function New-ConfigDocument { <# .SYNOPSIS Create a new build configuration document .DESCRIPTION The build configuration document may be used to adjust the configurable build values for a single module. This file is optional, without it the following default values will be used: - CodeCoverageThreshold: 0.8 (80%) - EndOfLineChar: [Environment]::NewLine - License: MIT - CreateChocoPackage: $false #> [CmdletBinding(SupportsShouldProcess)] param ( # BuildInfo is used to determine the source path. [Parameter(ValueFromPipeline)] [PSTypeName('Indented.BuildInfo')] $BuildInfo = (Get-BuildInfo) ) process { $documentPath = Join-Path $BuildInfo.Path.Source.Module 'buildConfig.psd1' $eolChar = switch -Regex ([Environment]::NewLine) { '\r\n' { '`r`n'; break } '\n' { '`n'; break } '\r' { '`r'; break } } # Build configuration for Indented.Build @( '@{' ' CodeCoverageThreshold = 0.8' (' EndOfLineChar = "{0}"' -f $eolChar) " License = 'MIT'" ' CreateChocoPackage = $false' '}' ) | Set-Content -Path $documentPath } } function Start-Build { <# .SYNOPSIS Start a build. .DESCRIPTION Start a build using Invoke-Build. If a build script is not present one will be created. If a build script exists it will be used. If the build script exists this command is superfluous. #> [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '')] [CmdletBinding()] param ( # The task categories to execute. [String[]]$BuildType = ('Setup', 'Build', 'Test'), [Parameter(ValueFromPipeline)] [PSTypeName('Indented.BuildInfo')] [PSObject[]]$BuildInfo = (Get-BuildInfo), [String]$ScriptName = '.build.ps1' ) foreach ($instance in $BuildInfo) { try { # If a build script exists in the project root, use it. $buildScript = Join-Path $instance.Path.ProjectRoot $ScriptName # Remove the script if it is created by this process. Export-BuildScript can be used to create a persistent script. $shouldClean = $false if (-not (Test-Path $buildScript)) { $instance | Export-BuildScript -Path $buildScript $shouldClean = $true } Import-Module InvokeBuild -Global Invoke-Build -Task $BuildType -File $buildScript -BuildInfo $instance } catch { throw } finally { if ($shouldClean) { Remove-Item $buildScript } } } } function Update-DevRootModule { <# .SYNOPSIS Update a dev root module which dot-sources all module content. .DESCRIPTION Create or update a root module file which loads module content using dot-sourcing. All content which should would normally be merged is added to a psm1 file. All other module content, such as required assebmlies, is ignored. #> [CmdletBinding(SupportsShouldProcess)] param ( # BuildInfo is used to determine the source path. [Parameter(ValueFromPipeline)] [PSTypeName('Indented.BuildInfo')] [PSObject]$BuildInfo = (Get-BuildInfo) ) process { $script = [System.Text.StringBuilder]::new() $groupedItems = $buildInfo | Get-BuildItem -Type ShouldMerge | Where-Object BaseName -ne 'InitializeModule' | Group-Object BuildItemType $null = foreach ($group in $groupedItems) { $script.AppendFormat('${0} = @(', $group.Name).AppendLine() foreach ($file in $group.Group) { $relativePath = $file.FullName -replace ([Regex]::Escape($buildInfo.Path.Source.Module)) -replace '^\\' -replace '\.ps1$' $groupTypePath, $relativePath = $relativePath -split '\\', 2 $script.AppendFormat(" '{0}'", $relativePath).AppendLine() } $script.AppendLine(')').AppendLine() $script.AppendFormat('foreach ($file in ${0}) {{', $group.Name).AppendLine(). AppendFormat(' . ("{{0}}\{0}\{{1}}.ps1" -f $psscriptroot, $file)', $groupTypePath).AppendLine(). AppendLine('}'). AppendLine() if ($group.Name -eq 'public') { $script.AppendLine('$functionsToExport = @(') foreach ($function in $group.Group | Get-FunctionInfo) { $script.AppendFormat(" '{0}'", $function.Name).AppendLine() } $script.AppendLine(')') $script.AppendLine('Export-ModuleMember -Function $functionsToExport').AppendLine() } } $initializeScriptPath = Join-Path $buildInfo.Path.Source.Module.FullName 'InitializeModule.ps1' if (Test-Path $initializeScriptPath) { $null = $script.AppendLine('. ("{0}\InitializeModule.ps1" -f $psscriptroot)'). AppendLine('InitializeModule') } $rootModulePath = Join-Path $buildInfo.Path.Source.Module ('{0}.psm1' -f $buildInfo.ModuleName) Set-Content -Path $rootModulePath -Value $script.ToString() } } function InitializeModule { # Fill the build task cache. This makes the module immune to source file deletion once the cache is filled (when building itself). $null = Get-BuildTask -ListAvailable } InitializeModule |