var/tests/CodingConventions.tests.ps1

function Get-ItemFromAst {
    # .SYNOPSIS
    # Get an item from the abstract syntax tree.
    # .DESCRIPTION
    # Searches for an item using the specified predicate.
    # .PARAMETER Ast
    # The base of the tree to search from.
    # .PARAMETER Query
    # Used to create the predicate.
    # .INPUTS
    # System.Management.Automation.Language.Ast
    # System.String
    # .OUTPUTS
    # System.Management.Automation.PSObject
    # .NOTES
    # Author: Chris Dent
    #
    # Change log:
    # 07/12/2015 - Chris Dent - Created.

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 1)]
        [System.Management.Automation.Language.Ast]$Ast,

        [Parameter(Mandatory = $true, Position = 2)]
        [String]$Query
    )

    $predicate = [ScriptBlock]::Create(('param( $Ast ); {0}' -f $Query))
    $matchedElements = $Ast.FindAll($Predicate, $true) | Where-Object { $_ }
    if ($matchedElements) {
        foreach ($element in $matchedElements) {
            '{0} at line {1}, position {2}: {3}' -f
                $element.Extent.Text,
                $element.Extent.StartLineNumber,
                $element.Extent.StartColumnNumber,
                $element.Parent.Extent.Text
        }
    } else {
        return $false 
    }
}

function Test-FunctionStructure {
    # .SYNOPSIS
    # Use the abstract syntax tree to explore the content of a command.
    # .DESCRIPTION
    # Test-FunctionStructure is used to analyse the content of a function to support the standards described below.
    # .PARAMETER ScriptBlock
    # A script block to operate against.
    # .INPUTS
    # System.Management.Automation.ScriptBlock
    # .OUTPUTS
    # System.Management.Automation.PSObject
    # .EXAMPLE
    # Get-Command New-GRXPathNavigator | Test-FunctionStructure
    # .NOTES
    # Author: Chris Dent
    #
    # Change log:
    # 07/12/2015 - Chris Dent - Created.

    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject])]
    param(
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ScriptBlock]$ScriptBlock
    )

    process {
        return [PSCustomObject]@{
            HasNestedFunctions    = (Get-ItemFromAst $ScriptBlock.Ast.Body '$Ast -is [System.Management.Automation.Language.FunctionDefinitionAst]')
            IsUsingAddType        = (Get-ItemFromAst $ScriptBlock.Ast '$Ast -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $Ast.Value -eq "Add-Type"')
            IsUsingAliases        = (Get-ItemFromAst $ScriptBlock.Ast '$Ast -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $Ast.Parent -isnot [System.Management.Automation.Language.MemberExpressionAst] -and $Ast.StringConstantType -eq [System.Management.Automation.Language.StringConstantType]::BareWord -and (Test-Path -LiteralPath alias:$($Ast.Value))')
            IsUsingNewObject      = (Get-ItemFromAst $ScriptBlock.Ast '$Ast -is [System.Management.Automation.Language.PipelineAst] -and $Ast.Extent.Text -match "New-Object (-TypeName )?(Object|PSObject|PSCustomObject)"')
            IsUsingThrow          = (Get-ItemFromAst $ScriptBlock.Ast '$Ast -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $Ast.Value -eq "throw"')
            IsUsingWriteErrorStop = (Get-ItemFromAst $ScriptBlock.Ast '$Ast -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $Ast.Value -eq "Write-Error" -and $Ast.Parent.Extent.Text -match "-ErrorAction (1|Stop)"')
        }
    }
}

function Test-IndentationStyle {
    # .SYNOPSIS
    # Test a scriptblock for subjectively incorrect use of white space.
    # .DESCRIPTION
    # Test-IndentationStyle looks at the content of a script and attempts to determine if the indentation style is somewhat consistent or not.
    #
    # As a by-product this function also checks for trailing white space.
    # .PARAMETER ScriptBlock
    # The script block to analyse.
    # .INPUTS
    # System.Management.Automation.ScriptBlock
    # .OUTPUTS
    # System.Management.Automation.PSObject
    # .EXAMPLE
    # Get-Command ConvertTo-GRString | Test-IndentationStyle
    # .NOTES
    # Author: Chris Dent
    #
    # Change log:
    # 08/12/2015 - Chris Dent - Created.

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true)]
        [ScriptBlock]$ScriptBlock
    )

    process {
        $Indentation = [PSCustomObject]@{
            Character          = $null
            Description        = ''
            HasMixed           = $false
            HasIncorrectIndent = $false
            HasTrailingSpaces  = $false
            Length             = 0
            IncorrectIndent    = (New-Object System.Collections.Generic.List[Int])
            TrailingSpaces     = (New-Object System.Collections.Generic.List[Int]) 
        }

        $Definition = $ScriptBlock.ToString() -split '\r?\n'

        $BraceStack = New-Object System.Collections.Stack
        $CommentBlock = $EscapedLineBreak = $PipelinedStack = $false
        for ($i = 0; $i -lt $Definition.Length; $i++) {
            if ($Definition[$i].Trim().Length -gt 0) {
                $Tokens = [System.Management.Automation.PSParser]::Tokenize($Definition[$i], [Ref]$null)

                # Establish if this is a comment block or not. Tokenize would be able to tell us this more easily if it weren't line-by-line processing.
                $Tokens | Where-Object { $_.Type -eq 'Comment' } | ForEach-Object {
                    if ($_.Content -eq '<#') {
                        $CommentBlock = $true 
                    } elseif ($_.Content -eq '#>') {
                        $CommentBlock = $false
                    }
                }

                # Attempt to establish the indentation style
                if (-not $CommentBlock) {
                    if ($Indentation.Character -eq $null -and $Definition[$i] -match '^([\s\t]+)') {
                        $Indentation.Character = [String]($matches[1][0])
                        $Indentation.Length = $matches[1].Length
                        $Indentation.Description = switch ($Indentation.Length) {
                            1       { 'single' }
                            2       { 'double' }
                            3       { 'triple' }
                            4       { 'quad' }
                            default { 'long' }
                        }
                        $Indentation.Description += switch ($Indentation.Character) {
                            ' '  { '-space' }
                            "`t" { '-tab' }
                        }
                    }
                }

                # Simple tests

                # Mixed indentation character
                if ($Definition[$i] -match '^(\s+\t|\t+\s)') {
                    $Indentation.HasMixed = $true 
                } elseif ($Indentation.Character -eq ' ' -and $Definition[$i] -match '^\t') {
                    $Indentation.HasMixed = $true 
                } elseif ($Indentation.Character -eq "`t" -and $Defintion[$i] -match ' ') {
                    $Indentation.HasMixed = $true 
                }
                # Trailing spaces
                if ($Definition[$i] -match ' +$') {
                    $Indentation.TrailingSpaces.Add($i + 1) 
                }

                # Account for opening and closing braces
                # A little extra work is required to handle close first then open.
                $Control = 0
                $Tokens | Where-Object { $_.Type -in 'GroupStart', 'GroupEnd' } | ForEach-Object {
                    if ($_.Type -eq 'GroupStart') {
                        $Control++
                        $null = $BraceStack.Push($_.Content)
                    } else {
                        $Control--
                        $null = $BraceStack.Pop()
                    }
                }

                if ($Control -eq 0 -and $Tokens[0].Type -eq 'GroupEnd') {
                    $IndentCount = $BraceStack.Count
                } elseif ($Control -lt 0 -and $Tokens.Count -gt 1 -and $Tokens[-1].Type -eq 'GroupEnd') {
                    # Attempting to account for " Thing)", but not "} thing (Stuff)"
                    # Where the last character is a closing group, but is not preceeded by the equivalent opening group
                    $GroupEnd = $Tokens[-1].Content
                    $GroupStart = switch ($GroupEnd) {
                        ')' { '(' }
                        ']' { '[' }
                        '}' { '}' }
                    }
                    if (-not ($Tokens | Where-Object { $_.Type -eq 'GroupStart' -and $_.Content -eq $GroupStart })) {
                        $IndentCount = $BraceStack.Count + 2
                    } else {
                        $IndentCount = $BraceStack.Count + 1 
                    }
                } elseif ($Control -gt 0) {
                    $IndentCount = $BraceStack.Count
                } else {
                    $IndentCount = $BraceStack.Count + 1
                }

                # Handle escape characters at the end of the line, allow extra indentation to follow. PSParser cannot see these characters.
                # This will apply to the next line, but will not affect the overall count.

                # Extra indentation based on an occurence of this one the preceeding line.
                if ($EscapedLineBreak) {
                    $IndentCount++
                }
                # Set the control variable if this has occured on this line.
                if (-not $EscapedLineBreak -and $Definition[$i] -match '`$' -and $Tokens[-1].Type -ne 'Comment') {
                    $EscapedLineBreak = $true
                }

                # Handle lines ending with |.
                # Indentation on the following line will be allowed but there's no way to track the end of the block with this style.
                if ($PipelinedStack) {#
                    $IndentCount++
                }
                if ($Tokens[-1].Type -eq 'Operator' -and $Tokens[-1].Content -eq '|') {
                    $PipelinedStack = $true
                }

                # A final check for the PipelinedStack
                if ($PipelinedStack) {
                    $TempIndentString = $Indentation.Character * $Indentation.Length * ($IndentCount - 1)
                    if ($Definition[$i] -match "^$TempIndentString\S+") {
                        $PipelinedStack = $false
                        $IndentCount--
                    }
                }

                # The amount the code is expected to be indented.
                $IndentString = $Indentation.Character * $Indentation.Length * $IndentCount

                # Test it

                if ($Definition[$i] -notmatch "^$IndentString\S+") {
                    Write-Debug ("Fail: ^$IndentString\S+".PadRight(40, ' ') + "Line " + ([String]($i + 1)).PadRight(6, ' ') + $Definition[$i])
                    $Indentation.IncorrectIndent.Add($i + 1)
                } else {
                    Write-Debug ("Pass: ^$IndentString\S+".PadRight(40, ' ') + "Line " + ([String]($i + 1)).PadRight(6, ' ') + $Definition[$i])
                }

                # If the line was previously marked as escaped, but this one is not, unset the value now testing of indentation levels have been performed.
                if ($EscapedLineBreak -and $Definition[$i] -notmatch '`$' -and $Tokens[-1].Type -ne 'Comment') {
                    $EscapedLineBreak = $false
                }
            }
        }

        if ($Indentation.IncorrectIndent.Count -gt 0) {
            $Indentation.HasIncorrectIndent = "Lines: $($Indentation.IncorrectIndent.ToArray())"
        }
        if ($Indentation.TrailingSpaces.Count -gt 0) {
            $Indentation.HasTrailingSpaces = "Lines: $($Indentation.TrailingSpaces.ToArray())"
        }

        $Indentation
    }
}

#
# Main
#

# This is a bit of a problem now.
$ModuleName = Split-Path (Split-Path $psscriptroot -Parent) -Leaf

$ReservedParameterNames = ([System.Management.Automation.Internal.CommonParameters]).GetProperties() | Select-Object -ExpandProperty Name
$ReservedParameterNames += ([System.Management.Automation.Internal.ShouldProcessParameters]).GetProperties() | Select-Object -ExpandProperty Name

#
# Functions tests
#

Describe 'Function help content' {
    Get-Command -Module $ModuleName | ForEach-Object {
        $CommandInfo = $_
        $HelpContent = Get-Help $CommandInfo.Name -Full

        Context $CommandInfo.Name {
            It 'Must have a synopsis' {
                $HelpContent.synopsis | Should Not BeNullOrEmpty
            }

            It 'Must have a description' {
                $HelpContent.description.text | Should Not BeNullOrEmpty
            }

            $CommandInfo.Parameters.Values | Where-Object { $_.Name -notin $ReservedParameterNames } | ForEach-Object {
                It "Must have a description for Parameters\$($_.Name)" {
                    (Get-Help $CommandInfo.Name -Parameter $_.Name).description.Text | Should Not BeNullOrEmpty 
                }
            }

            if ($CommandInfo.Name -match '-') {
                It 'Must have at least 1 example' {
                    ($HelpContent.examples.example | Measure-Object).Count | Should BeGreaterThan 0
                }
            }

            It 'Must have an author in notes' {
                $HelpContent.alertSet.alert.Text | Should Match 'Author: +.+'
            }

            It 'Must have a change log in notes' {
                $HelpContent.alertSet.alert.Text | Should Match 'Change log:'
            }
        }
    }
}

#
# Code analysis - Valid only for FunctionInfo in the context of a module
#

Describe 'Function structure' {
    Get-Command -Module $ModuleName -CommandType Function | ForEach-Object {
        $CommandInfo = $_
        $StructuralAnalysis = $CommandInfo | Test-FunctionStructure
        $IndentationStyle = $CommandInfo | Test-IndentationStyle

        Context $CommandInfo.Name {
            if ($CommandInfo.Name -match '-') {
                It "Must use an approved verb" {
                    Get-Verb $CommandInfo.Verb | Should Not BeNullOrEmpty
                }
            }

            It 'Must declare the CmdletBinding attribute to prevent parameter overloading' {
                $CommandInfo.CmdletBinding | Should Be $true 
            }

            It 'Must use PSCustomObject in place of New-Object PSObject -Property' {
                $StructuralAnalysis.IsUsingNewObject | Should Be $false
            }

            It 'Must not use Add-Type inside the body of a function' {
                $StructuralAnalysis.IsUsingAddType | Should Be $false
            }

            It 'Must not contain nested functions' {
                $StructuralAnalysis.HasNestedFunctions | Should Be $false
            }

            It "Must not mix space and tab indentation" {
                $IndentationStyle.HasMixed | Should Be $false
            }
        }
    }
}

Describe 'Function structure (recommended)' {
    Get-Command -Module $ModuleName -CommandType Function | ForEach-Object {
        $CommandInfo = $_
        $CommandMetadata = New-Object System.Management.Automation.CommandMetadata($CommandInfo)
        $StructuralAnalysis = $CommandInfo | Test-FunctionStructure
        $IndentationStyle = $CommandInfo | Test-IndentationStyle

        Context $CommandInfo.Name {
            It 'Should implement the OutputType attribute if returning output' {
                $CommandInfo.OutputType.Length | Should BeGreaterThan 0
            }

            It 'Should not use throw if CmdLetBinding is declared' {
                $CommandInfo.CmdletBinding -and $StructuralAnalysis.IsUsingThrow | Should Be $false
            }

            It 'Should not use Write-Error -ErrorAction Stop' {
                $StructuralAnalysis.IsUsingWriteErrorStop | Should Be $false 
            }

            It "Should be consistently indented" {
                $IndentationStyle.HasIncorrectIndent | Should Be $false
            }

            It "Should not have unnecessary trailing white space" {
                $IndentationStyle.HasTrailingSpaces | Should Be $false 
            }
        }
    }
}