PSMSBuildHelper.psm1

# Make sure all errors will stop the module logic
$ErrorActionPreference = 'Stop'

# Make sure missing Properties or Variables are not treated as $null
Set-StrictMode -Version Latest

Set-Variable -name Const_VSWhereExecutablePath -value (Join-Path $PSScriptRoot "vswhere.exe") -option Constant
Set-Variable -name MSBuildExecutable -value ([string] $null) -Option AllScope

#region Internal Module Functions

function Invoke-MSBuild {
  [CmdletBinding()]
    param
    (
    [Parameter(Mandatory=$true)]
    [string]
    $ProjectFilePath,

    [Parameter(Mandatory=$true)]
    [string]
    $MSBuildExecutablePath,

    [Parameter(Mandatory=$false)]
    [string]
    $TargetName,

    [Parameter(Mandatory=$false)]
    [System.Collections.Generic.Dictionary[[string], [string]]]
    $Properties = (New-Object 'system.collections.generic.dictionary[[string],[string]]')
  )

  $propertyArguments = @($Properties.GetEnumerator() | %{ "/p:$($_.Key)=`"$($_.Value)`"" })

  $arguments = @(
    '-verbosity:minimal',
    '-nologo',
    '-interactive:False'
  )

  if ($TargetName) {
    $arguments += "-target:$TargetName"
  }

  $arguments += $propertyArguments + "`"$ProjectFilePath`""
  $msBuildOutput = & $MSBuildExecutablePath $arguments

  if ($LASTEXITCODE -ne 0) {
    $message = "MSBuild failed with exit code $LASTEXITCODE. Output: `n$($msBuildOutput -join '`n')"
    Write-Error $message
  }

  return @($msBuildOutput)
}

function Get-PropertyFromMSBuildResultLine {
  [CmdletBinding()]
    param
    (
    [Parameter(Mandatory=$true)]
    [string]
    $Line
  )

  $nameAndValue = $Line.Split('|');
  $propertyName = ConvertFrom-Base64Utf8String -Base64String $nameAndValue[0]

  if ($nameAndValue[1]) {
    $propertyValue = ConvertFrom-Base64Utf8String -Base64String $nameAndValue[1]    
  } else {
    $propertyValue = ''
  }
  
  return [System.Collections.Generic.KeyValuePair[[string], [string]]]::new($propertyName, $propertyValue)
}

function ConvertFrom-Base64Utf8String {
  [CmdletBinding()]
    param
    (
    [Parameter(Mandatory=$true)]
    [string]
    $Base64String
  )

  return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Base64String))
}

function ConvertTo-Base64Utf8String {
  [CmdletBinding()]
    param
    (
    [Parameter(Mandatory=$true)]
    [string]
    $Text
  )
  
  return [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Text))
}

#endregion

#region Exported Module Functions

<#
.SYNOPSIS
 
Gets the path to the latest msbuild executable.
 
.DESCRIPTION
 
Uses vswhere under the covers to find the path to the latest msbuild executable.
 
.OUTPUTS
 
System.String. The path to the latest msbuidl executable
 
.EXAMPLE
 
PS> Get-LatestMSBuildExecutablePath
C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe
 
#>

function Get-LatestMSBuildExecutablePath {
    [OutputType([String])] 
    param(

    )
    
    # if the path has been determined, dont do it again!
    if ($Script:MSBuildExecutable){
      return $Script:MSBuildExecutable
    }

    $vsWhereOutput = (& $Const_VSWhereExecutablePath -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe).Trim()

    if ([string]::IsNullOrWhiteSpace($vsWhereOutput)){
      throw "MSBuild was not found"
    }

    $msBuildExecutablePath = ($vsWhereOutput -split "`n" | Select -First 1).Trim()

    if (!(Test-Path $msBuildExecutablePath)){
        throw "MSBuild path `"$($msBuildExecutablePath)`" does not exist"
    }

    return ($Script:MSBuildExecutable = $msBuildExecutablePath)
}

<#
.SYNOPSIS
Evaluates MSBuild properties in a provided msbuild script file.
 
.DESCRIPTION
This function can evaluate MSBuild properties in a provided msbuild script file.
 
You must:
- pass a path to an msbuild script file for which properties should be evaluated (ProjectFilePath)
- pass one ore multiple property-names to evaluate (PropertyName)
 
You can:
- provide the path to msbuild.exe yourself or let the function use the latest version (MSBuildExecutablePath)
- provide properties with values to pass to the msbuild script for initialization purposes (Properties)
- configure the output to only the valaues of the evaluated properties instead of a hashtable (OnlyValues)
 
.PARAMETER ProjectFilePath
Specifies the path to the msbuild project file
 
.PARAMETER PropertyName
Specifies one ore multiple properties to evaluate
 
.PARAMETER MSBuildExecutablePath
Specifies an optional the path to msbuild.exe. If not provided, the function uses the latest version on the system
 
.PARAMETER Properties
Specifies an optional hashtable containing properties with values used to initialize other properties of the msbuild project file
 
.PARAMETER OnlyValues
Switches the standard output from hashtable (property names and values) to an array of strings (only property values)
 
.INPUTS
None. You cannot pipe objects to Get-EvaluatedMSBuildProperty
 
.OUTPUTS
hashtable. if no "OnlyValues" switch is used.
System.String. If "OnlyValues" switch is used.
 
.EXAMPLE
PS> Get-EvaluatedMSBuildProperty -ProjectFilePath C:\myproject\myproject.csproj -PropertyName AssemblyName
Evaluates the property 'AssemblyName' of msbuild script ' C:\myproject\myproject.csproj'
 
.EXAMPLE
PS> Get-EvaluatedMSBuildProperty -ProjectFilePath C:\myproject\myproject.csproj -PropertyName AssemblyName, RootNamespace
Evaluates the properties 'AssemblyName' and 'RootNamespace' of msbuild script ' C:\myproject\myproject.csproj'
 
PS> Get-EvaluatedMSBuildProperty -ProjectFilePath C:\myproject\myproject.csproj -PropertyName AssemblyName, RootNamespace -OnlyValues
Evaluates the properties 'AssemblyName' and 'RootNamespace' of msbuild script ' C:\myproject\myproject.csproj' and returns the property values as a string array instead of a hashtable
 
.EXAMPLE
PS> Get-EvaluatedMSBuildProperty -ProjectFilePath C:\myproject\myproject.csproj -PropertyName AssemblyName -MSBuildExecutablePath 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe'
Evaluates the property 'AssemblyName' of msbuild script ' C:\myproject\myproject.csproj' using msbuild.exe from 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe'
 
.EXAMPLE
PS> Get-EvaluatedMSBuildProperty -ProjectFilePath C:\myproject\myproject.csproj -PropertyName Version -Properties @{ 'MajorVersion'='5', 'MinorVersion'='2' }
Evaluates the property 'Version' of msbuild script ' C:\myproject\myproject.csproj' by initializing the properties 'MajorVersion' with value '5' and 'MinorVersion' with value '2'. If the Version property is a calculated property like '$(MajorVersion).$(MinorVersion)' the result woul be '5.2'
 
#>

function Get-EvaluatedMSBuildProperty {
  [CmdletBinding()]
    param
    (
    [Parameter(Mandatory=$true)]
    [string]
    $ProjectFilePath,

    [Parameter(Mandatory=$true)]
    [string[]]
    $PropertyName,

    [Parameter(Mandatory=$false)]
    [string]
    $MSBuildExecutablePath = (Get-LatestMSBuildExecutablePath),

    [Parameter(Mandatory=$false)]
    [hashtable]
    $Properties = [hashtable]::new(),

    [Switch]
    $OnlyValues
  )

  [system.collections.generic.dictionary[[string],[string]]] $msbuildProperties = [system.collections.generic.dictionary[[string],[string]]]::new()
  $msbuildProperties['ProjectFilePath'] = $ProjectFilePath
  $msbuildProperties['PropertiesToInitialize'] = ($Properties.GetEnumerator() | %{ "$(ConvertTo-Base64Utf8String -Text $_.Key)|$(ConvertTo-Base64Utf8String -Text $_.Value)" }) -join ';'
  $msbuildProperties['PropertiesToEvaluate'] = ($PropertyName | %{ ConvertTo-Base64Utf8String -Text $_ }) -join ';'
  
  $msbuildResultLines = Invoke-MSBuild  -ProjectFilePath (Join-Path $PSScriptRoot 'PSMSBuildHelper.proj') `
                                        -TargetName 'PSMSBuildHelper_EvaluateProperties' `
                                        -MSBuildExecutablePath $MSBuildExecutablePath `
                                        -Properties $msbuildProperties

  [system.collections.generic.dictionary[[string],[string]]] $evaluatedProperties = [system.collections.generic.dictionary[[string],[string]]]::new()
  $msbuildResultLines | ? { $_ } | %{
    $property = Get-PropertyFromMSBuildResultLine -Line $_

    $evaluatedProperties[$property.Key] = $property.Value
  }

  if ($OnlyValues) {
    return $PropertyName | % { $evaluatedProperties[$_] }
  }

  return [hashtable]::new($evaluatedProperties)
}

#endregion

#region Exports

Export-ModuleMember -Function Get-EvaluatedMSBuildProperty, Get-LatestMSBuildExecutablePath

#endregion