TerminalGlyphs.psm1

function ConvertTo-AnsiSequence {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^#?[0-9A-Fa-f]{6}$')]
        [string]$Hex
    )

    $value = $Hex.TrimStart('#')
    $r = [Convert]::ToInt32($value.Substring(0, 2), 16)
    $g = [Convert]::ToInt32($value.Substring(2, 2), 16)
    $b = [Convert]::ToInt32($value.Substring(4, 2), 16)
    "$([char]27)[38;2;$r;$g;${b}m"
}

function ConvertTo-NerdFontKey {
    # Turns a font name as typed (JetBrainsMono Nerd Font Mono, MesloLGS NF) into the form used in file names
    # (JetBrainsMono, MesloLGS): no spaces and no Nerd Font / NF suffix.
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name
    )

    ($Name -replace '\s', '') -replace '(?i)(NerdFont(Mono|Propo)?|NF[MP]?)$', ''
}

function Find-GlyphEntry {
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Table,

        [Parameter(Mandatory)]
        [ValidateSet('files', 'directories')]
        [string]$Kind,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name,

        [string]$LinkType
    )

    $linkKey = switch ($LinkType) {
        'SymbolicLink' { 'symlink' }
        'Junction' { 'junction' }
        default { $null }
    }
    if ($linkKey -and $Table.links.ContainsKey($linkKey)) {
        return [pscustomobject]@{ Entry = $Table.links[$linkKey]; Rule = "$Kind.links[$linkKey]" }
    }
    if ($Name -and $Table.names.ContainsKey($Name)) {
        return [pscustomobject]@{ Entry = $Table.names[$Name]; Rule = "$Kind.names[$Name]" }
    }
    if ($Name -and $Table.ContainsKey('extensions')) {
        # Longest compound suffix first: app.test.d.ts tries .test.d.ts, then .d.ts, then .ts.
        $dot = $Name.IndexOf('.')
        while ($dot -ge 0) {
            $suffix = $Name.Substring($dot)
            if ($Table.extensions.ContainsKey($suffix)) {
                return [pscustomobject]@{ Entry = $Table.extensions[$suffix]; Rule = "$Kind.extensions[$suffix]" }
            }
            $dot = $Name.IndexOf('.', $dot + 1)
        }
    }
    [pscustomobject]@{ Entry = $Table.default; Rule = "$Kind.default" }
}

function Find-NerdGlyph {
    <#
    .SYNOPSIS
        Searches the Nerd Fonts 3.5.1 glyph names to use in your TerminalGlyphs config.
    .PARAMETER Name
        Part of a glyph name (for example cloudflare) or a wildcard pattern (nf-dev-*).
    .EXAMPLE
        Find-NerdGlyph cloudflare
    .EXAMPLE
        Find-NerdGlyph 'nf-md-folder_*'
    .OUTPUTS
        TerminalGlyphs.NerdGlyph
    #>

    [OutputType('TerminalGlyphs.NerdGlyph')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Name
    )

    $pattern = if ([WildcardPattern]::ContainsWildcardCharacters($Name)) { $Name } else { "*$Name*" }
    $map = Get-FullGlyphMap
    foreach ($glyphName in ($map.Keys | Where-Object { $_ -like $pattern } | Sort-Object)) {
        $glyph = $map[$glyphName]
        [pscustomobject]@{
            PSTypeName = 'TerminalGlyphs.NerdGlyph'
            Name       = $glyphName
            Glyph      = $glyph
            CodePoint  = 'U+{0:X4}' -f [char]::ConvertToUtf32($glyph, 0)
        }
    }
}

function Format-TerminalGlyph {
    <#
    .SYNOPSIS
        Prefixes a file or folder name with its Nerd Font icon and color.
    .DESCRIPTION
        Used by the TerminalGlyphs view of Get-ChildItem. Resolves the icon and color with the active themes
        and your config. Never throws: if anything goes wrong, it returns the plain name.
    .PARAMETER InputObject
        The file or folder to format.
    .EXAMPLE
        Get-ChildItem
        TerminalGlyphs formats every item automatically.
    .EXAMPLE
        Get-Item ./go.mod | Format-TerminalGlyph
    .INPUTS
        System.IO.FileSystemInfo
    .OUTPUTS
        System.String
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.IO.FileSystemInfo]$InputObject
    )

    process {
        try {
            Initialize-TerminalGlyph
            $resolved = Resolve-TerminalGlyph -Name $InputObject.Name -Directory:($InputObject -is [System.IO.DirectoryInfo]) -LinkType ([string]$InputObject.LinkType)
            $text = if ($resolved.Icon) { "$($resolved.Icon) $($InputObject.Name)" } else { $InputObject.Name }
            if ($InputObject.LinkTarget) { $text = "$text $($script:TGState.Arrow) $($InputObject.LinkTarget)" }
            if ($resolved.Color -and $PSStyle.OutputRendering -ne 'PlainText') {
                "$($resolved.Color)$text$($PSStyle.Reset)"
            } else {
                $text
            }
        } catch {
            $InputObject.Name
        }
    }
}

function Get-ConfigPath {
    [OutputType([string])]
    [CmdletBinding()]
    param()

    if ($env:TERMINALGLYPHS_CONFIG) { return $env:TERMINALGLYPHS_CONFIG }
    $base = if ($env:XDG_CONFIG_HOME) {
        $env:XDG_CONFIG_HOME
    } else {
        [System.IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.config')
    }
    [System.IO.Path]::Combine($base, 'terminalglyphs', 'config.jsonc')
}

function Get-FontLocation {
    <#
    .SYNOPSIS
        Returns the platform, the per-user font folders and the font folders for all users.
    .DESCRIPTION
        Directory is where new fonts are installed. UserDirectory lists every per-user folder whose Nerd Fonts are
        updated in place (Directory first; on Linux also the legacy ~/.fonts). SystemDirectory lists the folders whose
        Nerd Fonts are only reported, because changing them needs admin rights. Nothing is read or created here.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param()

    $userProfile = [Environment]::GetFolderPath('UserProfile')
    if ($IsWindows) {
        $platform = 'Windows'
        $directory = [System.IO.Path]::Combine([Environment]::GetFolderPath('LocalApplicationData'), 'Microsoft', 'Windows', 'Fonts')
        $userDirectory = [string[]]@($directory)
        $systemDirectory = [string[]]@([Environment]::GetFolderPath('Fonts'))
    } elseif ($IsMacOS) {
        $platform = 'MacOS'
        $directory = [System.IO.Path]::Combine($userProfile, 'Library', 'Fonts')
        $userDirectory = [string[]]@($directory)
        $systemDirectory = [string[]]@('/Library/Fonts')
    } else {
        $platform = 'Linux'
        $dataHome = if ($env:XDG_DATA_HOME) { $env:XDG_DATA_HOME } else { [System.IO.Path]::Combine($userProfile, '.local', 'share') }
        $directory = [System.IO.Path]::Combine($dataHome, 'fonts')
        $userDirectory = [string[]]@($directory, [System.IO.Path]::Combine($userProfile, '.fonts'))
        $systemDirectory = [string[]]@('/usr/share/fonts', '/usr/local/share/fonts')
    }
    [pscustomobject]@{ Platform = $platform; Directory = $directory; UserDirectory = $userDirectory; SystemDirectory = $systemDirectory }
}

function Get-FullGlyphMap {
    [OutputType([System.Collections.IDictionary])]
    [CmdletBinding()]
    param()

    if ($null -eq $script:FullGlyphs) {
        try {
            $script:FullGlyphs = Read-JsoncFile -Path $script:GlyphsPath
        } catch {
            Write-GlyphWarning -Message "could not load '$($script:GlyphsPath)': $($_.Exception.Message)"
            $script:FullGlyphs = @{}
        }
    }
    $script:FullGlyphs
}

function Get-GlyphThemeEntry {
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Theme
    )

    foreach ($kind in @($Theme.Keys)) {
        if ($kind -ceq 'name' -or $kind -ceq '$schema') { continue }
        $kindValue = $Theme[$kind]
        if ($kindValue -isnot [System.Collections.IDictionary]) {
            [pscustomobject]@{ Kind = $kind; Section = $null; Key = $null; Value = $kindValue }
            continue
        }
        foreach ($section in @($kindValue.Keys)) {
            $sectionValue = $kindValue[$section]
            if ($sectionValue -is [System.Collections.IDictionary]) {
                foreach ($key in @($sectionValue.Keys)) {
                    [pscustomobject]@{ Kind = $kind; Section = $section; Key = $key; Value = $sectionValue[$key] }
                }
            } else {
                [pscustomobject]@{ Kind = $kind; Section = $section; Key = $null; Value = $sectionValue }
            }
        }
    }
}

function Get-NerdFontInstallation {
    <#
    .SYNOPSIS
        Groups the Nerd Font files in one or more folders by release package and reports which ones are outdated.
    .DESCRIPTION
        A family spread over several folders is reported once, with all its files. Missing folders are skipped.
        The package of a file is found from the part of its name before "NerdFont", using the longest matching
        prefix in -PackageMap (JetBrainsMonoNL and JetBrainsMono both belong to JetBrainsMono). Files without a
        readable Nerd Fonts version count as outdated. Nerd Fonts 2.x files (named like "Hack Regular Nerd Font
        Complete.ttf") are reported together as one outdated 'Nerd Fonts 2.x' object with IsLegacy set.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$FontDirectory,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$PackageMap,

        [Parameter(Mandatory)]
        [version]$MinimumVersion
    )

    # The same folder can be listed twice (or ~/.fonts can link to ~/.local/share/fonts): read each one once.
    $folderComparer = if ($IsWindows) { [System.StringComparer]::OrdinalIgnoreCase } else { [System.StringComparer]::Ordinal }
    $folders = [System.Collections.Generic.HashSet[string]]::new($folderComparer)
    $existing = [System.Collections.Generic.List[string]]::new()
    foreach ($folder in $FontDirectory) {
        if (-not $folder -or -not [System.IO.Directory]::Exists($folder)) { continue }
        $full = [System.IO.Path]::TrimEndingDirectorySeparator([System.IO.Path]::GetFullPath($folder))
        $link = [System.IO.Directory]::ResolveLinkTarget($full, $true)
        if ($link) { $full = [System.IO.Path]::TrimEndingDirectorySeparator($link.FullName) }
        if ($folders.Add($full)) { $existing.Add($full) }
    }
    if ($existing.Count -eq 0) { return }
    $ignoreCase = [System.StringComparison]::OrdinalIgnoreCase
    $prefixes = @($PackageMap.Keys | Sort-Object -Property Length -Descending)
    $families = [ordered]@{}
    $fonts = @(Get-ChildItem -LiteralPath $existing -Recurse -File -ErrorAction Ignore |
        Where-Object { $_.Extension -in '.ttf', '.otf' } |
        Sort-Object -Property FullName)
    $files = $fonts | Where-Object { $_.Name.Contains('NerdFont', $ignoreCase) }
    # Nerd Fonts 2.x names files like "Hack Regular Nerd Font Complete.ttf".
    $legacyFiles = @($fonts | Where-Object { $_.Name.Contains(' Nerd Font ', $ignoreCase) -and -not $_.Name.Contains('NerdFont', $ignoreCase) })
    foreach ($file in $files) {
        $stem = $file.Name.Substring(0, $file.Name.IndexOf('NerdFont', $ignoreCase))
        $prefix = $prefixes | Where-Object { $stem.StartsWith($_, $ignoreCase) } | Select-Object -First 1
        $package = if ($prefix) { $PackageMap[$prefix] } else { $null }
        $key = if ($package) { $package } else { "?$stem" }
        if (-not $families.Contains($key)) {
            $families[$key] = [pscustomobject]@{
                PSTypeName = 'TerminalGlyphs.NerdFontFamily'
                Name       = if ($package) { $package } else { $stem }
                Package    = $package
                Files      = [System.Collections.Generic.List[string]]::new()
                Version    = $null
                IsOutdated = $false
                IsLegacy   = $false
            }
        }
        $entry = $families[$key]
        $entry.Files.Add($file.FullName)
        $info = Read-FontInfo -Path $file.FullName
        if ($null -eq $info -or $null -eq $info.Version) {
            $entry.IsOutdated = $true
            continue
        }
        if ($null -eq $entry.Version -or $info.Version -lt $entry.Version) { $entry.Version = $info.Version }
        if ($info.Version -lt $MinimumVersion) { $entry.IsOutdated = $true }
    }
    $families.Values
    if ($legacyFiles.Count -gt 0) {
        $legacyVersion = $null
        foreach ($file in $legacyFiles) {
            $info = Read-FontInfo -Path $file.FullName
            if ($null -ne $info -and $null -ne $info.Version -and ($null -eq $legacyVersion -or $info.Version -lt $legacyVersion)) { $legacyVersion = $info.Version }
        }
        [pscustomobject]@{
            PSTypeName = 'TerminalGlyphs.NerdFontFamily'
            Name       = 'Nerd Fonts 2.x'
            Package    = $null
            Files      = [System.Collections.Generic.List[string]]@($legacyFiles.FullName)
            Version    = $legacyVersion
            IsOutdated = $true
            IsLegacy   = $true
        }
    }
}

function Get-NerdFontPackageSuggestion {
    <#
    .SYNOPSIS
        Suggests up to five release packages for a font name that Resolve-NerdFontPackage did not accept.
    .DESCRIPTION
        First the packages whose name or font name starts the given name (Terminus TTF -> Terminus, MonaspiceNe ->
        Monaspace), then, for names of three or more characters, the packages or font names that contain its first
        four characters (Caskaydia -> CascadiaCode, CascadiaMono). Hyphens are ignored (iA Writer -> iA-Writer).
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$PackageMap
    )

    $key = (ConvertTo-NerdFontKey -Name $Name).Replace('-', '')
    if (-not $key) { return }
    $ignoreCase = [System.StringComparison]::OrdinalIgnoreCase
    $suggestions = [System.Collections.Generic.List[string]]::new()
    $add = { param([string]$Package) if ($suggestions.Count -lt 5 -and -not $suggestions.Contains($Package)) { $suggestions.Add($Package) } }
    # Package names and font names, longest first, without hyphens: iA-Writer is typed as "iA Writer".
    $names = foreach ($prefix in $PackageMap.Keys) {
        [pscustomobject]@{ Name = $prefix.Replace('-', ''); Package = $PackageMap[$prefix] }
        [pscustomobject]@{ Name = $PackageMap[$prefix].Replace('-', ''); Package = $PackageMap[$prefix] }
    }
    foreach ($entry in ($names | Sort-Object -Property { $_.Name.Length } -Descending)) {
        if ($entry.Name.Length -ge 2 -and $key.StartsWith($entry.Name, $ignoreCase)) { & $add $entry.Package }
    }
    if ($key.Length -ge 3) {
        $stem = $key.Substring(0, [Math]::Min(4, $key.Length))
        foreach ($entry in ($names | Sort-Object -Property Name)) {
            if ($entry.Name.Contains($stem, $ignoreCase)) { & $add $entry.Package }
        }
    }
    $suggestions
}

function Get-TerminalGlyph {
    <#
    .SYNOPSIS
        Shows which icon and color TerminalGlyphs uses for a file or folder, and why.
    .DESCRIPTION
        Resolves the icon and color with the active themes and your config, and reports the rule that matched
        (for example files.names[go.mod]) and where it came from (theme:<name> or user-config).
    .PARAMETER Path
        Path to a file or folder. Supports wildcards.
    .PARAMETER LiteralPath
        Path used exactly as typed. Objects from Get-ChildItem bind here through PSPath.
    .EXAMPLE
        Get-TerminalGlyph ./go.mod
    .EXAMPLE
        Get-ChildItem | Get-TerminalGlyph
    .OUTPUTS
        TerminalGlyphs.GlyphInfo
    #>

    [OutputType('TerminalGlyphs.GlyphInfo')]
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Path')]
        [string[]]$Path,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'LiteralPath')]
        [Alias('PSPath')]
        [string[]]$LiteralPath
    )

    begin {
        Initialize-TerminalGlyph
    }

    process {
        $items = if ($PSCmdlet.ParameterSetName -eq 'Path') {
            Get-Item -Path $Path -Force
        } else {
            Get-Item -LiteralPath $LiteralPath -Force
        }
        foreach ($item in $items) {
            if ($item -isnot [System.IO.FileSystemInfo]) { continue }
            $resolved = Resolve-TerminalGlyph -Name $item.Name -Directory:($item -is [System.IO.DirectoryInfo]) -LinkType ([string]$item.LinkType)
            [pscustomobject]@{
                PSTypeName = 'TerminalGlyphs.GlyphInfo'
                Name       = $item.Name
                Icon       = $resolved.Icon
                IconName   = $resolved.IconName
                Color      = $resolved.ColorName
                Rule       = $resolved.Rule
                Source     = $resolved.Source
            }
        }
    }
}

function Initialize-TerminalGlyph {
    [CmdletBinding()]
    param(
        [switch]$Force
    )

    if ($script:TGState -and -not $Force) { return }
    try {
        $data = Read-JsoncFile -Path $script:DataPath
        $builtinGlyphs = $data['glyphs']
        $builtinAnsi = $data['ansi']
        $configPath = Get-ConfigPath
        $config = Read-UserConfig -Path $configPath

        $requestedIcon = if ($config) { $config['iconTheme'] } else { $null }
        $requestedColor = if ($config) { $config['colorTheme'] } else { $null }
        $iconThemeName = Select-GlyphThemeName -Requested $requestedIcon -Available $data['iconThemes'] -Setting 'iconTheme' -ConfigPath $configPath
        $colorThemeName = Select-GlyphThemeName -Requested $requestedColor -Available $data['colorThemes'] -Setting 'colorTheme' -ConfigPath $configPath

        # These script blocks run inside Merge-GlyphConfig and see these variables through dynamic scoping.
        $glyphExists = { param($name) $name -is [string] -and ($builtinGlyphs.Contains($name) -or (Get-FullGlyphMap).Contains($name)) }
        $resolveIcon = {
            param($name)
            if ($builtinGlyphs.Contains($name)) { $builtinGlyphs[$name] } else { (Get-FullGlyphMap)[$name] }
        }
        $ansiCache = @{}
        $resolveColor = {
            param($hex)
            $key = $hex.TrimStart('#').ToUpperInvariant()
            if (-not $ansiCache.ContainsKey($key)) { $ansiCache[$key] = ConvertTo-AnsiSequence -Hex $key }
            $ansiCache[$key]
        }

        $icons = New-GlyphTable
        $colors = New-GlyphTable
        Merge-GlyphConfig -Table $icons -Theme $data['iconThemes'][$iconThemeName] -ThemeType Icon -Source "theme:$iconThemeName" -Resolve $resolveIcon -Lookup $builtinGlyphs
        Merge-GlyphConfig -Table $colors -Theme $data['colorThemes'][$colorThemeName] -ThemeType Color -Source "theme:$colorThemeName" -Resolve $resolveColor -Lookup $builtinAnsi

        if ($config) {
            $layers = @(
                @{ Setting = 'icons'; Table = $icons; Type = 'Icon'; Resolve = $resolveIcon; Lookup = $builtinGlyphs }
                @{ Setting = 'colors'; Table = $colors; Type = 'Color'; Resolve = $resolveColor; Lookup = $builtinAnsi }
            )
            foreach ($layer in $layers) {
                $section = $config[$layer.Setting]
                if ($null -eq $section) { continue }
                if ($section -isnot [System.Collections.IDictionary]) {
                    Write-GlyphWarning -Message "$($configPath): '$($layer.Setting)' must be an object, ignoring it."
                    continue
                }
                Merge-GlyphConfig -Table $layer.Table -Theme $section -ThemeType $layer.Type -Source 'user-config' -Origin $configPath -Validate -GlyphExists $glyphExists -Resolve $layer.Resolve -Lookup $layer.Lookup
            }
        }

        $arrow = if ($builtinGlyphs.Contains('nf-md-arrow_right_thick')) { $builtinGlyphs['nf-md-arrow_right_thick'] } else { '->' }
        $script:TGState = @{
            Icons      = $icons
            Colors     = $colors
            Arrow      = $arrow
            IconTheme  = $iconThemeName
            ColorTheme = $colorThemeName
            ConfigPath = $configPath
        }
    } catch {
        Write-GlyphWarning -Message "could not initialize, showing names without icons: $($_.Exception.Message)"
        $script:TGState = @{
            Icons      = New-GlyphTable
            Colors     = New-GlyphTable
            Arrow      = '->'
            IconTheme  = $null
            ColorTheme = $null
            ConfigPath = $null
        }
    }
}

function Install-NerdFontFile {
    <#
    .SYNOPSIS
        Copies Nerd Font files into the user's font folder, replacing the installed copies.
    .DESCRIPTION
        Each source file replaces the installed file with the same name (-InstalledFile), wherever it is. Files that
        are not installed are skipped with -UpdateOnly; otherwise they are copied to -TargetDirectory and, on Windows,
        registered for the current user and loaded. On Windows, an installed file that is in use is renamed to
        <name>.<yyyyMMddHHmmss>.old-nerdfont (UTC) before copying; Remove-StaleFontFile deletes it after a restart.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$SourceFile,

        [Parameter(Mandatory)]
        [string]$TargetDirectory,

        [Parameter(Mandatory)]
        [ValidateSet('Windows', 'Linux', 'MacOS')]
        [string]$Platform,

        [string[]]$InstalledFile = @(),

        [switch]$UpdateOnly,

        [string]$RegistryPath = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Fonts'
    )

    # This function runs only after Install-TerminalGlyphSetup approved the package, so the cmdlets below must not
    # ask again when the caller passes -Confirm or -WhatIf.
    # A file name can be installed in more than one folder (on Linux, ~/.fonts and ~/.local/share/fonts): every copy
    # is replaced, each path once.
    $pathComparer = if ($Platform -eq 'Windows') { [System.StringComparer]::OrdinalIgnoreCase } else { [System.StringComparer]::Ordinal }
    $seen = [System.Collections.Generic.HashSet[string]]::new($pathComparer)
    $installed = @{}
    foreach ($path in $InstalledFile) {
        if (-not $seen.Add([System.IO.Path]::GetFullPath($path))) { continue }
        $name = [System.IO.Path]::GetFileName($path)
        if (-not $installed.ContainsKey($name)) { $installed[$name] = [System.Collections.Generic.List[string]]::new() }
        $installed[$name].Add($path)
    }
    $result = [pscustomobject]@{
        Added    = [System.Collections.Generic.List[string]]::new()
        Replaced = 0
        Renamed  = 0
        Failed   = [System.Collections.Generic.List[string]]::new()
    }
    # A file that fails in several folders is reported once.
    $fail = { param([string]$Name) if (-not $result.Failed.Contains($Name)) { $result.Failed.Add($Name) } }

    foreach ($source in $SourceFile) {
        $name = [System.IO.Path]::GetFileName($source)
        if ($installed.ContainsKey($name)) {
            foreach ($target in $installed[$name]) {
                try {
                    Copy-Item -LiteralPath $source -Destination $target -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop
                    $result.Replaced++
                    continue
                } catch {
                    if ($Platform -ne 'Windows') {
                        & $fail $name
                        continue
                    }
                }
                # Windows keeps fonts in use open: it allows renaming them but not overwriting them.
                $stale = '{0}.{1}.old-nerdfont' -f $target, [DateTime]::UtcNow.ToString('yyyyMMddHHmmss', [cultureinfo]::InvariantCulture)
                try {
                    Move-Item -LiteralPath $target -Destination $stale -Confirm:$false -WhatIf:$false -ErrorAction Stop
                } catch {
                    & $fail $name
                    continue
                }
                try {
                    Copy-Item -LiteralPath $source -Destination $target -Confirm:$false -WhatIf:$false -ErrorAction Stop
                    $result.Renamed++
                } catch {
                    & $fail $name
                    try {
                        Move-Item -LiteralPath $stale -Destination $target -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop
                    } catch {
                        Write-Warning -Message "TerminalGlyphs: $name could not be restored now; it will be restored the next time you run Install-TerminalGlyphSetup."
                    }
                }
            }
            continue
        }
        if ($UpdateOnly) { continue }

        [System.IO.Directory]::CreateDirectory($TargetDirectory) | Out-Null
        $target = [System.IO.Path]::Combine($TargetDirectory, $name)
        try {
            Copy-Item -LiteralPath $source -Destination $target -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop
        } catch {
            & $fail $name
            continue
        }
        if ($Platform -eq 'Windows') {
            $info = Read-FontInfo -Path $target
            $label = if ($info) { $info.FullName } else { [System.IO.Path]::GetFileNameWithoutExtension($target) }
            $kind = if ([System.IO.Path]::GetExtension($target) -eq '.otf') { 'OpenType' } else { 'TrueType' }
            if (-not (Test-Path -LiteralPath $RegistryPath)) { New-Item -Path $RegistryPath -Force -Confirm:$false -WhatIf:$false | Out-Null }
            New-ItemProperty -LiteralPath $RegistryPath -Name "$label ($kind)" -Value $target -PropertyType String -Force -Confirm:$false -WhatIf:$false | Out-Null
        }
        $result.Added.Add($target)
    }

    if ($Platform -eq 'Windows' -and $result.Added.Count -gt 0) { Register-FontResource -Path $result.Added.ToArray() }
    $result
}

function Install-TerminalGlyphSetup {
    <#
    .SYNOPSIS
        Installs or updates Nerd Fonts and makes your PowerShell profile import TerminalGlyphs.
    .DESCRIPTION
        Updates the Nerd Fonts in your user font folder that are older than the version TerminalGlyphs is built for
        (3.5.1), installs the packages in -Family (or JetBrainsMono if you have no Nerd Font), and replaces
        "Import-Module Terminal-Icons" in your profile, keeping a backup. Fonts are installed for the current user
        only, without admin rights. Your terminal settings are not changed: choose the font there afterwards.
        Nerd Fonts 2.x files are not changed: remove them first, then run the command again. Nerd Fonts installed
        for all users are reported but not changed.

        Each step runs even if another one fails, and the command returns one result per step. On Windows, fonts
        that were in use are replaced after you restart Windows.
    .PARAMETER Family
        Nerd Fonts to install or update. Use the release package (JetBrainsMono, FiraCode, CascadiaCode, Meslo), the
        font name (CaskaydiaCove, MesloLGS) or the name shown in your terminal settings (JetBrainsMono Nerd Font Mono).
        Press Tab to list the packages.
    .PARAMETER SkipFont
        Does not install, update or clean up fonts.
    .PARAMETER SkipProfile
        Does not change your profile.
    .EXAMPLE
        Install-TerminalGlyphSetup
    .EXAMPLE
        Install-TerminalGlyphSetup -Family FiraCode -WhatIf
    .EXAMPLE
        Install-TerminalGlyphSetup -Family 'CaskaydiaCove Nerd Font Mono'
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ArgumentCompleter({
                # Completers receive (command, parameter, word to complete, ...). A quoted word arrives as 'Casc'.
                $WordToComplete = ([string]$args[2]).Trim([char[]]"'`"")
                # The module that owns the command in use, even when several versions are loaded.
                $module = (Get-Command -Name $args[0] -CommandType Function -ErrorAction Ignore | Select-Object -First 1).Module
                if (-not $module) { return }
                $packages = & $module { (Read-NerdFontIndex)['packages'].Values } | Sort-Object -Unique
                $pattern = [System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*'
                foreach ($package in ($packages | Where-Object { $_ -like $pattern })) {
                    [System.Management.Automation.CompletionResult]::new($package, $package, 'ParameterValue', $package)
                }
            })]
        [string[]]$Family,

        [switch]$SkipFont,

        [switch]$SkipProfile
    )

    $ErrorActionPreference = 'Stop'
    $nerdFonts = Read-NerdFontIndex
    $version = [version]$nerdFonts['version']
    $packageMap = $nerdFonts['packages']
    $requested = foreach ($name in $Family) {
        $match = Resolve-NerdFontPackage -Name $name -PackageMap $packageMap
        if (-not $match) {
            $similar = @(Get-NerdFontPackageSuggestion -Name $name -PackageMap $packageMap)
            $suggestion = if ($similar.Count -gt 0) { " Did you mean $($similar -join ', ')?" } else { ' Use a release package name such as JetBrainsMono, FiraCode, CascadiaCode, Hack or Meslo.' }
            throw "Unknown Nerd Fonts package '$name'.$suggestion Press Tab after -Family to list the packages."
        }
        $match
    }

    $newStep = {
        param([string]$Name, [string]$Status, [string]$Detail)
        [pscustomobject]@{ PSTypeName = 'TerminalGlyphs.SetupStep'; Step = $Name; Status = $Status; Detail = $Detail }
    }
    $restartNeeded = $false
    $profileChanged = $false
    $fontToChoose = $null

    if ($SkipFont) {
        & $newStep 'Fonts' 'Skipped' 'Skipped with -SkipFont'
    } else {
        $work = $null
        try {
            $location = Get-FontLocation
            if ($location.Platform -eq 'Windows') {
                try {
                    $stale = Remove-StaleFontFile -FontDirectory $location.Directory
                    if ($stale.Pending -gt 0) { $restartNeeded = $true }
                    if ($stale.Removed + $stale.Restored -gt 0) {
                        & $newStep 'Font cleanup' 'OK' ('Removed {0} replaced file(s), restored {1}' -f $stale.Removed, $stale.Restored)
                    } elseif ($stale.Pending -gt 0) {
                        & $newStep 'Font cleanup' 'Unchanged' ('{0} replaced file(s) waiting for a restart' -f $stale.Pending)
                    } else {
                        & $newStep 'Font cleanup' 'Unchanged' 'Nothing to clean up'
                    }
                } catch {
                    & $newStep 'Font cleanup' 'Error' $_.Exception.Message
                }
            }

            # Per-user folders are updated in place (Directory first; on Linux also ~/.fonts).
            $userDirectories = @($location.UserDirectory | Where-Object { $_ })
            if ($userDirectories.Count -eq 0) { $userDirectories = @($location.Directory) }
            $installed = @(Get-NerdFontInstallation -FontDirectory $userDirectories -PackageMap $packageMap -MinimumVersion $version)
            # Fonts installed for all users are only reported: changing them needs admin rights.
            $system = @(foreach ($systemDirectory in @($location.SystemDirectory | Where-Object { $_ })) {
                    Get-NerdFontInstallation -FontDirectory $systemDirectory -PackageMap $packageMap -MinimumVersion $version
                })
            foreach ($scan in @(@{ Suffix = ''; Found = $installed }, @{ Suffix = ' (all users)'; Found = $system })) {
                foreach ($legacy in ($scan.Found | Where-Object { $_.IsLegacy })) {
                    $advice = '{0} file(s) from Nerd Fonts 2.x; remove them in your system font settings, then run Install-TerminalGlyphSetup again' -f $legacy.Files.Count
                    Write-Warning -Message "TerminalGlyphs: $advice."
                    & $newStep "Font $($legacy.Name)$($scan.Suffix)" 'Skipped' $advice
                }
            }
            foreach ($unknown in ($installed | Where-Object { -not $_.Package -and -not $_.IsLegacy })) {
                Write-Warning -Message "TerminalGlyphs: $($unknown.Name) is not a Nerd Fonts $version family; it was not changed."
                & $newStep "Font $($unknown.Name)" 'Skipped' 'Unknown Nerd Fonts family'
            }
            foreach ($shared in ($system | Where-Object { -not $_.IsLegacy })) {
                $stepName = "Font $($shared.Name) (all users)"
                if ($shared.IsOutdated) {
                    $from = if ($shared.Version) { $shared.Version } else { 'unknown version' }
                    & $newStep $stepName 'Skipped' "Nerd Fonts $from installed for all users; updating it needs admin rights"
                } else {
                    & $newStep $stepName 'Unchanged' "Nerd Fonts $($shared.Version)"
                }
            }
            $plan = [ordered]@{}
            foreach ($entry in ($installed | Where-Object { $_.Package })) { $plan[$entry.Package] = $entry }
            foreach ($name in $requested) { if (-not $plan.Contains($name)) { $plan[$name] = $null } }
            if (-not $Family -and $installed.Count -eq 0 -and $system.Count -eq 0) { $plan['JetBrainsMono'] = $null }

            $changed = 0
            foreach ($package in $plan.Keys) {
                $current = $plan[$package]
                $stepName = "Font $package"
                if ($current -and -not $current.IsOutdated) {
                    & $newStep $stepName 'Unchanged' "Nerd Fonts $($current.Version)"
                    continue
                }
                $action = if ($current) {
                    $from = if ($current.Version) { $current.Version } else { 'an unknown version' }
                    "Update from $from to Nerd Fonts $version"
                } else {
                    "Install Nerd Fonts $version"
                }
                if (-not $PSCmdlet.ShouldProcess("$package Nerd Font", $action)) {
                    & $newStep $stepName 'Skipped' $action
                    continue
                }
                try {
                    if (-not $work) {
                        $work = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "terminalglyphs-$([guid]::NewGuid())")
                        [System.IO.Directory]::CreateDirectory($work) | Out-Null
                    }
                    $files = @(Save-NerdFontRelease -Package $package -Version $version.ToString() -ExpectedHash $nerdFonts['archives'][$package] -Destination $work)
                    $target = if ($location.Platform -eq 'Linux') { [System.IO.Path]::Combine($location.Directory, 'NerdFonts', $package) } else { $location.Directory }
                    $installedFiles = @()
                    if ($current) { $installedFiles = [string[]]$current.Files }
                    $result = Install-NerdFontFile -SourceFile $files -TargetDirectory $target -Platform $location.Platform -InstalledFile $installedFiles -UpdateOnly:([bool]$current)
                    $fileChanges = $result.Added.Count + $result.Replaced + $result.Renamed
                    $changed += $fileChanges
                    if ($result.Renamed -gt 0) { $restartNeeded = $true }
                    if (-not $current -and -not $fontToChoose -and $result.Added.Count -gt 0) {
                        $mono = $result.Added | Where-Object { [System.IO.Path]::GetFileName($_) -like '*NerdFontMono-Regular.*' } | Select-Object -First 1
                        $info = if ($mono) { Read-FontInfo -Path $mono }
                        $fontToChoose = if ($info -and $info.Family) { $info.Family } else { "$package Nerd Font Mono" }
                    }
                    $detail = '{0}: {1} added, {2} replaced' -f $action, $result.Added.Count, ($result.Replaced + $result.Renamed)
                    if ($result.Failed.Count -gt 0) {
                        $problem = if ($location.Platform -eq 'Windows') { 'in use could not be replaced, close the apps that use them and run again' } else { 'could not be replaced (check permissions)' }
                        & $newStep $stepName 'Error' ('{0}; {1} file(s) {2}' -f $detail, $result.Failed.Count, $problem)
                    } elseif ($current -and $fileChanges -eq 0) {
                        & $newStep $stepName 'Error' ('{0}: none of the installed files are in the {1} package' -f $action, $package)
                    } else {
                        & $newStep $stepName 'OK' $detail
                    }
                } catch {
                    & $newStep $stepName 'Error' $_.Exception.Message
                }
            }
            if ($changed -gt 0 -and $location.Platform -eq 'Linux' -and -not (Invoke-FontCacheRefresh -Directory $userDirectories)) {
                Write-Warning -Message 'TerminalGlyphs: fc-cache was not found or failed; sign out and back in to see the new fonts.'
            }
        } catch {
            & $newStep 'Fonts' 'Error' $_.Exception.Message
        } finally {
            if ($work) { Remove-Item -LiteralPath $work -Recurse -Force -WhatIf:$false -Confirm:$false -ErrorAction Ignore }
        }
    }

    if ($SkipProfile) {
        & $newStep 'Profile' 'Skipped' 'Skipped with -SkipProfile'
    } else {
        try {
            $profileResult = Update-ProfileImport
            & $newStep 'Profile' $profileResult.Status ('{0}: {1}' -f $profileResult.Path, $profileResult.Detail)
            $profileChanged = $profileResult.Status -eq 'OK'
        } catch {
            & $newStep 'Profile' 'Error' $_.Exception.Message
        }
    }

    if ($fontToChoose) { Write-Host "Set your terminal font to '$fontToChoose'." }
    if ($restartNeeded) { Write-Host 'Restart Windows to finish replacing fonts that were in use; until then, apps keep the old version. Then run Install-TerminalGlyphSetup again to remove the replaced files.' }
    if ($profileChanged) { Write-Host 'Open a new terminal to load TerminalGlyphs.' }
}

function Invoke-FontCacheRefresh {
    # Rebuilds the fontconfig cache of the given folders on Linux. Returns $false when fc-cache is not installed or fails.
    [OutputType([bool])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Directory
    )

    $fcCache = Get-Command -Name 'fc-cache' -CommandType Application -ErrorAction Ignore | Select-Object -First 1
    if (-not $fcCache) { return $false }
    $existing = @($Directory | Where-Object { $_ -and [System.IO.Directory]::Exists($_) })
    if ($existing.Count -eq 0) { return $true }
    & $fcCache.Source -f @existing | Out-Null
    $LASTEXITCODE -eq 0
}

function Invoke-NerdFontDownload {
    # The only network access of the module; tests replace it.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [Parameter(Mandatory)]
        [string]$OutFile
    )

    Invoke-WebRequest -Uri $Uri -OutFile $OutFile -ErrorAction Stop
}

function Merge-GlyphConfig {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Resolve', Justification = 'Called from the $newItem script block, which the analyzer does not follow.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseLiteralInitializerForHashtable', '', Justification = 'The cache of built values must be case-sensitive, so each value keeps its own name.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Table,

        [AllowNull()]
        [System.Collections.IDictionary]$Theme,

        [Parameter(Mandatory)]
        [ValidateSet('Icon', 'Color')]
        [string]$ThemeType,

        [Parameter(Mandatory)]
        [string]$Source,

        [Parameter(Mandatory)]
        [scriptblock]$Resolve,

        # Resolved values by name (glyph name, or color as RRGGBB); -Resolve is called only for names it does not have.
        [System.Collections.IDictionary]$Lookup,

        [scriptblock]$GlyphExists = { $true },

        [string]$Origin = $Source,

        [switch]$Validate
    )

    if ($null -eq $Theme) { return }
    # Built-in themes have about a thousand entries and few distinct values. Walking the dictionaries directly and
    # building each distinct value once avoids an entry object and a -Resolve call per entry (~150 ms per theme).
    $items = [hashtable]::new([System.StringComparer]::Ordinal)
    $newItem = {
        param($Value, $Name)
        $resolved = & $Resolve $Value
        if ($null -eq $resolved) { return $null }
        [pscustomobject]@{ Value = $resolved; Name = $Name; Source = $Source }
    }

    foreach ($kind in @($Theme.Keys)) {
        if ($kind -ceq 'name' -or $kind -ceq '$schema') { continue }
        $target = $Table[$kind]
        $kindValue = $Theme[$kind]
        # Assigned inside each branch: an if statement's output is unrolled, so @($null) would become $null.
        if ($kindValue -is [System.Collections.IDictionary]) { $sections = @($kindValue.Keys) } else { $sections = @($null) }
        foreach ($section in $sections) {
            $sectionValue = if ($null -eq $section) { $kindValue } else { $kindValue[$section] }
            if ($sectionValue -is [System.Collections.IDictionary]) {
                $pairs = @($sectionValue.GetEnumerator())
            } else {
                $pairs = @([pscustomobject]@{ Key = $null; Value = $sectionValue })
            }
            foreach ($pair in $pairs) {
                if ($Validate) {
                    $entry = [pscustomobject]@{ Kind = $kind; Section = $section; Key = $pair.Key; Value = $pair.Value }
                    $problem = Test-GlyphThemeEntry -Entry $entry -ThemeType $ThemeType -GlyphExists $GlyphExists
                    if ($problem) {
                        Write-GlyphWarning -Message "$($Origin): ignoring $problem"
                        continue
                    }
                }
                if ($null -eq $target -or $null -eq $section) { continue }
                $isDefault = $section -ceq 'default'
                if (-not $isDefault -and ($null -eq $pair.Key -or -not $target.ContainsKey($section))) { continue }
                $value = $pair.Value
                if ($value -is [string] -and $items.ContainsKey($value)) {
                    $item = $items[$value]
                } else {
                    $name = $value
                    if ($ThemeType -eq 'Color' -and $name -is [string]) { $name = $name.TrimStart('#').ToUpperInvariant() }
                    $item = if ($Lookup -and $name -is [string] -and $Lookup.Contains($name)) {
                        [pscustomobject]@{ Value = $Lookup[$name]; Name = $name; Source = $Source }
                    } else {
                        & $newItem $value $name
                    }
                    if ($value -is [string]) { $items[$value] = $item }
                }
                if ($null -eq $item) { continue }
                if ($isDefault) { $target['default'] = $item } else { $target[$section][$pair.Key] = $item }
            }
        }
    }
}

function New-GlyphTable {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-memory table only.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseLiteralInitializerForHashtable', '', Justification = 'The tables need an explicit OrdinalIgnoreCase comparer, which a literal initializer cannot provide.')]
    [OutputType([hashtable])]
    [CmdletBinding()]
    param()

    $comparer = [System.StringComparer]::OrdinalIgnoreCase
    @{
        files       = @{
            names      = [hashtable]::new($comparer)
            extensions = [hashtable]::new($comparer)
            links      = [hashtable]::new($comparer)
            default    = $null
        }
        directories = @{
            names   = [hashtable]::new($comparer)
            links   = [hashtable]::new($comparer)
            default = $null
        }
    }
}

function Read-FontInfo {
    <#
    .SYNOPSIS
        Reads the full name, family and Nerd Fonts version from the OpenType 'name' table of a font file.
    .DESCRIPTION
        Returns $null when the file cannot be read or has no full name. Version is $null when the version string
        has no "Nerd Fonts X.Y.Z" part. US English Windows records are preferred over other languages.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $stream = $null
    try {
        $stream = [System.IO.File]::OpenRead($Path)
        $read = {
            param([long]$Offset, [long]$Count)
            if ($Count -gt 1MB) { throw 'The name table is too large.' }
            $buffer = [byte[]]::new($Count)
            [void]$stream.Seek($Offset, [System.IO.SeekOrigin]::Begin)
            $stream.ReadExactly($buffer, 0, $buffer.Length)
            , $buffer
        }
        $u16 = { param([byte[]]$Bytes, [int]$At) ([int]$Bytes[$At] -shl 8) -bor $Bytes[$At + 1] }
        $u32 = { param([byte[]]$Bytes, [int]$At) ([long]$Bytes[$At] -shl 24) -bor ([long]$Bytes[$At + 1] -shl 16) -bor ([long]$Bytes[$At + 2] -shl 8) -bor $Bytes[$At + 3] }

        $header = & $read 0 12
        $tableCount = & $u16 $header 4
        $directory = & $read 12 (16 * $tableCount)
        $table = $null
        for ($i = 0; $i -lt $tableCount; $i++) {
            if ([System.Text.Encoding]::ASCII.GetString($directory, 16 * $i, 4) -ceq 'name') {
                $table = & $read (& $u32 $directory (16 * $i + 8)) (& $u32 $directory (16 * $i + 12))
                break
            }
        }
        if ($null -eq $table) { return $null }

        $count = & $u16 $table 2
        $storage = & $u16 $table 4
        $names = @{}
        $ranks = @{}
        for ($i = 0; $i -lt $count; $i++) {
            $record = 6 + 12 * $i
            $platform = & $u16 $table $record
            $language = & $u16 $table ($record + 4)
            $nameId = & $u16 $table ($record + 6)
            if ($platform -notin 0, 3 -or $nameId -notin 1, 4, 5, 16) { continue }
            $rank = if ($platform -eq 3 -and $language -eq 0x409) { 0 } else { 1 }
            if ($ranks.ContainsKey($nameId) -and $ranks[$nameId] -le $rank) { continue }
            $ranks[$nameId] = $rank
            $names[$nameId] = [System.Text.Encoding]::BigEndianUnicode.GetString($table, $storage + (& $u16 $table ($record + 10)), (& $u16 $table ($record + 8)))
        }
        if (-not $names.ContainsKey(4)) { return $null }

        $version = $null
        if ($names[5] -match 'Nerd Fonts (\d+\.\d+(?:\.\d+)?)') { $version = [version]$Matches[1] }
        $family = if ($names.ContainsKey(16)) { $names[16] } else { $names[1] }
        [pscustomobject]@{ FullName = $names[4]; Family = $family; Version = $version }
    } catch {
        Write-Verbose -Message "Could not read font names from ${Path}: $($_.Exception.Message)"
        $null
    } finally {
        if ($stream) { $stream.Dispose() }
    }
}

function Read-JsoncFile {
    [OutputType([System.Collections.IDictionary])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    $text = [System.IO.File]::ReadAllText($fullPath)
    if ([string]::IsNullOrWhiteSpace($text)) {
        throw "'$fullPath' is empty."
    }
    $value = ConvertFrom-Json -InputObject $text -AsHashtable -Depth 32 -ErrorAction Stop
    if ($value -isnot [System.Collections.IDictionary]) {
        throw "'$fullPath' must contain a JSON object at the root."
    }
    $value
}

function Read-NerdFontIndex {
    <#
    .SYNOPSIS
        Reads nerdfonts.json: the Nerd Fonts version, file prefix -> release package, and package -> SHA-256.
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param()

    [System.IO.File]::ReadAllText($script:FontsPath) | ConvertFrom-Json -AsHashtable
}

function Read-UserConfig {
    [OutputType([System.Collections.IDictionary])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    if (-not [System.IO.File]::Exists($fullPath)) { return $null }
    try {
        Read-JsoncFile -Path $fullPath
    } catch {
        Write-GlyphWarning -Message "could not read config '$fullPath', using the built-in theme: $($_.Exception.Message)"
        $null
    }
}

function Register-FontResource {
    # Loads newly copied fonts into the current Windows session, so they can be used without signing out.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Path
    )

    $api = 'TerminalGlyphs.FontApi' -as [type]
    if (-not $api) {
        Add-Type -Namespace 'TerminalGlyphs' -Name 'FontApi' -MemberDefinition @'
[DllImport("gdi32.dll", CharSet = CharSet.Unicode)]
public static extern int AddFontResourceW(string lpFileName);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr SendMessageTimeoutW(IntPtr hWnd, uint Msg, UIntPtr wParam, IntPtr lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
'@

        $api = 'TerminalGlyphs.FontApi' -as [type]
    }
    foreach ($file in $Path) {
        if ($api::AddFontResourceW($file) -eq 0) { throw "Windows could not load the font $file." }
    }
    $result = [UIntPtr]::Zero
    # HWND_BROADCAST, WM_FONTCHANGE, SMTO_ABORTIFHUNG, 1 second.
    [void]$api::SendMessageTimeoutW([IntPtr]0xFFFF, 0x001D, [UIntPtr]::Zero, [IntPtr]::Zero, 0x0002, 1000, [ref]$result)
}

function Remove-StaleFontFile {
    <#
    .SYNOPSIS
        Deletes font files renamed by Install-NerdFontFile once Windows has restarted.
    .DESCRIPTION
        The Windows font cache keeps using a renamed font file until the next restart; deleting it earlier makes
        apps fall back to other fonts. A renamed file whose original is missing is restored instead.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$FontDirectory,

        [datetime]$BootTime = (Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime
    )

    $result = [pscustomobject]@{ Removed = 0; Restored = 0; Pending = 0 }
    if (-not [System.IO.Directory]::Exists($FontDirectory)) { return $result }
    $boot = $BootTime.ToUniversalTime()
    $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
    $files = Get-ChildItem -LiteralPath $FontDirectory -Filter '*.old-nerdfont' -File -Recurse -ErrorAction Ignore | Sort-Object -Property Name -Descending
    foreach ($file in $files) {
        if ($file.Name -notmatch '^(?<original>.+)\.(?<stamp>\d{14})\.old-nerdfont$') { continue }
        $originalName = $Matches['original']
        $renamedAt = [datetime]::MinValue
        if (-not [datetime]::TryParseExact($Matches['stamp'], 'yyyyMMddHHmmss', [cultureinfo]::InvariantCulture, $styles, [ref]$renamedAt)) { continue }
        $original = [System.IO.Path]::Combine($file.DirectoryName, $originalName)

        if (-not [System.IO.File]::Exists($original)) {
            if ($PSCmdlet.ShouldProcess($file.FullName, "Restore missing font $originalName")) {
                try {
                    Move-Item -LiteralPath $file.FullName -Destination $original -Confirm:$false -WhatIf:$false -ErrorAction Stop
                    $result.Restored++
                } catch {
                    $result.Pending++
                }
            }
            continue
        }
        if ($boot -le $renamedAt) {
            $result.Pending++
            continue
        }
        if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete replaced font file')) {
            try {
                Remove-Item -LiteralPath $file.FullName -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop
                $result.Removed++
            } catch {
                $result.Pending++
            }
        }
    }
    $result
}

function Resolve-NerdFontPackage {
    <#
    .SYNOPSIS
        Returns the Nerd Fonts release package for a package or font name, or nothing if there is none.
    .DESCRIPTION
        Accepts the release package (CascadiaCode), the font name used in file names (CaskaydiaCove) and the name shown
        in terminal settings (JetBrainsMono Nerd Font Mono, MesloLGS NF), case-insensitively. JetBrainsMonoNL and the
        Meslo LGS/LGM/LGL (DZ) variants belong to the package of their family.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$PackageMap
    )

    $key = ConvertTo-NerdFontKey -Name $Name
    if (-not $key) { return }
    foreach ($package in $PackageMap.Values) {
        if ($package -eq $key) { return $package }
    }
    foreach ($prefix in $PackageMap.Keys) {
        if ($prefix -eq $key) { return $PackageMap[$prefix] }
    }
    # Nerd Fonts 3.5.1 families named after their package plus a variant suffix: JetBrainsMonoNL, MesloLGS/LGM/LGL
    # (with or without DZ), OverpassM and OpenDyslexicM.
    $variants = @{ JetBrainsMono = '^(?i)NL$'; MesloLG = '^(?i)[SML](DZ)?$'; Overpass = '^(?i)M$'; OpenDyslexic = '^(?i)M$' }
    foreach ($prefix in $variants.Keys) {
        if ($PackageMap.Contains($prefix) -and $key.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) -and $key.Substring($prefix.Length) -match $variants[$prefix]) {
            return $PackageMap[$prefix]
        }
    }
}

function Resolve-TerminalGlyph {
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name,

        [switch]$Directory,

        [string]$LinkType
    )

    $kind = if ($Directory) { 'directories' } else { 'files' }
    $icon = Find-GlyphEntry -Table $script:TGState.Icons[$kind] -Kind $kind -Name $Name -LinkType $LinkType
    $color = Find-GlyphEntry -Table $script:TGState.Colors[$kind] -Kind $kind -Name $Name -LinkType $LinkType
    [pscustomobject]@{
        Icon      = $icon.Entry.Value
        IconName  = $icon.Entry.Name
        Color     = $color.Entry.Value
        ColorName = $color.Entry.Name
        Rule      = $icon.Rule
        Source    = $icon.Entry.Source
    }
}

function Save-NerdFontRelease {
    <#
    .SYNOPSIS
        Downloads a Nerd Fonts release package, checks its SHA-256 and extracts its font files.
    .DESCRIPTION
        -ExpectedHash comes from the checksums shipped with the module (vendor/nerd-fonts/SHA-256.txt), so the
        archive is never checked against a file downloaded from the same server.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Package,

        [Parameter(Mandatory)]
        [string]$Version,

        [Parameter(Mandatory)]
        [ValidatePattern('^[0-9a-fA-F]{64}$')]
        [string]$ExpectedHash,

        [Parameter(Mandatory)]
        [string]$Destination,

        [string]$BaseUri = 'https://github.com/ryanoasis/nerd-fonts/releases/download'
    )

    $archiveName = "$Package.tar.xz"
    $archive = [System.IO.Path]::Combine($Destination, $archiveName)
    Invoke-NerdFontDownload -Uri "$BaseUri/v$Version/$archiveName" -OutFile $archive
    $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash
    if ($actual -ne $ExpectedHash) { throw "Checksum mismatch for ${archiveName}: expected $ExpectedHash, got $actual." }

    $extract = [System.IO.Path]::Combine($Destination, $Package)
    [System.IO.Directory]::CreateDirectory($extract) | Out-Null
    $tar = if ($IsWindows) { [System.IO.Path]::Combine($env:SystemRoot, 'System32', 'tar.exe') } else { 'tar' }
    $tarOutput = (& $tar -xf $archive -C $extract 2>&1 | ForEach-Object { "$_" }) -join ' '
    if ($LASTEXITCODE -ne 0) {
        $hint = ''
        # GNU tar needs the xz program for .tar.xz files; the bsdtar shipped with Windows does not.
        if (-not $IsWindows -and -not (Get-Command -Name 'xz' -CommandType Application -ErrorAction Ignore)) { $hint = ' Install xz (xz-utils) and run again.' }
        throw "tar could not extract $archiveName (exit code $LASTEXITCODE): $tarOutput$hint"
    }
    Get-ChildItem -LiteralPath $extract -Recurse -File |
        Where-Object { $_.Extension -in '.ttf', '.otf' } |
        Sort-Object -Property Name |
        ForEach-Object { $_.FullName }
}

function Select-GlyphThemeName {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Requested,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Available,

        [Parameter(Mandatory)]
        [string]$Setting,

        [Parameter(Mandatory)]
        [string]$ConfigPath
    )

    if ($null -eq $Requested) { return 'default' }
    if ($Requested -is [string]) {
        $match = @($Available.Keys | Where-Object { $_ -eq $Requested })
        if ($match.Count -gt 0) { return [string]$match[0] }
    }
    Write-GlyphWarning -Message "$($ConfigPath): unknown $Setting '$Requested', using 'default'."
    'default'
}

function Show-TerminalGlyphTheme {
    <#
    .SYNOPSIS
        Previews the active icon and color themes, one line per mapping.
    .PARAMETER Kind
        Which mappings to show: files, directories or both (default).
    .EXAMPLE
        Show-TerminalGlyphTheme -Kind directories
    .OUTPUTS
        System.String
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param(
        [ValidateSet('files', 'directories')]
        [string[]]$Kind = @('directories', 'files')
    )

    Initialize-TerminalGlyph
    $plain = $PSStyle.OutputRendering -eq 'PlainText'
    foreach ($kindName in $Kind) {
        $sections = if ($kindName -eq 'files') { @('names', 'extensions') } else { @('names') }
        foreach ($section in $sections) {
            $map = $script:TGState.Icons[$kindName][$section]
            foreach ($key in ($map.Keys | Sort-Object)) {
                $sample = if ($section -eq 'extensions') { "example$key" } else { $key }
                $resolved = Resolve-TerminalGlyph -Name $sample -Directory:($kindName -eq 'directories')
                $text = '{0} {1,-28} {2}' -f $resolved.Icon, $key, "$kindName.$section"
                if ($resolved.Color -and -not $plain) { "$($resolved.Color)$text$($PSStyle.Reset)" } else { $text }
            }
        }
    }
}

function Test-GlyphThemeEntry {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [psobject]$Entry,

        [Parameter(Mandatory)]
        [ValidateSet('Icon', 'Color')]
        [string]$ThemeType,

        [scriptblock]$GlyphExists = { $true }
    )

    $where = if ($null -eq $Entry.Section) {
        "$($Entry.Kind)"
    } elseif ($null -eq $Entry.Key) {
        "$($Entry.Kind).$($Entry.Section)"
    } else {
        "$($Entry.Kind).$($Entry.Section)[$($Entry.Key)]"
    }

    if ($Entry.Kind -cnotin 'files', 'directories') { return "unknown section '$($Entry.Kind)'" }
    if ($null -eq $Entry.Section) { return "'$where' must be an object" }

    $sections = if ($Entry.Kind -ceq 'files') { 'names', 'extensions', 'links', 'default' } else { 'names', 'links', 'default' }
    if ($Entry.Section -cnotin $sections) { return "unknown section '$where'" }

    if ($Entry.Section -ceq 'default') {
        if ($null -ne $Entry.Key) { return "'$where' must be a string" }
    } elseif ($null -eq $Entry.Key) {
        return "'$where' must be an object"
    }

    if ($Entry.Section -ceq 'links' -and $Entry.Key -cnotin 'symlink', 'junction') { return "unknown link type at '$where'" }
    if ($Entry.Section -ceq 'extensions' -and -not $Entry.Key.StartsWith('.')) { return "extension must start with '.' at '$where'" }
    if ($Entry.Value -isnot [string]) { return "value at '$where' must be a string" }

    if ($ThemeType -eq 'Icon') {
        if (-not (& $GlyphExists $Entry.Value)) { return "unknown glyph '$($Entry.Value)' at '$where'" }
    } elseif ($Entry.Value -notmatch '^#?[0-9A-Fa-f]{6}$') {
        return "invalid color '$($Entry.Value)' at '$where'"
    }
}

function Update-ProfileImport {
    <#
    .SYNOPSIS
        Makes a PowerShell profile import TerminalGlyphs instead of Terminal-Icons.
    .DESCRIPTION
        Edits the first profile in -Path that already imports Terminal-Icons or TerminalGlyphs, or else the first
        profile in -Path. Keeps a backup, the file encoding and the line endings, and writes atomically. A file
        without BOM keeps every byte outside the edit, whether it is UTF-8 or ANSI. A symbolic link is followed to
        its final target, which is edited in place of the link.
    #>

    [OutputType([pscustomobject])]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string[]]$Path = @($PROFILE.CurrentUserCurrentHost, $PROFILE.CurrentUserAllHosts, $PROFILE.AllUsersCurrentHost, $PROFILE.AllUsersAllHosts)
    )

    $oldImport = '(?im)^(?<lead>[ \t]*Import-Module[ \t]+(?:-Name[ \t]+)?)(?<quote>[''"]?)Terminal-Icons\k<quote>(?=[ \t;]|\r?$)'
    $newImport = '(?im)^[ \t]*Import-Module[ \t]+(?:-Name[ \t]+)?[''"]?TerminalGlyphs(?=[''" \t;]|\r?$)'
    $candidates = @($Path | Where-Object { $_ })
    if ($candidates.Count -eq 0) { throw 'This PowerShell host has no profile path.' }

    $target = $candidates[0]
    foreach ($candidate in $candidates) {
        if (-not [System.IO.File]::Exists($candidate)) { continue }
        $content = [System.IO.File]::ReadAllText($candidate)
        if ($content -match $newImport -or $content -match $oldImport) {
            $target = $candidate
            break
        }
    }

    # Edit the final target of a symbolic link (a dotfiles repository, for example), so the link stays a link.
    if ($null -ne [System.IO.FileInfo]::new($target).LinkTarget) {
        $target = [System.IO.File]::ResolveLinkTarget($target, $true).FullName
    }

    # A new profile is UTF-8 without BOM. A file without BOM may be UTF-8 or ANSI (Windows PowerShell 5.1, old
    # Notepad): Latin1 round-trips every byte and the edit only touches ASCII, so either keeps its exact bytes.
    $encoding = [System.Text.UTF8Encoding]::new($false)
    $text = ''
    $exists = [System.IO.File]::Exists($target)
    if ($exists) {
        $reader = [System.IO.StreamReader]::new($target, [System.Text.Encoding]::Latin1, $true)
        try {
            $text = $reader.ReadToEnd()
            $encoding = $reader.CurrentEncoding
        } finally {
            $reader.Dispose()
        }
    }

    if ($text -match $newImport) {
        $detail = 'already imports TerminalGlyphs'
        if ($text -match $oldImport) { $detail += '; remove the Import-Module Terminal-Icons line' }
        return [pscustomobject]@{ Path = $target; Status = 'Unchanged'; Detail = $detail }
    }

    $newline = if ($text.Contains("`r`n")) { "`r`n" } elseif ($text.Contains("`n")) { "`n" } else { [Environment]::NewLine }
    if ($text -match $oldImport) {
        $updated = [regex]::Replace($text, $oldImport, '${lead}${quote}TerminalGlyphs${quote}')
        $action = 'Replace Import-Module Terminal-Icons with TerminalGlyphs'
    } else {
        $separator = if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $newline } else { '' }
        $updated = $text + $separator + '# Added by TerminalGlyphs' + $newline + 'Import-Module -Name TerminalGlyphs' + $newline
        $action = 'Add Import-Module TerminalGlyphs'
    }
    if (-not $PSCmdlet.ShouldProcess($target, $action)) {
        return [pscustomobject]@{ Path = $target; Status = 'Skipped'; Detail = $action }
    }

    [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($target)) | Out-Null
    $temp = "$target.terminalglyphs.tmp"
    try {
        [System.IO.File]::WriteAllText($temp, $updated, $encoding)
        if ($exists) {
            $backup = '{0}.terminalglyphs-{1}.bak' -f $target, [DateTime]::Now.ToString('yyyyMMddHHmmss', [cultureinfo]::InvariantCulture)
            [System.IO.File]::Replace($temp, $target, $backup)
            $action += "; backup in $backup"
        } else {
            [System.IO.File]::Move($temp, $target)
        }
    } catch {
        [System.IO.File]::Delete($temp)
        throw
    }
    # Imports the patterns cannot rewrite, such as "Import-Module posh-git, Terminal-Icons" or a one-line block.
    $lines = $updated -split '\r?\n'
    for ($index = 0; $index -lt $lines.Count; $index++) {
        $line = $lines[$index].TrimStart()
        if (-not $line.StartsWith('#') -and $line -match '(?i)(?<![\w-])Terminal-Icons(?![\w-])') {
            $action += "; Terminal-Icons is still imported on line $($index + 1), remove it"
            break
        }
    }
    [pscustomobject]@{ Path = $target; Status = 'OK'; Detail = $action }
}

function Update-TerminalGlyphConfig {
    <#
    .SYNOPSIS
        Reloads your TerminalGlyphs config without restarting the session.
    .DESCRIPTION
        Re-reads the config file and shows its validation warnings again, even if they were shown before.
        The config path is $env:TERMINALGLYPHS_CONFIG, $env:XDG_CONFIG_HOME/terminalglyphs/config.jsonc
        or ~/.config/terminalglyphs/config.jsonc.
    .EXAMPLE
        Update-TerminalGlyphConfig
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param()

    if ($PSCmdlet.ShouldProcess((Get-ConfigPath), 'Reload TerminalGlyphs config')) {
        $script:Warned.Clear()
        Initialize-TerminalGlyph -Force
    }
}

function Write-GlyphWarning {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Message
    )

    # Each distinct problem is reported once per session (Update-TerminalGlyphConfig resets this).
    if ($script:Warned.Add($Message)) {
        Write-Warning -Message "TerminalGlyphs: $Message"
    }
}

# Module body. build.ps1 prepends every function from src/Private and src/Public above this point.
# Importing must stay cheap and must never read data files or write anything to disk.
$script:DataPath = [System.IO.Path]::Combine($PSScriptRoot, 'TerminalGlyphs.data.json')
$script:GlyphsPath = [System.IO.Path]::Combine($PSScriptRoot, 'glyphs.json')
$script:FontsPath = [System.IO.Path]::Combine($PSScriptRoot, 'nerdfonts.json')
$script:TGState = $null
$script:FullGlyphs = $null
$script:Warned = [System.Collections.Generic.HashSet[string]]::new()

if (Get-Module -Name 'Terminal-Icons') {
    Write-Warning -Message 'TerminalGlyphs: Terminal-Icons is also loaded. Both modules replace the Get-ChildItem view; remove "Import-Module Terminal-Icons" from your profile.'
}
Update-FormatData -PrependPath ([System.IO.Path]::Combine($PSScriptRoot, 'TerminalGlyphs.format.ps1xml'))