Public/Compile-SourceScript.ps1

function Compile-SourceScript {
    <#
    .SYNOPSIS
    A wrapper for compiling SourceMod (.sp) and AMX Mod X (.sma) plugin source files for Source / Goldsource games.
 
    .DESCRIPTION
    Specified plugins are compiled and subsequently copied into the mod's plugins directory if found to be new or have been changed.
 
    .PARAMETER File
    Path to the plugin's source file (.sp or .sma).
 
    .PARAMETER SkipWrapper
    To directly run the mod's compiler instead of using provided wrappers (such as 'compile.exe' and 'compile.sh') in the compilation process.
 
    .PARAMETER Force
    Copies the newly compiled plugin to the mod's plugins directory without user confirmation.
 
    .EXAMPLE
    Compile-SourceScript -File ~/servers/csgo/addons/sourcemod/scripting/plugin1.sp
    Compiles the SourceMod plugin source file 'plugin1.sp', and installs the compiled plugin with user confirmation for the game Counter-Strike: Global Offensive.
 
    .EXAMPLE
    Compile-SourceScript -File ~/servers/cstrike/addons/amxmodx/scripting/plugin2.sma -SkipWrapper -Force
    Compiles the AMX Mod X plugin source file 'plugin2.sma' without using the mod's compiler wrapper, and installs the compiled plugin without user confirmation for the game Counter-Strike 1.6.
 
    .LINK
    https://github.com/startersclan/Compile-SourceScript
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)]
        [ValidateNotNullOrEmpty()]
        [string]$File
        ,
        [Parameter(Mandatory=$False)]
        [switch]$SkipWrapper
        ,
        [Parameter(Mandatory=$False)]
        [switch]$Force
    )

    begin {
        try {
            $ErrorActionPreference = 'Stop'
            "Starting Compile-SourceScript" | Write-Host -ForegroundColor Cyan

            # Verify the specified item's type and extension
            $sourceFile = Get-Item -Path $PSBoundParameters['File']
            if (!(Test-Path -Path $sourceFile.FullName -PathType Leaf)) {
                throw "The item is not a file."
            }
            $MOD_NAME = if ($sourceFile.Extension -eq '.sp') { 'sourcemod' }
                        elseif ($sourceFile.Extension -eq '.sma') { 'amxmodx' }
            if (!$MOD_NAME) {
                throw "File is not a '.sp' or '.sma' source file."
            }
            if (!($sourceFile.DirectoryName | Split-Path)) {
                throw "The directory 'addons/$MOD_NAME/' cannot exist relative to the specified plugin source file '$($sourceFile.FullName)'."
            }

            # Initialize variables
            $MOD = @{
                # The sourcemod compiler returns exits code correctly.
                # The sourcemod compiler wrapper returns the exit code of the lastmost executed shell statement. This is particularly bad when the compiler exits with '0' from a successful finalmost shell statement, even when one or more prior shell statements exited with non-zero exit codes.
                # Hence, knowing that exit codes are not a reliable way to determine whether one or more compilation statements failed, we are going to use a regex on the stdout as a more reliable way to detect compilation errors, regardless of whether compilation was performed via the compiler binary or via the compiler wrapper.
                sourcemod = @{
                    script_ext = '.sp'
                    plugin_ext = '.smx'
                    compiled_dir_name = 'compiled'
                    plugins_dir_name = 'plugins'
                    compiler = @{
                        windows = @{
                            wrapper = 'compile.exe'
                            bin = 'spcomp.exe'
                            error_regex = '^Compilation aborted|^\d+\s+Errors?|.*\.sp\(\d+\)\s*:\s*(?:fatal)? error (\d+)'
                        }
                        others = @{
                            wrapper = 'compile.sh'
                            bin = 'spcomp'
                            error_regex = '^Compilation aborted|^\d+\s+Errors?|.*\.sp\(\d+\)\s*:\s*(?:fatal)? error (\d+)'
                        }
                    }
                }
                # The amxmodx compiler binary always exits with exit code 0.
                # The amxmodx compiler wrapper always exits with exit code 0.
                # Hence, knowing that exit codes are not a reliable way to determine whether one or more compilation statements failed, we are going to use a regex on the stdout as a more reliable way to detect compilation errors, regardless of whether compilation was performed via the compiler binary or via the compiler wrapper.
                amxmodx = @{
                    script_ext = '.sma'
                    plugin_ext = '.amxx'
                    compiled_dir_name = 'compiled'
                    plugins_dir_name = 'plugins'
                    compiler = @{
                        windows = @{
                            wrapper = 'compile.exe'
                            bin = 'amxxpc.exe'
                            error_regex = '^\d+\s+Errors?|compile failed|^.*\.sma\(\d+\)\s*:\s*error (\d+)'
                        }
                        others = @{
                            wrapper = 'compile.sh'
                            bin = 'amxxpc'
                            error_regex = '^\d+\s+Errors?|compile failed|^.*\.sma\(\d+\)\s*:\s*error (\d+)'
                        }
                    }
                }
            }
            $OS = if ($env:OS -eq 'Windows_NT') { 'windows' } else { 'others' }
            $COMPILER_NAME = if ($PSBoundParameters['SkipWrapper']) { $MOD[$MOD_NAME]['compiler'][$OS]['bin'] } else { $MOD[$MOD_NAME]['compiler'][$OS]['wrapper'] }
            $SCRIPTING_DIR = $sourceFile.DirectoryName
            $COMPILED_DIR = Join-Path $SCRIPTING_DIR $MOD[$MOD_NAME]['compiled_dir_name']
            $COMPILER_PATH = Join-Path $SCRIPTING_DIR $COMPILER_NAME
            $PLUGINS_DIR = Join-Path (Split-Path $SCRIPTING_DIR -Parent) $MOD[$MOD_NAME]['plugins_dir_name']

            # Verify the presence of the compiler item
            $compiler = Get-Item -Path $COMPILER_PATH -ErrorAction SilentlyContinue
            if (!$compiler) {
                throw "Cannot find the plugin compiler at the path '$COMPILER_PATH'."
            }

        }catch {
            Write-Error -Exception $_.Exception -Message $_.Exception.Message -Category $_.CategoryInfo.Category -TargetObject $_.TargetObject
        }

    }process {
        try {
            "Compiler: '$($compiler.FullName)'" | Write-Host

            # Get all items in compiled directory before compilation by hash
            $compiledDirItemsPre = Get-ChildItem -Path $COMPILED_DIR -File -Recurse -Force | ? { $_.Extension -eq $MOD[$MOD_NAME]['plugin_ext'] } | Select-Object *, @{name='md5'; expression={(Get-FileHash -Path $_.Fullname -Algorithm MD5).Hash}}

            # Generate command line arguments
            $epoch = [Math]::Floor([decimal](Get-Date(Get-Date).ToUniversalTime()-uformat "%s"))
            $stdInFile = Join-Path $SCRIPTING_DIR ".$epoch"
            $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) (New-Guid).Guid
            $stdoutFile = Join-Path $tempDir 'stdout'
            $stderrFile = Join-Path $tempDir 'stderr'
            $processArgs = @{
                FilePath = $compiler.FullName
                ArgumentList = @(
                    if ($PSBoundParameters['SkipWrapper']) {
                        $sourceFile.Name
                        "-o$($MOD[$MOD_NAME]['compiled_dir_name'])/$($sourceFile.Basename)$($MOD[$MOD_NAME]['plugin_ext'])"
                    }else {
                        $sourceFile.Name
                    }
                )
                WorkingDirectory = $SCRIPTING_DIR
                RedirectStandardInput = $stdInFile
                RedirectStandardOutput = $stdoutFile
                RedirectStandardError = $stderrFile
                Wait = $true
                NoNewWindow = $true
                PassThru = $true
            }

            try {
                # Prepare compilation environment
                if ($item = New-Item -Path $stdInFile -ItemType File -Force) {
                    # This dummy input bypasses the 'Press any key to continue' prompt of the compiler
                    '1' | Out-File -FilePath $item.FullName -Force -Encoding utf8
                }
                New-Item $tempDir -ItemType Directory -Force > $null
                New-Item -Path $COMPILED_DIR -ItemType Directory -Force | Out-Null

                # Begin compilation
                "Compiling..." | Write-Host -ForegroundColor Cyan
                if ($PSBoundParameters['SkipWrapper']) { "Compiling $($sourceFile.Name)..." | Write-Host -ForegroundColor Yellow }

                # Compile
                $global:LASTEXITCODE = 0
                $p = Start-Process @processArgs
                $stdout = Get-Content $stdoutFile
                $stdout | Write-Host
                $stderr = Get-Content $stderrFile
                $stderr | Write-Host
                foreach ($line in $stdout) {
                    if ($line -match $MOD[$MOD_NAME]['compiler'][$OS]['error_regex']) {
                        $global:LASTEXITCODE = 1
                        break
                    }
                }
            }catch {
                throw
            }finally {
                # Cleanup
                Remove-Item $stdInFile -Force
                Remove-Item $tempDir -Recurse -Force
            }

            if ($PSBoundParameters['SkipWrapper']) { "End of compilation." | Write-Host -ForegroundColor Yellow }

            # Get all items in compiled directory after compilation by hash
            $compiledDirItemsPost = Get-ChildItem -Path $COMPILED_DIR -File -Recurse -Force | ? { $_.Extension -eq $MOD[$MOD_NAME]['plugin_ext'] } | Select-Object *, @{name='md5'; expression={(Get-FileHash -Path $_.FullName -Algorithm MD5).Hash}}

            # Get items with differing hashes
            $compiledDirItemsDiff = if ($compiledDirItemsPost) {
                                        if ($compiledDirItemsPre) {
                                            $hashesDiffObj = Compare-object -ReferenceObject $compiledDirItemsPre -DifferenceObject $compiledDirItemsPost -Property FullName, md5 | ? { $_.SideIndicator -eq '=>' }
                                            if ($hashesDiffObj) {
                                                $compiledDirItemsPost | ? { $_.md5 -in $hashesDiffObj.md5 }
                                            }
                                        }else {
                                            $compiledDirItemsPost
                                        }
                                    }

            # Return if no items in the compiled directory have changed
            if (!$compiledDirItemsDiff) {
                "`nNo changes to plugins were found. No operations were performed." | Write-Host -ForegroundColor Magenta
                return

            }else {
                # List successfully compiled plugins
                "`nNewly compiled plugins:" | Write-Host -ForegroundColor Cyan
                $compiledDirItemsDiff | % {
                    $compiledPluginHash = (Get-FileHash -Path $_.FullName -Algorithm MD5).Hash
                    " $($_.Name), $($_.LastWriteTime), $compiledPluginHash" | Write-Host -ForegroundColor White
                }

                # Prepare to install the plugin
                New-Item -Path $PLUGINS_DIR -ItemType Directory -Force | Out-Null
                $installationFailure = $false

                $compiledDirItemsDiff | % {
                    # Display info for the compiled plugin
                    "`n$($_.Name):" | Write-Host -ForegroundColor Green
                    if ($_.Basename -ne $sourceFile.Basename) {
                        " The plugin's name does not match the specified script's name. The plugin will not copied to the plugins directory." | Write-Host -ForegroundColor Yellow
                        return  # continue in %
                    }

                    # Check for an existing plugin
                    $existingPlugin = Get-Item -Path "$PLUGINS_DIR/$($_.Name)" -ErrorAction SilentlyContinue
                    if (!$existingPlugin) {
                        " Plugin does not currently exist in the plugins directory." | Write-Host -ForegroundColor Yellow
                    }else {
                        $existingPluginHash = (Get-FileHash -Path $existingPlugin -Algorithm MD5).Hash
                        " Existing: $($existingPlugin.LastWriteTime), $existingPluginHash" | Write-Host -ForegroundColor Yellow
                    }

                    # Display the compiled and existing plugin's file info
                    $compiledPluginHash = (Get-FileHash -Path $_.FullName -Algorithm MD5).Hash
                    " Compiled: $($_.LastWriteTime), $compiledPluginHash" | Write-Host -ForegroundColor Green

                    # Attempt to copy the compiled plugin to the plugins directory
                    try {
                        Copy-Item -Path $_.FullName -Destination $PLUGINS_DIR -Confirm:$(!$PSBoundParameters['Force'])
                    }catch {
                        " Plugin copy error." | Write-Host -ForegroundColor Magenta
                        $installationFailure = $true
                        return  # continue in %
                    }

                    # Alert the user on the situation of the plugin
                    $updatedPlugin = Get-Item -Path "$PLUGINS_DIR/$($_.Name)" -ErrorAction SilentlyContinue
                    if (!$updatedPlugin) { "`n Plugin does not exist in the plugins directory." | Write-Host -ForegroundColor Magenta; return }
                    $updatedPluginHash = (Get-FileHash -Path $updatedPlugin -Algorithm MD5).Hash
                    if ($updatedPluginHash -eq $compiledPluginHash) { "`n Plugin successfully copied to '$($updatedPlugin.FullName)'" | Write-Host -ForegroundColor Green }
                    else { "`n Failed to update existing plugin in the plugins directory." | Write-Host -ForegroundColor Magenta; return }
                }

                # Throw an error if the copying process failed
                if ($installationFailure) {
                    throw "Failed to install the specified plugin."
                }
            }

        }catch {
            Write-Error -Exception $_.Exception -Message $_.Exception.Message -Category $_.CategoryInfo.Category -TargetObject $_.TargetObject
        }finally {
            "End of Compile-SourceScript." | Write-Host -ForegroundColor Cyan
        }
    }

}