Environment.Path.psm1

<#
.SYNOPSIS
    Normalizes a path string for safe use in PATH entries.
 
.DESCRIPTION
    Trims whitespace, resolves a real filesystem path when possible, and returns a consistent
    path format without a trailing backslash. If the path does not exist, it is still converted
    to a full absolute path relative to the current working directory.
    Variables are preserved (but not expanded).
 
.PARAMETER LiteralPath
    The path to normalize.
 
.OUTPUTS
    System.String
    Returns the normalized absolute path.
 
.EXAMPLE
    Resolve-EnvPath ".\tools\bin"
 
    Description:
    Converts the relative path to an absolute path.
 
.EXAMPLE
    Resolve-EnvPath "C:\Tools\bin\"
 
    Description:
    Returns a normalized version without the trailing backslash.
 
.EXAMPLE
    Resolve-EnvPath "C:\This\Folder\Does\Not\Exist"
 
    Description:
    Returns the absolute full path even if it does not currently exist.
#>

function Resolve-EnvPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('literalPath')]
        [string[]]$path
    )

    begin {
        $resolved = @()
    }

    process {
        foreach ($p in $path) {
            $p = $p.Trim('"`t ')

            # Keep absolute paths and variables untouched
            if ([IO.Path]::IsPathRooted($p) -or ($p -like '%*')) {
                $resolved += $p.TrimEnd('\')
                continue
            }

            # If it exists, resolve it fully.
            if (Test-Path -Literal $p) {
                $fullPath = (Resolve-Path -Literal $p).ProviderPath
                $resolved += $fullPath.TrimEnd('\')
                continue
            }

            # If it doesn't exist, still normalize it relative to current location.
            $fullPath = [IO.Path]::GetFullPath($p)
            if ($fullPath.Length -gt 3 -and $fullPath.EndsWith('\')) {
                $fullPath = $fullPath.TrimEnd('\')
            }

            $resolved += $fullPath
        }
        return $resolved
    }
}

<#
.SYNOPSIS
    Gets matching paths from the PATH environment variable.
 
.DESCRIPTION
    Returns any occurrence of the searched directory name or path in the PATH variable.
    If path is relative, resolves it to the current working directory
    (PATH contains only absolute paths).
    If parameters are omitted, returns all paths.
 
.PARAMETER Path
    Full or relative path or name of the file.
 
.PARAMETER Scope
    The PATH scope to search in.
 
    Valid values:
    - Session: searches only the current PowerShell session PATH
    - User: searches the current user's PATH
    - Machine: searches the system-wide PATH (may require elevated permissions)
 
.INPUTS
    System.String[]
 
.OUTPUTS
    System.String[]
    Returns full path(s) found in the selected PATH scope or empty array if nothing was found.
 
.EXAMPLE
    path notepad
    C:\Windows\system32\notepad
 
.EXAMPLE
    path conemu-maximus5 -Scope User
    C:\Users\ToYu\cmder\vendor\conemu-maximus5
 
.EXAMPLE
    path C:\Users\ToYu\cmder\vendor\conemu-maximus5
 
    Description:
    If path exists in PATH variable, returns it. Otherwise returns empty array.
#>

function Get-EnvPath {
    [CmdletBinding()]
    [Alias('path', 'finddir', 'findfolder')]

    param(
        [Parameter(ValueFromPipeline)]
        [AllowEmptyCollection()]
        [Alias('name', 'literalPath')]
        [string[]]$path = @(),

        [ValidateSet('Session', 'User', 'Machine')]
        [string]$scope = 'Session'
    )

    begin {
        $rawPath = switch ($scope) {
            'Session' {
                $env:Path
            }
            'User' {
                [Environment]::GetEnvironmentVariable('Path', [EnvironmentVariableTarget]::User)
            }
            'Machine' {
                [Environment]::GetEnvironmentVariable('Path', [EnvironmentVariableTarget]::Machine)
            }
        }

        $envPaths = if ([string]::IsNullOrWhiteSpace($rawPath)) {
            @()
        } else {
            $rawPath -split ([IO.Path]::PathSeparator) |
                Where { -not ([string]::IsNullOrWhiteSpace($_)) } |
                ForEach { $_.Trim() } |
                Select -Unique
        }

        $matches  = @()
    }

    process {
        if (-not $path -or -not $envPaths) {
            return $envPaths
        }

        foreach ($p in $path) {
            if ([string]::IsNullOrWhiteSpace($p)) {
                continue
            }

            $isPathLike = [IO.Path]::IsPathRooted($p)
            $resolved = $isPathLike ? (Resolve-EnvPath $p) : ''
            $isFound  = $false

            foreach ($envPath in $envPaths) {
                if ($isPathLike) {
                    # Search for exact path
                    if ($envPath -ieq $resolved) {
                        $matches += $envPath
                        $isFound  = $true
                    }
                    continue
                }

                # Find all possible matches
                $name = [IO.Path]::GetFileName($p)
                $base = [IO.Path]::GetFileNameWithoutExtension($p)

                $envName = [IO.Path]::GetFileName($envPath)
                $envBase = [IO.Path]::GetFileNameWithoutExtension($envPath)

                if (
                    $envName -ieq $name -or
                    $envBase -ieq $base -or
                    $envName -like "$name*" -or
                    $envBase -like "$base*"
                ) {
                    $matches += $envPath
                    $isFound  = $true
                }
            }
            if (-not $isFound) {
                Write-Error "No matching entry found for `"$p`" in $scope PATH" -category 'ObjectNotFound'
            }
        }
        return $matches
    }
}

<#
.SYNOPSIS
    Finds files from the PATH environment variable.
 
.DESCRIPTION
    Returns any occurrence of the searched filename in the folders contained in the PATH variable.
    Provides more flexibility in selecting which executable to run instead of relying on
    Windows' first-match behavior when typing an executable name in the CLI or a script.
 
.NOTES
    Unlike other functions (like Get-Env*) in this function the path should exist on the disk.
    Otherwise it returns empty string.
 
.PARAMETER Name
    Name of the executable file to search for.
 
.PARAMETER Scope
    The PATH scope to search in.
 
    Valid values:
    - Session: searches only the current PowerShell session PATH
    - User: searches the current user's PATH
    - Machine: searches the system-wide PATH (may require elevated permissions)
 
.INPUTS
    System.String[]
 
.OUTPUTS
    System.String[]
    Returns full path(s) to matching executable file(s) found in the selected PATH scope.
 
.EXAMPLE
    Find-EnvPath notepad.exe
    C:\Windows\system32\notepad.exe
 
.EXAMPLE
    Find-EnvPath conemu64 -Scope User
    C:\Users\ToYu\cmder\vendor\conemu-maximus5\conemu64.exe
 
.EXAMPLE
    "conemu", "git" | Find-EnvPath
 
    Description:
    Search for all matching executables
 
.EXAMPLE
    Find-EnvPath pwsh -Scope Machine
 
    Description:
    Search the system PATH for PowerShell executables
 
.EXAMPLE
    Find-EnvPath C:\Windows\system32\notepad.exe
    Find-EnvPath C:\Users\ToYu\cmder
 
    Description:
    It can be used to check if path is present in environment
 
.EXAMPLE
    Find-EnvPath "cmder" | ForEach { Get-HelpMessage $_ }
 
    Description:
    Retrieves help information for all matching paths
 
.EXAMPLE
    Find-EnvPath git, gitbutler, but | Split-Path -Parent | Remove-EnvPath
 
    Description:
    Removes all paths to the VCS applications.
 
.LINK
    Get-EnvPath
    Get-HelpMessage
#>

function Find-EnvPath {
    [CmdletBinding()]
    [Alias('findfile')]

    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$name,

        [ValidateSet('Session', 'User', 'Machine')]
        [string]$scope = 'Session'
    )

    process {
        foreach ($n in $name) {
            if ([string]::IsNullOrWhiteSpace($n)) {
                continue
            }

            Get-EnvPath -scope $scope |
            ForEach {
                # Search for files matching the name with any extension
                Get-ChildItem -Path $_ -Filter "$n*" -File   |
                    Where { $n -like "$($_.BaseName)*" }
            } | Select -ExpandProperty FullName
        }
    }
}


function Update-Environment([int]$TimeoutMs = 50) {
    Add-Type @'
using System;
using System.Runtime.InteropServices;
 
public static class NativeMethods {
    public const uint WM_SETTINGCHANGE = 0x001A;
    public const uint SMTO_ABORTIFHUNG = 0x0002;
 
    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern IntPtr SendMessageTimeout(
        IntPtr hWnd,
        uint Msg,
        IntPtr wParam,
        string lParam,
        uint fuFlags,
        uint uTimeout,
        out IntPtr lpdwResult);
}
'@


    $result = [IntPtr]::Zero
    [void][NativeMethods]::SendMessageTimeout(
        [IntPtr]0xFFFF,
        [NativeMethods]::WM_SETTINGCHANGE,
        [IntPtr]::Zero,
        'Environment',
        [NativeMethods]::SMTO_ABORTIFHUNG,
        $TimeoutMs,
        [ref]$result
    )
}

<#
.SYNOPSIS
    Sets an environment variable in the selected scope.
 
.DESCRIPTION
    Writes the environment variable to either the current session, the current user profile,
    or the machine-wide environment. If the value is empty, the variable is removed.
 
.PARAMETER Name
    The name of the environment variable to set or remove.
 
.PARAMETER Value
    The value to assign. Use an empty string to remove the variable.
 
.PARAMETER Scope
    The scope in which to update the value.
 
    Valid values:
    - Session: affects only the current session
    - User: affects the current user's future sessions
    - Machine: affects the system-wide (may require elevation)
 
.NOTES
    The original .NET/Windows implementation of SetEnvironmentVariable is slow because it
    broadcasts a system-wide environment change via WM_SETTINGCHANGE and waits for ALL PROCESSES
    to process it. This can block for roughly 20 seconds or more, which is why
    calls appear sluggish when updating PATH or other environment values.
 
    This function avoids that slow broadcast path by writing directly to the registry and by updating `$env:` for the current session.
    It then calls Update-Environment.
 
.EXAMPLE
    Set-EnvironmentVariable Path -Value "C:\Users\ToYu\ripgrep;C:\Users\ToYu\Notepad++" -Scope User
 
.EXAMPLE
    Set-EnvironmentVariable JAVA_HOME -Value C:\oracle\jdk\bin -Scope Session
#>

function Set-EnvironmentVariable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$name,

        [Parameter(ValueFromPipeline)]
        [AllowEmptyString()]
        [object]$value,

        [Parameter()]
        [ValidateSet('Session', 'User', 'Machine')]
        [string]$scope = 'User'
    )

    $ErrorAction = if ($PSBoundParameters.ContainsKey('ErrorAction')) {
        $PSBoundParameters['ErrorAction']
    } else {
        $ErrorActionPreference
    }

    switch ($scope) {
        'Session' {
            if ([string]::IsNullOrEmpty([string]$value)) {
                Remove-Item -path "Env:$name" -ErrorAction $ErrorAction
            } else {
                Set-Item -path "Env:$name" -value $value -ErrorAction $ErrorAction
            }
            return
        }
        'User' {
            $hive = 'HKCU:\Environment'
        }
        'Machine' {
            $hive = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment'
        }
    }

    if ([string]::IsNullOrEmpty([string]$value)) {
        Remove-ItemProperty -path $hive -name $name -ErrorAction $ErrorAction
    } else {
        $type = if ($name -ieq 'Path') {
            # Preserve variables
            [Microsoft.Win32.RegistryValueKind]::ExpandString
        } else {
            [Microsoft.Win32.RegistryValueKind]::String
        }

        Set-ItemProperty -path $hive -name $name -value $value -type $type -force -ErrorAction $ErrorAction
    }

    Update-Environment
}

<#
.SYNOPSIS
    Adds a folder to the selected PATH scope.
 
.DESCRIPTION
    Resolves the provided path, normalizes it, and appends it to the PATH variable for the
    requested scope if it does not already exist. The current session PATH is updated immediately.
 
.NOTES
    Non-executable files and variables are preserved (but not expanded).
    Usually, adding files has no effect, so use Add-EnvExe instead.
    The target path does not have to exist on the disk.
 
.PARAMETER Path
    The folder path to add.
    If path is relative, resolves it to the current working directory.
    Variables and files (except for executables) are preserved.
 
.PARAMETER Scope
    The PATH scope to modify.
 
    Valid values:
    - Session: affects only the current session
    - User: affects the current user's future sessions
    - Machine: affects the system-wide PATH (may require elevation)
 
.OUTPUTS
    System.String[]
    Returns the normalized path(s) that was added.
 
.EXAMPLE
    Add-EnvPath C:\Users\ToYu\ripgrep
 
    Description:
    Adds the folder to the current user's PATH
 
.EXAMPLE
    Add-EnvPath -Path .\Microsoft\WindowsApps -Scope Session
 
    Description:
    Resolves relative path to folder and adds it only to the current PowerShell session
 
.EXAMPLE
    Add-EnvPath -Path C:\Users\ToYu\Notepad++\notepad++.exe -Scope Machine
 
    Description:
    Adds the executable to the machine-wide PATH
 
.LINK
    Find-EnvPath git -Scope User | Add-EnvExe -Scope Machine
 
    Description:
    Finds the git.exe in the PATH (if present) and makes it available it for all users.
#>

function Add-EnvPath {
    [CmdletBinding()]
    [Alias('addpath')]

    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('literalPath')]
        [string[]]$path,

        [ValidateSet('Session', 'User', 'Machine')]
        [string]$scope = 'User'
    )

    begin {
        # Values are writed separately for the current session ($env:Path)
        # and for the specified scope (environment)
        $isSessionOnly = ($scope -ieq 'Session')
        $sessionPaths  = Get-EnvPath -scope 'Session'
        $envPaths      = $isSessionOnly ? @() : (Get-EnvPath -scope $scope)
        $sessionValues = @()
        $envValues     = @()

        function Write-Err([string]$msg) {
            $extra = if ($p -ieq $resolved) {
                "`"$p`""
            } else {
                "`"$p`" -> `"$resolved`""
            }
            Write-Error "$($msg): $extra" -CategoryActivity 'Add-EnvPath' -category 'InvalidData'
        }
    }

    process {
        foreach ($p in $path) {
            if ([string]::IsNullOrWhiteSpace($p)) {
                continue
            }

            $resolved = Resolve-EnvPath $p
            $ext = [IO.Path]::GetExtension($resolved)

            if ($ext -and ($ext -match '^\.?(exe|cmd|bat|ps1|dll|com)$')) {
                Write-Err 'Specify the directory containing the file, not the file itself'
                continue
            }

            if ($resolved -notin $sessionPaths) {
                $sessionValues += $resolved
            } elseif ($isSessionOnly) {
                # Current session supposed to be updated explicitly
                Write-Err 'Path already exists in current session'
            }
            if ($isSessionOnly) {
                continue
            }

            # Persistent path
            if ($resolved -notin $envPaths) {
                $envValues += $resolved
            } else {
                Write-Err "Path already exists in $scope PATH"
            }
        }
    }

    end {
        if ($sessionValues.count) {
            $env:Path = `
                @((Get-EnvPath -scope 'Session'), $sessionValues) |
                Join-String -sep ([IO.Path]::PathSeparator)
        }
        if ($isSessionOnly) {
            return $sessionValues
        }

        if ($envValues.count) {
            @((Get-EnvPath -scope $scope), $envValues) |
                Join-String -sep ([IO.Path]::PathSeparator) |
                Set-EnvironmentVariable 'Path' -scope $scope
        }
        return $envValues
    }
}

<#
.SYNOPSIS
    Removes a folder from the selected PATH scope.
 
.DESCRIPTION
    Resolves the provided path and removes all matching entries from the selected PATH scope.
    If the path is not present, the function returns false without changing the PATH.
 
.PARAMETER Path
    The path to remove.
    If path is relative, resolves it to the current working directory.
    If only a name is provided, removes all matches.
 
.PARAMETER Scope
    The PATH scope to update.
 
    Valid values:
    - Session: affects only the current session
    - User: affects the current user's future sessions
    - Machine: affects the system-wide PATH (may require elevation)
 
.OUTPUTS
    System.String[]
    Returns all removed entries.
 
.EXAMPLE
    Remove-EnvPath C:\Users\ToYu\ripgrep
 
    Description:
    Removes the full path.
 
.EXAMPLE
    Remove-EnvPath ripgrep
 
    Description:
    Removes all matching directories.
 
.EXAMPLE
    Remove-EnvPath -Path .\Microsoft\WindowsApps -Scope Session
 
    Description:
    Removes the folder relative to cur. working dir. if it was found in the variable.
 
.EXAMPLE
    Find-EnvPath git, gitbutler, but | Split-Path -Parent | Remove-EnvPath
 
    Description:
    Removes all paths to the VCS applications.
#>

function Remove-EnvPath {
    [CmdletBinding()]
    [Alias('rempath')]

    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('name')]
        [string[]]$path,

        [ValidateSet('Session', 'User', 'Machine')]
        [string]$scope = 'User'
    )

    begin {
        # Values are writed separately for the current session ($env:Path)
        # and for the specified scope (environment)
        $isSessionOnly = ($scope -ieq 'Session')
        $sessionValues = @()
        $envValues     = @()

        $ErrorAction = if ($PSBoundParameters.ContainsKey('ErrorAction')) {
            $PSBoundParameters['ErrorAction']
        } else {
            $ErrorActionPreference
        }
    }

    process {
        $sessionValues = Get-EnvPath $path 'Session' -ErrorAction ($isSessionOnly ? $ErrorAction : 'Ignore')
        if (-not $isSessionOnly) {
            $envValues = Get-EnvPath $path $scope    -ErrorAction $ErrorAction
        }
    }

    end {
        if ($sessionValues.count) {
            $env:Path = Get-EnvPath -scope 'Session' |
                Where { $_ -notin $sessionValues } |
                Join-String -sep ([IO.Path]::PathSeparator)
        }
        if ($isSessionOnly) {
            return $sessionValues
        }

        if ($envValues.count) {
            $value = Get-EnvPath -scope $scope |
                Where { $_ -notin $envValues } |
                Join-String -sep ([IO.Path]::PathSeparator) |
                Set-EnvironmentVariable 'Path' -scope $scope -ErrorAction $ErrorAction
        }
        return $envValues
    }
}

<#
.SYNOPSIS
    Repairs the PATH value for the selected scope.
 
.DESCRIPTION
    Normalizes PATH entries by trimming quotes and whitespace, removes empty values,
    and optionally keeps only entries that exist or are directories.
 
.PARAMETER scope
    The scope to repair.
 
    Valid values:
    - Session: affects only the current session
    - User: affects the current user's future sessions
    - Machine: affects the system-wide PATH (may require elevation)
 
.PARAMETER exists
    If specified, removes entries that do not exist on disk.
 
.PARAMETER folders
    If specified, keeps only directory entries.
 
.EXAMPLE
    Repair-EnvPath -scope User
 
.EXAMPLE
    Repair-EnvPath -scope Machine -exists
 
.EXAMPLE
    Repair-EnvPath -scope Session -folders
#>

function Repair-EnvPath {
    [CmdletBinding()]
    param(
        [ValidateSet('Session', 'User', 'Machine')]
        [string]$scope = 'User',

        [Alias('strict')]
        [switch]$exists,

        [Alias('dir', 'dirs', 'directories')]
        [switch]$folders
    )

    Get-EnvPath -scope $scope |
        ForEach { $_.Trim('"/\`t ') } |
        Where {
            $_ -and
            (-not $exists  -or (Test-Path -Literal $_)) -and
            (-not $folders -or (Test-Path -Literal $_ -PathType Container))
        } |
        Select -Unique |
        Join-String -sep ([IO.Path]::PathSeparator) |
        Set-EnvironmentVariable 'Path' -scope $scope
}