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