Indented.IniFile.psm1

using namespace System.Collections.Generic
using namespace System.IO
using namespace System.Management.Automation
using namespace System.Text

enum Ensure {
    Absent
    Present
}

[DscResource()]
class IniFileItem {
    [DscProperty()]
    [Ensure]$Ensure = 'Present'

    [DscProperty(Key)]
    [String]$Name

    [DscProperty()]
    [String]$Section

    [DscProperty()]
    [String]$Value

    [DscProperty()]
    [String]$NewValue

    [DscProperty(Key)]
    [String]$Path

    Hidden [Void] InitializeRequest() {
        if (-not (Test-Path $this.Path)) {
            throw 'The INI file, {0}, does not exist' -f $this.Path
        }
    }

    Hidden [Hashtable] GetParams() {
        $params = @{
            Name = $this.Name
            Path = $this.Path
        }
        if ($this.Section) {
            $params.Add('Section', $this.Section)
        }
        if ($this.Value) {
            $params.Add('Value', $this.Value)
        }

        return $params
    }

    [IniFileItem] Get() {
        $this.InitializeRequest()
        $params = $this.GetParams()
        $item = Get-IniFileItem @params

        if ($item) {
            $this.Ensure = 'Present'
            $this.NewValue = $item.Value
        } else {
            $this.Ensure = 'Absent'
        }

        return $this
    }

    [Void] Set() {
        $this.InitializeRequest()
        $params = $this.GetParams()
        if ($this.Ensure -eq 'Present') {
            Set-IniFileItem @params -NewValue $this.NewValue
        } elseif ($this.Ensure -eq 'Absent') {
            Remove-IniFileItem @params
        }
    }

    [Boolean] Test() {
        $this.InitializeRequest()
        $params = $this.GetParams()
        $item = Get-IniFileItem @params

        if ($this.Ensure -eq 'Present') {
            if (-not $item) {
                return $false
            }
            if ($item -and $item.Value -ne $this.NewValue) {
                return $false
            }
        } elseif ($this.Ensure -eq 'Absent') {
            if ($item) {
                return $false
            }
        }

        return $true
    }
}

function GetEol {
    <#
    .SYNOPSIS
        Attempt to find the end of line character.
    .DESCRIPTION
        Used to find the end of line character in a file.
    #>


    [CmdletBinding()]
    [OutputType([System.String])]
    param (
        # The path to an ini file.
        [Parameter(Mandatory)]
        [String]$Path
    )

    try {
        $eol = [Environment]::NewLine

        $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path)

        if (-not (Test-Path $Path)) {
            return $eol
        }

        $streamReader = [StreamReader][File]::OpenRead($Path)
        [Char[]]$buffer = [Char[]]::new(100)
        while (-not $streamReader.EndOfStream) {
            $null = $streamReader.Read($buffer, 0, 100)
            $newLineIndex = $buffer.IndexOf([Char]"`n")

            if ($newLineIndex -gt -1) {
                if ($buffer[$newLineIndex - 1] -eq [Char]"`r") {
                    $streamReader.Close()
                    return "`r`n"
                } else {
                    $streamReader.Close()
                    return "`n"
                }
            }
        }
        $streamReader.Close()

        return $eol
    } catch {
        $pscmdlet.ThrowTerminatingError($_)
    }
}

function UpdateString {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [String]$String,

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

        [Int32]$Position = $String.Length,

        [String]$EndOfLine = [Environment]::NewLine
    )

    if ($Position -gt 0) {
        if ($String[$Position - 1] -ne "`n") {
            $Value = '{0}{1}' -f $EndOfLine, $Value
        }
    }
    if ($String.Length -gt $Position) {
        $Value = '{0}{1}' -f $Value, $EndOfLine
    }

    $String.Insert($Position, $Value)
}

function Get-IniFileItem {
    <#
    .SYNOPSIS
        Get an item from an Ini file.
    .DESCRIPTION
        Reads an Ini file, returning matching items.
 
        The ini file items returned by this function include an Extent property which describes the location of an item within the file.
    .EXAMPLE
        Get-IniFileItem -Path somefile.ini
 
        Get all items in somefile.ini.
    .EXAMPLE
        Get-IniFileItem -Section somesection -Path somefile.ini
 
        Get all items within the second "somesection" from somefile.ini.
    .EXAMPLE
        Get-IniFileItem -Name somename -Path somefile.ini
 
        Get all items named somename from any section.
    #>


    [CmdletBinding(DefaultParameterSetName = 'DefaultSearch')]
    [OutputType([System.Management.Automation.PSObject])]
    param (
        # The name of the field to retrieve. The Name parameter supports wildcards. By default, all fields are returned.
        [Parameter(Position = 1)]
        [String]$Name = '*',

        # The section a setting resides within. The Section parameter supports wildcrds. By default, all sections are returned.
        [Parameter(Position = 2)]
        [string]$Section = '*',

        # The path to an ini file.
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [String]$Path,

        # The value may be defined to describe the item. Wildcards are supported.
        [Parameter(Mandatory, ParameterSetName = 'SearchUsingValue')]
        [String]$Value,

        # The literal value may be defined to absolutely describe the item. Wildcards are not supported.
        [Parameter(Mandatory, ParameterSetName = 'SearchUsingLiteralValue')]
        [String]$LiteralValue
    )

    process {
        try {
            $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path)
            $eolLength = (GetEol -Path $Path).Length

            $streamReader = [StreamReader][File]::OpenRead($Path)
            $position = 0
            while (-not $streamReader.EndOfStream) {
                switch -Regex ($streamReader.ReadLine()) {
                    '' {
                        $position += $_.Length
                    }
                    '^;' {
                        break
                    }
                    '^\[([^\]]+)\] *$' {
                        $SectionName = $matches[1]
                        $SectionStart = $position - $_.Length
                        break
                    }
                    '^([^=]+?) *= *(.*)$' {
                        [PSCustomObject]@{
                            Name       = $matches[1]
                            Value      = $matches[2]
                            Section    = $SectionName
                            Extent     = [PSCustomObject]@{
                                SectionStart = $SectionStart
                                ItemStart    = $position - $_.Length
                                ItemEnd      = $position
                                ItemLength   = $_.Length
                                ValueStart   = $position - $matches[2].Length
                                ValueLength  = $matches[2].Length
                            }
                            Path       = $Path
                            PSTypeName = 'IniFileItem'
                        } | Where-Object {
                            $_.Name -like $Name -and
                            $_.Section -like $Section -and
                            ($pscmdlet.ParameterSetName -ne 'SearchUsingValue' -or $_.Value -like $Value) -and
                            ($pscmdlet.ParameterSetName -ne 'SearchUsingLiteralValue' -or $_.Value -like $LiteralValue)
                        }
                        break
                    }
                }

                # End of line character
                $position += $eolLength
            }
        } catch {
            $pscmdlet.WriteError($_)
        } finally {
            if ($streamReader) {
                $streamReader.Close()
            }
        }
    }
}

function Get-IniFileSection {
    <#
    .SYNOPSIS
        Get a section from an Ini file.
    .DESCRIPTION
        Reads an Ini file, returning matching sections.
 
        The ini file sections returned by this function includes an Extent property which describes the location of a section within the file.
    .EXAMPLE
        Get-IniFileItem -Path somefile.ini
 
        Get all sections in somefile.ini.
    .EXAMPLE
        Get-IniFileSection -Name somesection -Path somefile.ini
 
        Get the section named "somesection" from somefile.ini.
    #>


    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject])]
    param (
        # The name of the field to retrieve. The Name parameter supports wildcards. By default, all fields are returned.
        [String]$Name = '*',

        # The path to an ini file.
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [String]$Path
    )

    process {
        try {
            $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path)
            $eolLength = (GetEol -Path $Path).Length

            $streamReader = [StreamReader][File]::OpenRead($Path)

            $Section = [PSCustomObject]@{
                Name   = '<Undefined>'
                Items  = @()
                Extent = [PSCustomObject]@{
                    Start = 0
                    End   = 0
                }
            }

            $position = 0
            while (-not $streamReader.EndOfStream) {
                switch -Regex ($streamReader.ReadLine()) {
                    '' {
                        $position += $_.Length
                    }
                    '^;' {
                        break
                    }
                    '^\[([^\]]+)\] *$' {
                        if ($Section.Name -ne '<Undefined>' -or $Section.Items.Count -gt 0) {
                            $Section.Extent.End = $lastPosition
                            $Section | Where-Object Name -like $Name
                        }

                        $Section = [PSCustomObject]@{
                            Name       = $matches[1]
                            Items      = @()
                            Extent     = [PSCustomObject]@{
                                Start = $position
                                End   = 0
                            }
                            PSTypeName = 'IniFileSection'
                        }
                        break
                    }
                    '^([^=]+?) *= *(.*)$' {
                        $Section.Items += [PSCustomObject]@{
                            Name  = $matches[1]
                            Value = $matches[2]
                        }
                        break
                    }
                }

                $lastPosition = $position
                $position += $eolLength
            }

            $streamReader.Close()

            $Section.Extent.End = $lastPosition
            $Section | Where-Object Name -like $Name
        } catch {
            $pscmdlet.WriteError($_)
        } finally {
            if ($streamReader) {
                $streamReader.Close()
            }
        }
    }
}

function Remove-IniFileItem {
    <#
    .SYNOPSIS
        Get an item from an Ini file.
    .DESCRIPTION
        Reads an Ini file, returning matching items.
    .EXAMPLE
        Remove-IniFileItem -Name somename -Section somesection -Path somefile.ini
 
        Remove somename from the somesection section in somefile.ini.
    .EXAMPLE
        Remove-IniFileItem -Name somename -Path somefile.ini
 
        Remove somename from all sections in somefile.ini.
    .EXAMPLE
        Remove-IniFileItem -Name extension -Value imap -Section PHP
 
        Remove extension, when the value is imap, from the section PHP.
    #>


    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'DefaultSearch')]
    [OutputType([Void])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'FromPipeline')]
        [PSTypeName('IniFileItem')]
        [PSObject]$InputObject,

        # The name of the field to retrieve.
        [Parameter(Mandatory, Position = 1, ParameterSetName = 'DefaultSearch')]
        [Parameter(Mandatory, Position = 1, ParameterSetName = 'SearchUsingValue')]
        [Parameter(Mandatory, Position = 1, ParameterSetName = 'SearchUsingLiteralValue')]
        [String]$Name,

        # The section a setting resides within. If this value is not set the value will be removed from all sections.
        [Parameter(Position = 2, ParameterSetName = 'DefaultSearch')]
        [Parameter(Position = 2, ParameterSetName = 'SearchUsingValue')]
        [Parameter(Position = 2, ParameterSetName = 'SearchUsingLiteralValue')]
        [String]$Section,

        # The path to an ini file.
        [Parameter(Mandatory, Position = 3, ParameterSetName = 'DefaultSearch')]
        [Parameter(Mandatory, Position = 3, ParameterSetName = 'SearchUsingValue')]
        [Parameter(Mandatory, Position = 3, ParameterSetName = 'SearchUsingLiteralValue')]
        [ValidateScript( { Test-Path $_ -PathType Leaf } )]
        [String]$Path,

        # The value may be defined to describe the item. Wildcards are supported.
        [Parameter(Mandatory, ParameterSetName = 'SearchUsingValue')]
        [String]$Value,

        # The literal value may be defined to absolutely describe the item. Wildcards are not supported.
        [Parameter(Mandatory, ParameterSetName = 'SearchUsingLiteralValue')]
        [String]$LiteralValue
    )

    begin {
        $null = $psboundparameters.Remove('WhatIf')

        if ($pscmdlet.ParameterSetName -eq 'FromPipeline') {
            $iniFileItems = [List[PSObject]]::new()
        } else {
            Get-IniFileItem @psboundparameters | Remove-IniFileItem
         }
    }

    process {
        if ($pscmdlet.ParameterSetName -eq 'FromPipeline') {
            $iniFileItems.Add($InputObject)
        }
    }

    end {
        if ($pscmdlet.ParameterSetName -eq 'FromPipeline' -and $iniFileItems.Count -gt 0) {
            foreach ($group in $iniFileItems | Group-Object Path) {
                $iniFilePath = $pscmdlet.GetUnresolvedProviderPathFromPSPath($group.Name)

                $eolLength = (GetEol -Path $iniFilePath).Length

                $streamReader = [StreamReader][File]::OpenRead($iniFilePath)
                $encoding = $streamReader.CurrentEncoding
                $content = $streamReader.ReadToEnd()
                $streamReader.Close()

                $items = $group.Group | Sort-Object { $_.Extent.ItemStart } -Descending
                foreach ($item in $items) {
                    if ($item.Value -ne $Value) {
                        if ($item.Length -gt $item.Extent.ItemStart + $item.Extent.ItemLength + $eolLength) {
                            $itemLength = $item.Extent.ItemLength + $eolLength
                        } else {
                            $itemLength = $item.Extent.ItemLength
                        }
                        $content = $content.Remove($item.Extent.ItemStart, $itemLength)
                    }
                }

                if ($pscmdlet.ShouldProcess(('Updating INI file {0}' -f $iniFilePath))) {
                    [File]::WriteAllLines(
                        $iniFilePath,
                        $content,
                        $encoding
                    )
                }
            }
        }
    }
}

function Set-IniFileItem {
    <#
    .SYNOPSIS
        Set the value of an item in an INI file.
    .DESCRIPTION
        Set the value of an item in an INI file.
 
        Set-IniFileItem allows
    .EXAMPLE
        Set-IniFileItem -Name itemName -NewValue someValue -Path config.ini
 
        Set a value for itemName in config.ini.
    .EXAMPLE
        Set-IniFileItem -Name itemName -Value currentValue -NewValue newValue -Path config.ini
 
        Set a new value for itemName with value currentValue.
    .EXAMPLE
        Set-IniFileItem -Name extension -Value ldap -NewValue ldap -Path php.ini
 
        Set a value for extension to LDAP. Other extension items in the file are ignored because of the Value filter.
    #>


    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'DefaultSearch')]
    param (
        # The name of an item to add or edit.
        #
        # If the item does not exist it will be created. Section is mandatory for new items.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 1)]
        [String]$Name,

        # The name of a section to add the item to. If the section does not exist it will be created.
        #
        # Section must be defined when adding a new value.
        [Parameter(ValueFromPipelineByPropertyName)]
        [String]$Section,

        # The value may be defined to describe the item. Wildcards are supported.
        [Parameter(Mandatory, ParameterSetName = 'SearchUsingValue')]
        [String]$Value,

        # The literal value may be defined to absolutely describe the item. Wildcards are not supported.
        [Parameter(Mandatory, ParameterSetName = 'SearchUsingLiteralValue')]
        [String]$LiteralValue,

        # The value of the item.
        [Parameter(Mandatory, Position = 2)]
        [String]$NewValue,

        # The path to an ini file.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [String]$Path,

        # Request expansion of environment variables within the value.
        [Switch]$ExpandEnvironmentVariables,

        # Add spaces around the = symbol. For example, sets "Name = Value" instead of "Name=Value".
        [Switch]$IncludePadding,

        # If the INI file already exists the files current encoding will be used. If not, an encoding may be specified using this parameter. By default, files are saved using UTF8 encoding with no BOM.
        [Encoding]$Encoding = [System.Text.UTF8Encoding]::new($false),

        # The default end of line character used when creating new files.
        [ValidateSet("`r`n", "`n")]
        [String]$EndOfLine = [Environment]::NewLine
    )

    process {
        $Path = $pscmdlet.GetUnresolvedProviderPathFromPSPath($Path)

        $eolParam = @{
            EndOfLine = $EndOfLine
        }

        if ($ExpandEnvironmentVariables) {
            $null = $psboundparameters.Remove('ExpandEnvironmentVariables')
            $NewValue = [Environment]::ExpandEnvironmentVariables($NewValue)
        }
        $newItem = '{0}{2}={2}{1}' -f $Name, $NewValue, @('', ' ')[$IncludePadding.ToBool()]

        if (Test-Path $Path) {
            $eolParam['EndOfLine'] = GetEol -Path $Path

            $streamReader = [StreamReader][File]::OpenRead($Path)
            $content = $streamReader.ReadToEnd()
            $encoding = $streamReader.CurrentEncoding
            $streamReader.Close()

            $null = $psboundparameters.Remove('NewValue')
            $null = $psboundparameters.Remove('ExpandEnvironmentVariables')
            $null = $psboundparameters.Remove('IncludePadding')
            $null = $psboundparameters.Remove('Encoding')
            $null = $psboundparameters.Remove('EndOfLine')

            [PSObject[]]$existingItems = Get-IniFileItem @psboundparameters
            if ($existingItems) {
                [Array]::Reverse($existingItems)

                foreach ($existingItem in $existingItems) {
                    if ($existingItem.Value -ne $NewValue) {
                        $content = $content.Remove($existingItem.Extent.ValueStart, $existingItem.Extent.ValueLength).
                                            Insert($existingItem.Extent.ValueStart, $NewValue)
                    }
                }
            } else {
                if (-not $Section) {
                    $Section = '<Undefined>'
                }
                $existingSection = Get-IniFileSection -Name $Section -Path $Path

                if ($existingSection) {
                    $position = $existingSection.Extent.End
                } else {
                    if ($Section -eq '<Undefined>') {
                        $position = 0
                    } else {
                        $content = UpdateString -String $content -Value ('[{0}]' -f $Section) @eolParam
                        $position = $content.Length
                    }
                }
                $content = UpdateString -String $content -Value $newItem -Position $position @eolParam
            }
        } else {
            $content = ''

            if ($Section) {
                $content = UpdateString -String $content -Value ('[{0}]' -f $Section) @eolParam
            }
            $content = UpdateString -String $content -Value $newItem @eolParam
        }

        if ($pscmdlet.ShouldProcess(('Updating file {0}, {1} with value {2}' -f $Path, $Name, $NewValue))) {
            [File]::WriteAllLines(
                $Path,
                $content,
                $encoding
            )
        }
    }
}