VisualStudio-scripts.ps1

# constants
$script:VSDEVCMD_PATH = "Common7\Tools\VsDevCmd.bat";
$script:VS_INSTANCES_DIR = "$env:ProgramData\Microsoft\VisualStudio\Packages\_Instances";
$script:CONFIG_DIR = "$env:USERPROFILE\.posh-vsdev";
$script:CACHE_PATH = "$script:CONFIG_DIR\instances.json";

$script:VisualStudioVersions = $null;   # In-memory cache of instances
$script:HasChanges = $false;            # Indicates whether the in-memory cache has changes

# simplifies access to HashSet<string>
class Set : System.Collections.Generic.HashSet[string] {
    Set() { }
    Set([string[]] $Data) {
        foreach($local:Item in $Data) {
            $this.Add($local:Item);
        }
    }
}

class Env : System.Collections.Generic.Dictionary[string,string] {
    hidden static [Env] $_Default;

    Env() {}

    hidden Env([Env] $Other) {
        if ($Other) {
            foreach ($local:Entry in $Other.GetEnumerator()) {
                $this[$local:Entry.Key] = $local:Entry.Value;
            }
        }
    }

    static [Env] GetDefault() {
        if ([Env]::_Default -eq $null) {
            [Env]::_Default = [Env]::GetCurrent();
        }
        return [Env]::_Default;
    }

    static [Env] GetCurrent() {
        $local:Env = [Env]::new();
        foreach($local:Item in Get-ChildItem "ENV:\") {
            $local:Env[$local:Item.Name] = $local:Item.Value;
        }
        return $local:Env;
    }

    [string] get_Item([string] $Key) {
        $Value = $null;
        [void]($this.TryGetValue($Key, [ref]$Value));
        return $Value;
    }

    [void] Apply() {
        [void]([Env]::GetDefault());
        $local:Current = [Env]::GetCurrent();
        foreach ($local:Item in $local:Current.GetEnumerator()) {
            if (-not $this.ContainsKey($local:Item.Key)) {
                script:SetEnvironmentVariable $local:Item.Key $null;
            }
        }
        foreach ($local:Item in $this.GetEnumerator()) {
            script:SetEnvironmentVariable $local:Item.Key $local:Item.Value;
        }
    }

    [Env] Clone() {
        return [Env]::new($this);
    }
}

class PathsDiff {
    hidden [string[]] $Added;
    hidden [string[]] $Removed;
    hidden [Set] $RemovedSet;

    hidden PathsDiff([string[]] $Added, [string[]] $Removed) {
        $this.Added = @() + $Added;
        $this.Removed = @() + $Removed;
        $this.RemovedSet = [Set]::new($Removed);
    }

    static [PathsDiff] FromObject([psobject] $Object) {
        if ($Object -eq $null) { return $null; }
        if ($Object -is [PathsDiff]) { return $Object; }
        return [PathsDiff]::new($Object.Added, $Object.Removed);
    }

    static [psobject] ToObject([PathsDiff] $Object) {
        if ($Object -eq $null) { return $null; }
        return @{
            Added = @() + $Object.Added;
            Removed = @() + $Object.Removed;
        };
    }

    static [PathsDiff] DiffBetween([string[]] $OldPaths, [string[]] $NewPaths) {
        [Set] $local:OldSet = [Set]::new($OldPaths);
        [Set] $local:NewSet = [Set]::new($NewPaths);
        [string[]] $local:Added = @();
        [string[]] $local:Removed = @();
        foreach ($local:Path in $NewSet.GetEnumerator()) {
            if (-not $OldSet.Contains($local:Path)) {
                $local:Added += $local:Path;
            }
        }
        foreach ($local:Path in $OldSet.GetEnumerator()) {
            if (-not $NewSet.Contains($local:Path)) {
                $local:Removed += $local:Path;
            }
        }
        return [PathsDiff]::new($local:Added, $local:Removed);
    }

    [string] Apply([string] $Path) {
        return $this.ApplyToPaths($Path -split ";") -join ";";
    }

    [string[]] Apply([string[]] $Paths) {
        return $this.ApplyToPaths($Paths);
    }

    [PathsDiff] Clone() {
        return [PathsDiff]::new(
            $this.Added,
            $this.Removed
        );
    }

    hidden [string[]] ApplyToPaths([string[]] $Paths) {
        $local:Result = @();
        foreach ($local:Path in $Paths) {
            if ($local:Path -and $local:Path.Trim() -and -not $this.RemovedSet.Contains($local:Path)) {
                $local:Result += $local:Path;
            }
        }
        foreach ($local:Path in $this.Added) {
            if ($local:Path -and $local:Path.Trim()) {
                $local:Result += $local:Path;
            }
        }
        return $local:Result;
    }
}

class Diff : System.Collections.Generic.Dictionary[string,psobject] {
    Diff() { }

    hidden Diff([Diff] $Other) {
        if ($Other) {
            foreach ($local:Entry in $Other.GetEnumerator()) {
                $local:Key = $local:Entry.Key;
                $local:Value = $local:Entry.Value;
                if ($local:Key -ieq "Path" -and $local:Value -is [PathsDiff]) {
                    $local:Value = $local:Value.Clone();
                }
                $this[$local:Key] = $local:Value;
            }
        }
    }

    static [Diff] FromObject([psobject] $Object) {
        if ($Object -eq $null) { return $null; }
        if ($Object -is [Diff]) { return $Object; }
        $Object = script:ConvertToHashTable $Object;
        [Diff] $local:Changes = [Diff]::new();
        foreach ($local:Entry in $Object.GetEnumerator()) {
            $local:Key = $local:Entry.Key;
            $local:Value = $local:Entry.Value;
            if ($local:Key -ieq "Path") {
                $local:Value = [PathsDiff]::FromObject($local:Value);
            }
            $local:Changes[$local:Key] = $local:Value;
        }
        return $local:Changes;
    }

    static [psobject] ToObject([Diff] $Object) {
        if ($Object -eq $null) { return $null; }
        $local:Changes = @{};
        foreach ($local:Entry in $Object.GetEnumerator()) {
            $local:Key = $local:Entry.Key;
            $local:Value = $local:Entry.Value;
            if ($local:Key -ieq "Path") {
                $local:Value = [PathsDiff]::ToObject($local:Value);
            }
            $local:Changes[$local:Key] = $local:Value;
        }
        return $local:Changes;
    }

    static [Diff] DiffBetween([Env] $OldEnv, [Env] $NewEnv) {
        [Diff] $local:Changes = [Diff]::new();
        foreach ($local:Entry in $OldEnv.GetEnumerator()) {
            if (-not $NewEnv.ContainsKey($local:Entry.Key)) {
                $local:Changes[$local:Entry.Key] = $null;
            }
        }
        foreach ($local:Entry in $NewEnv.GetEnumerator()) {
            $local:Key = $local:Entry.Key;
            $local:Value = $local:Entry.Value;
            $local:OldValue = $OldEnv[$local:Key];
            if ($local:Value -ne $local:OldValue) {
                if ($local:Key -ieq "Path") {
                    $local:Value = [PathsDiff]::DiffBetween($local:OldValue, $local:Value);
                }
                $local:Changes[$local:Key] = $local:Value;
            }
        }
        return $local:Changes;
    }

    [psobject] get_Item([string] $Key) {
        [psobject] $Value = $null;
        [void]($this.TryGetValue($Key, [ref]$Value));
        return $Value;
    }

    [void] set_Item([string] $Key, [psobject] $Value) {
        if (-not $this.ValidateKeyValue($Key, $Value)) { return; }
        ([System.Collections.Generic.Dictionary[string, psobject]]$this)[$Key] = $Value;
    }

    [void] Add([string] $Key, [psobject] $Value) {
        if (-not $this.ValidateKeyValue($Key, $Value)) { return; }
        [void](([System.Collections.Generic.Dictionary[string, psobject]]$this).Add($Key, $Value));
    }

    [Env] Apply([Env]$Env) {
        [Env] $local:NewEnv = $Env.Clone();
        foreach ($local:Entry in $this.GetEnumerator()) {
            $local:Key = $local:Entry.Key;
            $local:Value = $local:Entry.Value;
            if ($local:Value -is [PathsDiff]) {
                $local:Value = $local:Value.Apply($Env[$local:Key]);
            }
            if ($local:Value) {
                $local:NewEnv[$local:Key] = $local:Value;
            }
            else {
                $local:NewEnv.Remove($local:Key);
            }
        }
        return $local:NewEnv;
    }

    [Diff] Clone() {
        return [Diff]::new($this);
    }

    hidden [bool] ValidateKeyValue([string] $Key, [psobject] $Value) {
        if (($Key -ieq "Path") -and -not ($Value -eq $null -or $Value -is [PathsDiff])) {
            throw [System.ArgumentException]::new("Invalid argument: Value");
            return $false;
        }
        if (($Key -ine "Path") -and -not ($Value -eq $null -or $Value -is [string])) {
            throw [System.ArgumentException]::new("Invalid argument: Value");
            return $false;
        }
        return $true;
    }
}

class Instance {
    [string] $Name;
    [string] $Channel;
    [string] $Version;
    [string] $Path;
    hidden [Diff] $Env;

    Instance([string] $Name, [string] $Channel, [string] $Version, [string] $Path, [Diff] $Env) {
        $this.Name = $Name;
        $this.Channel = $Channel;
        $this.Version = $Version;
        $this.Path = $Path;
        $this.Env = $Env;
    }

    hidden Instance([Instance] $Other) {
        if ($Other) {
            $this.Name = $Other.Name;
            $this.Channel = $Other.Channel;
            $this.Version = $Other.Version;
            $this.Path = $Other.Path;
            $this.Env = if ($Other.Env) { $Other.Env.Clone(); }
        }
    }

    static [Instance] FromObject([psobject] $Object) {
        if ($Object -eq $null) { return $null; }
        if ($Object -is [Instance]) { return $Object; }
        return [Instance]::new(
            $Object.Name,
            $Object.Channel,
            $Object.Version,
            $Object.Path,
            [Diff]::FromObject($Object.Env)
        );
    }

    static [psobject] ToObject([Instance] $Object) {
        if ($Object -eq $null) { return $null; }
        return @{
            Name = $Object.Name;
            Channel = $Object.Channel;
            Version = $Object.Version;
            Path = $Object.Path;
            Env = [Diff]::ToObject($Object.Env);
        };
    }

    [Diff] GetEnvironment() {
        if ($this.Env -eq $null) {
            $local:CurrentEnv = [Env]::GetCurrent();
            $local:DefaultEnvironment = [Env]::GetDefault();
            $local:DefaultEnvironment.Apply();
            $local:Env = [Env]::GetCurrent();
            $local:CommandPath = Join-Path $this.Path $script:VSDEVCMD_PATH;
            $local:Command = '"' + ($local:CommandPath) + '"&set';
            cmd /c $local:Command | ForEach-Object {
                if ($_ -match "^(.*?)=(.*)$") {
                    $local:Key = $Matches[1];
                    $local:Value = $Matches[2];
                    $local:Env[$local:Key] = $local:Value;
                }
            }
            $this.Env = [Diff]::DiffBetween($local:DefaultEnvironment, $local:Env);
            $local:CurrentEnv.Apply();
            $script:HasChanges = $true;
        }
        return $this.Env;
    }

    [void] Apply() {
        $local:Default = [Env]::GetDefault();
        $local:Diff = $this.GetEnvironment();
        $local:Env = $local:Diff.Apply($local:Default);
        $local:Env.Apply();
    }

    [Instance] Clone() {
        return [Instance]::new($this);
    }

    [void] Save() {
        $script:HasChanges = $true;
        script:SaveChanges;
    }
}

function script:ConvertToHashTable([psobject] $Object) {
    if ($Object -eq $null) { return $null; }
    if ($Object -is [hashtable]) { return $Object };
    $local:Table = @{};
    foreach ($local:Key in $Object | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name) {
        $local:Value = $Object | Select-Object -ExpandProperty $local:Key;
        $local:Table[$local:Key] = $local:Value;
    }
    return $local:Table;
}

function script:SetEnvironmentVariable([string] $Key, [string] $Value) {
    if ($Value -ne $null) {
        [void](Set-Item -Force "ENV:\$Key" -Value $Value);
    }
    else {
        [void](Remove-Item -Force "ENV:\$Key");
    }
}

function script:PopulateVisualStudioVersionsFromCache() {
    if ($script:VisualStudioVersions -eq $null) {
        if (Test-Path $script:CACHE_PATH) {
            $script:VisualStudioVersions = (Get-Content $script:CACHE_PATH | ConvertFrom-Json) `
                | ForEach-Object {
                    [Instance]::FromObject($_);
                };
        }
    }
}

function script:PopulateVisualStudioVersions() {
    if ($script:VisualStudioVersions -eq $null) {
        # Add Legacy instances
        $script:VisualStudioVersions = Get-ChildItem ${env:ProgramFiles(x86)} `
            | Where-Object -Property Name -Match "Microsoft Visual Studio (\d+.0)" `
            | ForEach-Object {
                [Instance]::new(
                    $Matches[0],
                    "Release",
                    $Matches[1],
                    $_.FullName,
                    $null
                );
            };

        # Add Dev15+ instances
        if (Test-Path $script:VS_INSTANCES_DIR) {
            $script:VisualStudioVersions += Get-ChildItem $script:VS_INSTANCES_DIR `
                | ForEach-Object {
                    $local:StatePath = Join-Path $_.FullName "state.json";
                    $local:State = Get-Content $local:StatePath | ConvertFrom-Json;
                    [Instance]::new(
                        $local:State.installationName,
                        $local:State.channelId,
                        $local:State.installationVersion,
                        $local:State.installationPath,
                        $null
                    );
                };
        }

        # Sort by version descending and remove versions that don't exist
        $script:VisualStudioVersions = $script:VisualStudioVersions `
            | Sort-Object -Property Version -Descending `
            | Where-Object { Test-Path (Join-Path $_.Path $script:VSDEVCMD_PATH) };

        if ($script:VisualStudioVersions) {
            $script:HasChanges = $true;
        }
    }
}

function script:SaveChanges() {
    if ($script:HasChanges -and $script:VisualStudioVersions) {
        $local:Content = $script:VisualStudioVersions `
            | ForEach-Object {
                [Instance]::ToObject($_);
            } `
            | ConvertTo-Json;
        if ($script:VisualStudioVersions.Length -eq 1) {
            $local:Content = "[" + $local:Content + "]";
        }
        $local:CacheDir = Split-Path $script:CACHE_PATH -Parent;
        if (-not (Test-Path $local:CacheDir)) {
            [void](mkdir $local:CacheDir -ErrorAction:SilentlyContinue);
        }

        $local:Content | Out-File $script:CACHE_PATH;
        $script:HasChanges = $false;
    }
}

function Get-VisualStudioVersion([string] $Name, [string] $Channel, [string] $Version) {
    script:PopulateVisualStudioVersionsFromCache;
    script:PopulateVisualStudioVersions;
    $local:Versions = $script:VisualStudioVersions;
    if ($Name) {
        $local:Versions = $local:Versions | Where-Object -Property Name -Like $Name;
    }
    if ($Channel) {
        $local:Versions = $local:Versions | Where-Object -Property Channel -Like $Channel;
    }
    if ($Version) {
        $local:Versions = $local:Versions | Where-Object -Property Version -Like $Version;
    }
    $local:Versions;
    script:SaveChanges;
}

function Use-VisualStudioEnvironment {
    [CmdletBinding()]
    param (
        [Parameter(ParameterSetName = "Match")]
        [string] $Name,
        [Parameter(ParameterSetName = "Match")]
        [string] $Channel,
        [Parameter(ParameterSetName = "Match")]
        [version] $Version,
        [Parameter(ParameterSetName = "Pipeline", Position = 0, ValueFromPipeline = $true, Mandatory = $true)]
        [psobject] $InputObject
    );

    [void]([Env]::GetDefault());
    [Instance] $local:VisualStudioVersion = $null;
    if ($InputObject) {
        $local:VisualStudioVersion = [Instance]::FromObject($InputObject);
    } else {
        $local:VisualStudioVersion = Get-VisualStudioVersion -Name:$Name -Channel:$Channel -Version:$Version | Select-Object -First:1;
    }

    if ($local:VisualStudioVersion) {
        $local:VisualStudioVersion.Apply();
        script:SaveChanges;
        Write-Host "Using Development Environment from '$($local:VisualStudioVersion.Name)'." -ForegroundColor:Gray;
        $global:VisualStudioVersion = $local:VisualStudioVersion;
    }
    else {
        [string] $local:Message = "Could not find Visual Studio";
        [string[]] $local:MessageParts = @();
        if ($Name) { $local:MessageParts += "Name='$Name'"; }
        if ($Channel) { $local:MessageParts += "Channel='$Channel'"; }
        if ($Version) { $local:MessageParts += "Version='$Version'"; }
        if ($local:MessageParts.Length > 0) {
            $local:Message += "for " + $local:MessageParts[0];
            if ($local:MessageParts.Length -eq 2) {
            }
            elseif ($local:MessageParts.Length -gt 2) {
                for ($local:I = 1; $local:I -lt $local:MessageParts.Length - 1; $local:I++) {
                    $local:Message += ", " + $local:MessageParts[$local:I];
                }
                if ($local:MessageParts.Length > 2) {
                    $local:Message += ", and " + $local:MessageParts[$local:MessageParts.Length - 1];
                }
            }
        }
        $local:Message += ".";
        Write-Warning $local:Message;
    }
}

function Reset-VisualStudioEnvironment {
    $global:VisualStudioVersion = $null;
    [Env]::GetDefault().Apply();
}

function Reset-VisualStudioVersionCache() {
    $script:VisualStudioVersions = $null;
    if (Test-Path $script:CACHE_PATH) {
        [void](Remove-Item $script:CACHE_PATH -Force);
    }
}

function script:HasProfile([string] $ProfilePath) {
    if (-not $ProfilePath) { return $false; }
    if (-not (Test-Path -LiteralPath $ProfilePath)) { return $false; }
    return $true;
}

function script:IsInProfile([string] $ProfilePath) {
    if (-not (script:HasProfile $ProfilePath)) { return $false; }
    $local:Content = Get-Content $ProfilePath -ErrorAction:SilentlyContinue;
    if ($local:Content -match "posh-vsdev") { return $true; }
    return $false;
}

function script:IsUsingEnvironment([string] $ProfilePath) {
    if (-not (script:HasProfile $ProfilePath)) { return $false; }
    $local:Content = Get-Content $ProfilePath -ErrorAction:SilentlyContinue;
    if ($local:Content -match "Use-VisualStudioEnvironment") { return $true; }
    return $false;
}

function script:IsProfileSigned([string] $ProfilePath) {
    if (-not (script:HasProfile $ProfilePath)) { return $false; }
    $local:Sig = Get-AuthenticodeSignature $ProfilePath;
    if (-not $local:Sig) { return $false; }
    if (-not $local:Sig.SignerCertificate) { return $false; }
    return $true;
}

function script:IsInModulePaths() {
    foreach ($local:Path in $env:PSModulePath -split ";") {
        if (-not $local:Path.EndsWith("\")) { $local:Path += "\"; }
        if ($PSScriptRoot.StartsWith($local:Path, [System.StringComparison]::InvariantCultureIgnoreCase)) {
            return $true;
        }
    }
    return $false;
}

function Add-VisualStudioEnvironmentToProfile([switch] $AllHosts, [switch] $UseEnvironment) {
    $local:ProfilePath = if ($AllHosts) { $profile.CurrentUserAllHosts; } else { $profile.CurrentUserCurrentHost; }
    $local:IsInProfile = script:IsInProfile $local:ProfilePath;
    $local:IsUsingEnvironment = script:IsUsingEnvironment $local:ProfilePath;
    if ($local:IsInProfile -and -not $UseEnvironment) {
        Write-Warning "'posh-vsdev' is already installed.";
        return;
    }
    if ($local:IsUsingEnvironment -and $UseEnvironment) {
        Write-Warning "'posh-vsdev' is already using a VisualStudio environment.";
        return;
    }
    if (script:IsProfileSigned $local:ProfilePath) {
        Write-Warning "Cannot modify signed profile.";
        return;
    }
    if (-not $local:IsInProfile) {
        if (-not (script:HasProfile $local:ProfilePath)) {
            $local:ProfileDir = Split-Path $local:ProfilePath -Parent;
            if (-not (Test-Path -LiteralPath:$local:ProfileDir)) {
                [void](mkdir $local:ProfileDir -ErrorAction:SilentlyContinue);
            }
        }
        if (script:IsInModulePaths) {
            Add-Content -LiteralPath:$local:ProfilePath -Value "Import-Module posh-vsdev;" -Encoding UTF8;
        }
        else {
            Add-Content -LiteralPath:$local:ProfilePath -Value "Import-Module `"$PSScriptRoot\posh-vsdev.psd1`";" -Encoding UTF8;
        }
    }
    if (-not $local:IsUsingEnvironment -and $UseEnvironment) {
        Add-Content -LiteralPath:$local:ProfilePath -Value "Use-VisualStudioEnvironment;" -Encoding UTF8;
    }
}

[void]([Env]::GetDefault());