PSIni.psm1

#region Configuration
$script:NoSection = "_"
$script:CommentPrefix = "__Comment"
function Export-Ini {
    <#
    .Synopsis
        Write hash content to INI file
 
    .Description
        Write hash content to INI file
 
    .Inputs
        System.String
        System.Collections.IDictionary
 
    .Example
        Export-Ini $IniVar "C:\myinifile.ini"
        -----------
        Description
        Saves the content of the $IniVar Hashtable to the INI File c:\myinifile.ini
 
    .Example
        $IniVar | Export-Ini "C:\myinifile.ini" -Force
        -----------
        Description
        Saves the content of the $IniVar Hashtable to the INI File c:\myinifile.ini and overwrites the file if it is already present
 
    .Example
        $file = Export-Ini $IniVar -FilePath "C:\myinifile.ini" -PassThru
        -----------
        Description
        Saves the content of the $IniVar Hashtable to the INI File c:\myinifile.ini and saves the file into $file. Writes exported data to console, as a powershell object.
 
    .Example
        $Category1 = @{"Key1"="Value1";"Key2"="Value2"}
        $Category2 = @{"Key1"="Value1";"Key2"="Value2"}
        $NewINIContent = @{"Category1"=$Category1;"Category2"=$Category2}
        Export-Ini -InputObject $NewINIContent -FilePath "C:\MyNewFile.ini"
        -----------
        Description
        Creating a custom Hashtable and saving it to C:\MyNewFile.ini
 
    .Example
        $Winpeshl = @{
            LaunchApp = @{
                AppPath = %"SYSTEMDRIVE%\Fabrikam\shell.exe"
            }
            LaunchApps = @{
                "%SYSTEMDRIVE%\Fabrikam\app1.exe" = $null
                '%SYSTEMDRIVE%\Fabrikam\app2.exe, /s "C:\Program Files\App3"' = $null
            }
        }
        Export-Ini -InputObject $Winpeshl -FilePath "winpeshl.ini" -SkipTrailingEqualSign
        -----------
        Description
        Example as per https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/winpeshlini-reference-launching-an-app-when-winpe-starts
 
    .Link
        Import-Ini
        ConvertFrom-Ini
        ConvertTo-Ini
    #>


    [CmdletBinding( SupportsShouldProcess )]
    [OutputType( [Void] )]
    param(
        # Specifies the Hashtable to be written to the file.
        # Enter a variable that contains the objects or type a command or expression that gets the objects.
        [Parameter( Mandatory, ValueFromPipeline )]
        [System.Collections.IDictionary]
        $InputObject,

        # Specifies the path to the output file.
        [Parameter( Mandatory, Position = 0, ParameterSetName = "Path") ]
        [ValidateScript( { Invoke-ConditionalParameterValidationPath -InputObject $_ } )]
        [Alias( "Path" )]
        [String]
        $FilePath,

        # Specifies the path to the output file.
        # The LiteralPath parameter is used exactly as it's typed.
        # Wildcard characters aren't accepted.
        # If the path includes escape characters, enclose it in single quotation marks.
        # Single quotation marks tell PowerShell not to interpret any characters as escape sequences.
        # For more information, see about_Quoting_Rules.
        [Parameter( Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = "LiteralPath" )]
        [Alias( "PSPath", "LP" )]
        [String]
        $LiteralPath,

        # Adds the output to the end of an existing file, instead of replacing the file contents.
        [Switch]
        $Append,

        # Specifies the file encoding.
        # The default is UTF8.
        # The supported values are system dependent and can be listed with:
        # `(Get-Help -Name Out-File).parameters.parameter | ? name -eq Encoding`
        [Parameter()]
        [ValidateScript( { Invoke-ConditionalParameterValidationEncoding -InputObject $_ } )]
        [String]
        $Encoding = "UTF8",

        # Allows the cmdlet to overwrite an existing read-only file.
        # Even using the Force parameter, the cmdlet cannot override security restrictions.
        [Parameter()]
        [Switch]
        $Force,

        # NoClobber prevents an existing file from being overwritten and displays a message
        # that the file already exists.
        # By default, if a file exists in the specified path, it will be overwritten without warning.
        [Parameter()]
        [Alias( "NoOverwrite" )]
        [Switch]
        $NoClobber,

        # Specifies the character used to indicate a comment.
        [Parameter()]
        [String]
        $CommentChar = ";",

        # Determines the format of how to write the file.
        #
        # The following values are supported:
        # - pretty: will write the file with an empty line between sections and whitespaces around the `=` sign
        # - minified: will write the file in as few characters as possible
        [Parameter()]
        [ValidateSet("pretty", "minified")]
        [String]
        $Format = "pretty",

        # Will not write comments to the output file
        [Parameter()]
        [Switch]
        $IgnoreComments,

        # Does not add trailing = sign to keys without value.
        # This behavior is needed for specific OS files, such as:
        # https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/winpeshlini-reference-launching-an-app-when-winpe-starts
        [Parameter()]
        [Switch]
        $SkipTrailingEqualSign
    )

    begin {
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Function started"

        $delimiter = if ($Format -eq "pretty") { ' = ' } else { '=' }

        $fileParameters = @{
            Encoding = $Encoding
            Path     = $Path
            Force    = $Force
        }
        Write-DebugMessage "Using the following parameters when writing to file:"
        Write-DebugMessage ($fileParameters | Out-String)
    }

    process {
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Creating file content in memory"
        $fileContent = @()

        foreach ($section in $InputObject.Keys) {
            Write-Verbose "$($MyInvocation.MyCommand.Name):: Writing Section: [$section]"

            # Add section header to the content array
            # Note: this relies on an OrderedDictionary for the keys without a section to be at the top of the file
            if ($section -ne $script:NoSection) {
                $fileContent += "[$section]"
            }

            $outKeyParam = @{
                InputObject           = $InputObject[$section]
                Delimiter             = $delimiter
                IgnoreComments        = $IgnoreComments
                CommentChar           = $CommentChar
                SkipTrailingEqualSign = $SkipTrailingEqualSign
            }
            $fileContent += Out-Key @outKeyParam

            # TODO: what when the Input is only a simple hash?

            # Separate Sections with whiteSpace
            if ($Format -eq "pretty") { $fileContent += "" }
        }

        Write-Verbose "$($MyInvocation.MyCommand.Name):: Writing to file: $Path"
        $ofsplat = @{
            InputObject = $fileContent
            NoClobber   = $NoClobber
            Append      = $Append
            Encoding    = $Encoding
        }
        if ($LiteralPath) {
            if ($PSCmdlet.ShouldProcess((Split-Path $LiteralPath -Leaf), "Write")) {
                Out-File @ofsplat -LiteralPath $LiteralPath
            }
        }
        else {
            if ($PSCmdlet.ShouldProcess((Split-Path $FilePath -Leaf), "Write")) {
                Out-File @ofsplat -FilePath $FilePath
            }
        }
    }

    end {
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Function ended"
    }
}

Set-Alias epini Export-Ini

Register-ArgumentCompleter -CommandName Export-Ini -ParameterName Encoding -ScriptBlock {
    Get-AllowedEncoding |
        Where-Object { $_ -like "$wordToComplete*" } |
        ForEach-Object {
            [System.Management.Automation.CompletionResult]::new(
                $_,
                $_,
                [System.Management.Automation.CompletionResultType]::ParameterValue,
                $_
            )
        }
}

function Import-Ini {
    <#
    .Synopsis
        Gets the content of an INI file
 
    .Description
        Gets the content of an INI file and returns it as a hashtable
 
    .Inputs
        System.String
 
    .Outputs
        System.Collections.Specialized.OrderedDictionary
 
    .Example
        $FileContent = Import-Ini "C:\myinifile.ini"
        -----------
        Description
        Saves the content of the c:\myinifile.ini in a hashtable called $FileContent
 
    .Example
        $inifilepath | $FileContent = Import-Ini
        -----------
        Description
        Gets the content of the ini file passed through the pipe into a hashtable called $FileContent
 
    .Example
        C:\PS>$FileContent = Import-Ini "c:\settings.ini"
        C:\PS>$FileContent["Section"]["Key"]
        -----------
        Description
        Returns the key "Key" of the section "Section" from the C:\settings.ini file
 
    .Link
        Export-Ini
        ConvertFrom-Ini
        ConvertTo-Ini
    #>


    [CmdletBinding()]
    [OutputType( [System.Collections.Specialized.OrderedDictionary] )]
    param(
        # Specifies the path to an item.
        # This cmdlet gets the item at the specified location.
        # Wildcard characters are permitted.
        # This parameter is required, but the parameter name Path is optional.
        #
        # Use a dot (`.`) to specify the current location. Use the wildcard character (`*`) to specify all the items in the current location.
        [Parameter( Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = "Path", Position = 0 )]
        [ValidateNotNullOrEmpty()]
        [Alias("PSPath", "FullName")]
        [String[]]
        $Path,

        # Specifies a path to one or more locations.
        # The value of LiteralPath is used exactly as it's typed.
        # No characters are interpreted as wildcards.
        # If the path includes escape characters, enclose it in single quotation marks.
        # Single quotation marks tell PowerShell not to interpret any characters as escape sequences.
        #
        # For more information, see about_Quoting_Rules
        [Parameter( Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = "LiteralPath" )]
        [ValidateNotNullOrEmpty()]
        [String[]]
        $LiteralPath,

        # The string representation of the INI file.
        [Parameter( Mandatory, ParameterSetName = "String" )]
        [ValidateNotNullOrEmpty()]
        [String]
        $InputString,

        # Specifies the file encoding.
        # The default is UTF8.
        [Parameter( ParameterSetName = "Path" )]
        [Parameter( ParameterSetName = "LiteralPath" )]
        [ValidateNotNullOrEmpty()]
        [System.Text.Encoding]
        $Encoding = [System.Text.Encoding]::UTF8,

        # Specify what characters should be describe a comment.
        # Lines starting with the characters provided will be rendered as comments.
        # Default: ";"
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [Char[]]
        $CommentChar = @(";"),

        # Remove lines determined to be comments from the resulting dictionary.
        [Switch]
        $IgnoreComments,

        # Remove sections without any key
        [Switch]
        $IgnoreEmptySections
    )

    begin {
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Function started"

        $listOfCommentChars = $CommentChar -join ''
        $commentRegex = "^[$listOfCommentChars](.*)$"
        $sectionRegex = "^\s*\[(.+)\]"
        $keyRegex = "^([^$listOfCommentChars]+?)=(.*)$"

        Write-DebugMessage ("commentRegex is $commentRegex")
        Write-DebugMessage ("sectionRegex is $sectionRegex")
        Write-DebugMessage ("keyRegex is $keyRegex")
    }

    process {
        if ($Path) { $Sources = (Resolve-Path $Path) }
        elseif ($LiteralPath) { $Sources = $LiteralPath }
        elseif ($InputString) { $Sources = $InputString }

        foreach ($source in $Sources) {
            if ($LiteralPath -or $Path) {
                Write-Verbose "$($MyInvocation.MyCommand.Name):: Processing file: $source"

                $source = (Get-Item -LiteralPath $source).FullName
                try { $fileContent = [System.IO.File]::ReadAllLines($source, $Encoding) }
                catch {
                    Write-Error "Could not find file '$source'"
                    continue
                }
            }
            else {
                Write-Verbose "$($MyInvocation.MyCommand.Name):: Processing a string"
                $fileContent = $source.split("`n")
            }

            $ini = New-Object System.Collections.Specialized.OrderedDictionary([System.StringComparer]::OrdinalIgnoreCase)
            $section, $name = $null
            $commentCount = 0

            foreach ($line in $fileContent) {
                switch -Regex ($line) {
                    $sectionRegex {
                        $section = $matches[1]
                        Write-Debug "$($MyInvocation.MyCommand.Name):: Adding section : $section"
                        $ini[$section] = New-Object System.Collections.Specialized.OrderedDictionary([System.StringComparer]::OrdinalIgnoreCase)
                        $commentCount = 0
                        continue
                    }
                    $commentRegex {
                        if (-not $IgnoreComments) {
                            if (-not $section) {
                                $section = $script:NoSection
                                $ini[$section] = New-Object System.Collections.Specialized.OrderedDictionary([System.StringComparer]::OrdinalIgnoreCase)
                            }
                            $value = $matches[1].Trim()
                            $commentCount++
                            Write-DebugMessage ("Incremented commentCount is now $commentCount.")
                            $name = "$script:CommentPrefix$commentCount"
                            Write-Debug "$($MyInvocation.MyCommand.Name):: Adding $name with value: $value"
                            $ini[$section][$name] = $value
                        }
                        else {
                            Write-DebugMessage ("Ignoring comment $($matches[1]).")
                        }
                        continue
                    }
                    $keyRegex {
                        if (-not $section) {
                            $section = $script:NoSection
                            $ini[$section] = New-Object System.Collections.Specialized.OrderedDictionary([System.StringComparer]::OrdinalIgnoreCase)
                        }
                        $name, $value = $matches[1].Trim(), $matches[2].Trim()
                        if (-not [string]::IsNullOrWhiteSpace($name)) {
                            Write-Verbose "$($MyInvocation.MyCommand.Name):: Adding key $name with value: $value"
                            if (-not $ini[$section][$name]) {
                                $ini[$section][$name] = $value
                            }
                            else {
                                if ($ini[$section][$name] -is [string]) {
                                    $oldValue = $ini[$section][$name]
                                    $ini[$section][$name] = [System.Collections.ArrayList]::new()
                                    $null = $ini[$section][$name].Add($oldValue)
                                }
                                $null = $ini[$section][$name].Add($value)
                            }
                        }
                        continue
                    }
                    Default {
                        # As seen in https://github.com/lipkau/PSIni/issues/65, some software write keys without the `=` sign.
                        if (-not $section) {
                            $section = $script:NoSection
                            $ini[$section] = New-Object System.Collections.Specialized.OrderedDictionary([System.StringComparer]::OrdinalIgnoreCase)
                        }
                        $name = $_.Trim()
                        if (-not [string]::IsNullOrWhiteSpace($name)) {
                            Write-Verbose "$($MyInvocation.MyCommand.Name):: Adding key $name without a value"
                            $ini[$section][$name] = $null
                        }
                        continue
                    }
                }
            }

            if ($IgnoreEmptySections) {
                $ToRemove = [System.Collections.ArrayList]@()
                foreach ($Section in $ini.Keys) {
                    if (($ini[$Section]).Count -eq 0) {
                        $null = $ToRemove.Add($Section)
                    }
                }
                foreach ($Section in $ToRemove) {
                    Write-Verbose "$($MyInvocation.MyCommand.Name):: Removing empty section $Section"
                    $null = $ini.Remove($Section)
                }
            }

            $ini
        }
    }

    end {
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Function ended"
    }
}

Set-Alias ipini Import-Ini

function Get-AllowedEncoding {
    $command = Get-Command -Name Out-File

    if ($PSVersionTable.PSVersion.Major -ge 6) {
        (
            $command.Parameters['Encoding'].Attributes |
                Where-Object { $_ -is [ArgumentCompletions] }
        )[0].CompleteArgument('Out-File', 'Encoding', '*', $null, @{ }).CompletionText
    }
    else {
        (
            $command.Parameters['Encoding'].Attributes |
                Where-Object { $_.TypeId -eq [ValidateSet] }
        )[0].ValidValues
    }
}

function Invoke-ConditionalParameterValidationEncoding {
    param( [String] $InputObject )

    $allowedEncodings = Get-AllowedEncoding

    if ($InputObject -notin $allowedEncodings) {
        $errorItem = [System.Management.Automation.ErrorRecord]::new(
            ([System.ArgumentException]"Invalid Encoding"),
            'InvalidEncoding',
            [System.Management.Automation.ErrorCategory]::InvalidType,
            $InputObject
        )
        $errorItem.ErrorDetails = "Cannot validate argument on parameter 'Encoding'. The argument `"$InputObject`" does not belong to the set `"$($allowedEncodings -join ", ")`" specified by the ValidateSet attribute. Supply an argument that is in the set and then try the command again."
        $PSCmdlet.ThrowTerminatingError($errorItem)
    }

    return $true
}

function Invoke-ConditionalParameterValidationPath {
    param(
        $InputObject
    )

    if (-not (Test-Path $InputObject -IsValid)) {
        $errorItem = [System.Management.Automation.ErrorRecord]::new(
            ([System.ArgumentException]"Path not found"),
            'ParameterValue.FileNotFound',
            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
            $InputObject
        )
        $errorItem.ErrorDetails = "Invalid path '$InputObject'."
        $PSCmdlet.ThrowTerminatingError($errorItem)
    }
    else {
        return $true
    }
}

function Out-Key {
    param(
        [Parameter( Mandatory )]
        [Char]
        $CommentChar,

        [Parameter( Mandatory )]
        [String]
        $Delimiter,

        [Parameter( ValueFromPipeline )]
        [System.Collections.IDictionary]
        $InputObject,

        [Parameter()]
        [Switch]
        $IgnoreComments,

        [Parameter()]
        [Switch]
        $SkipTrailingEqualSign
    )

    begin {
        $outputLines = @()
    }

    process {
        if (-not ($InputObject.Keys)) {
            Write-Verbose "$($MyInvocation.MyCommand.Name):: No data found in '$InputObject'."
            return
        }

        foreach ($key in $InputObject.Keys) {
            if ($key -like "$script:CommentPrefix*") {
                if ($IgnoreComments) {
                    Write-Verbose "$($MyInvocation.MyCommand.Name):: Skipping comment: $key"
                }
                else {
                    Write-Verbose "$($MyInvocation.MyCommand.Name):: Writing comment: $key"
                    $outputLines += "$CommentChar$($InputObject[$key])"
                }
            }
            elseif (-not $InputObject[$key]) {
                Write-Verbose "$($MyInvocation.MyCommand.Name):: Writing key: $key without value"
                $outputLines += if ($SkipTrailingEqualSign) { "$key" } else { "${key}${Delimiter}" }
            }
            else {
                foreach ($entry in $InputObject[$key]) {
                    Write-Verbose "$($MyInvocation.MyCommand.Name):: Writing key: $key"
                    $outputLines += "${key}${Delimiter}${entry}"
                }
            }
        }
    }

    end {
        return $outputLines
    }
}

function Write-DebugMessage {
    [CmdletBinding()]
    param(
        [Parameter( ValueFromPipeline )]
        $Message
    )

    begin {
        $oldDebugPreference = $DebugPreference
        if (!($DebugPreference -eq "SilentlyContinue")) {
            $DebugPreference = 'Continue'
        }
    }

    process {
        Write-Debug $Message
    }

    end {
        $DebugPreference = $oldDebugPreference
    }
}