Common/GitDescription.ps1

# Copyright (C) 2024 kzrnm
# Based on git-completion.bash (https://github.com/git/git/blob/HEAD/contrib/completion/git-completion.bash).
# Distributed under the GNU General Public License, version 2.0.
using namespace System.Collections.Generic;
using namespace System.Management.Automation;

# Options
$__helpCompletion = [System.Management.Automation.CompletionResult]::new(
    '-h',
    '-h',
    'ParameterName',
    'show help'
)

function Write-HostDummy {}

function trimDescription {
    param (
        [Parameter(Mandatory, Position = 0)][AllowEmptyString()][string]$Text
    )

    $removed = [System.Text.StringBuilder]::new($Text.Length)

    while ($Text) {
        if ($Text -cmatch '^(\s+)(.*)') {
            $removed.Append($Matches[1])
            $Text = $Matches[2]
        }
        elseif ($Text.StartsWith('<')) {
            $b = removeLeadingBracket $Text -Begin '<' -End '>'
            $removed.Append($b.Removed)
            $Text = $b.Remaining

            if ($Text.Length -le 1) {
                $removed.Append($Text)
                $Text = ''
            }
        }
        elseif ($Text.StartsWith('[')) {
            $b = removeLeadingBracket $Text -Begin '[' -End ']'
            $removed.Append($b.Removed)
            $Text = $b.Remaining
        }
        elseif ($Text.StartsWith('(+|-)x')) {
            $removed.Append('(+|-)x')
            $Text = $Text.Substring('(+|-)x'.Length)
            continue
        }
        elseif ($Text -ceq '...') {
            $removed.Append($Text)
            $Text = ''
        }
        elseif ($Text -cmatch '^(\(?[\w-]+(\|[\w-]+)+\))(.*)') {
            $removed.Append($Matches[1])
            $Text = $Matches[3]
        }
        else { break }
    }

    # while ($remaining -match '^(\s*(<[^>]+>|?)\.*)(.*)') {
    # Write-HostParsing $Matches[1] -NoNewline
    # $remaining = $Matches[4]
    # }

    return [PSCustomObject]@{
        Removed   = $removed.ToString()
        Remaining = $Text
    }
}

function removeLeadingBracket {
    param (
        [Parameter(Mandatory, Position = 0)][string]$Text,
        [char]$Begin = '[',
        [char]$End = ']'
    )

    $removed = [System.Text.StringBuilder]::new($Text.Length)
    $cnt = 0
    for ($i = 0; $i -lt $Text.Length; $i++) {
        $c = $Text[$i]
        if ($c -ceq $Begin) {
            $cnt++
        }

        if ($cnt -eq 0) {
            break
        }
        $removed.Append($c)

        if ($c -ceq $End) {
            $cnt--
        }
    }

    return [PSCustomObject]@{
        Removed   = $removed.ToString()
        Remaining = $Text.Substring($i)
    }
}

function colorBracket {
    param (
        [Parameter(Mandatory, Position = 0)][string]$Text,
        [char]$Begin = '[',
        [char]$End = ']'
    )

    $removed = [System.Text.StringBuilder]::new($Text.Length)
    $colored = [System.Text.StringBuilder]::new($Text.Length)
    $cnt = 0
    for ($i = 0; $i -lt $Text.Length; $i++) {
        $c = $Text[$i]
        if ($c -ceq $Begin) {
            if ($cnt -eq 0) {
                $colored.Append("`e[43m")
            }
            $cnt++
        }

        if ($cnt -eq 0) {
            $removed.Append($c)
        }
        $colored.Append($c)

        if ($c -ceq $End) {
            $cnt--
            if ($cnt -eq 0) {
                $colored.Append("`e[40m")
            }
        }
    }

    return [PSCustomObject]@{
        Removed = $removed.ToString()
        Colored = $colored.ToString()
    }
}

function Convert-ToGitHelpOptions {
    [OutputType([GitHelpOptions])]
    param (
        [Parameter(Position = 0)]
        [string[]]
        $Help,
        [switch] $ShowParser
    )

    if ($ShowParser) {
        Set-Alias Write-HostParsing Write-Host -Scope Local
    }
    else {
        Set-Alias Write-HostParsing Write-HostDummy -Scope Local
    }

    $longDescriptions = [Dictionary[string, string]]::new()
    $shortDescriptions = [Dictionary[string, string]]::new()
    $Prev = $false
    $long = $null
    $short = $null

    function Add-Description {
        param (
            [Parameter(Mandatory, Position = 0)][string]$Description
        )
        if ($long) { $longDescriptions[$long] = $Description }
        if ($short) {
            if ($short -ceq '-NUM') {
                0..9 | ForEach-Object {
                    $shortDescriptions["$_"] = $Description.Replace('NUM', "$_")
                }
            }
            else {
                $shortDescriptions[$short] = $Description
            }
        }
    }

    foreach ($line in $Help) {
        if ($Prev) {
            Add-Description $line.Trim()
            $Prev = $false
            Write-HostParsing "`e[32m$line`e[0m"
        }
        elseif ($line -match '^(\s+)(-.*)') {
            Write-HostParsing $Matches[1] -NoNewline
            $line = $Matches[2]
            $long = $null
            $short = $null
            if ($line -match '^(-([^-])(,?\s*))?(--\S+)(.*)') {
                $short = $Matches[2]
                if ($short) {
                    Write-HostParsing "`e[35m$($short)`e[0m$($Matches[3])" -NoNewline
                }

                $bLong = colorBracket $Matches[4]
                $long = $bLong.Removed
                $coloredLong = $bLong.Colored

                $remaining = $Matches[5]

                Write-HostParsing "`e[36m$coloredLong`e[0m" -NoNewline
            }
            elseif ($line -match '^-NUM(.*)') {
                $short = '-NUM'
                $remaining = $Matches[1]
                Write-HostParsing "`e[35m-NUM`e[0m" -NoNewline
            }
            elseif ($line -match '^-(\S)(=?\[[^\]]+\])?(.*)') {
                $short = $Matches[1]
                $value = $Matches[2]
                $remaining = $Matches[3]

                Write-HostParsing "`e[35m$short" -NoNewline
                if ($value) { Write-HostDummy "`e[43m$value`e[40m" -NoNewline }
                Write-HostDummy "`e[0m" -NoNewline
            }
            else {
                Write-HostParsing "`e[31m${line}`e[0m"
                continue
            }

            $splitedRemaining = trimDescription $remaining

            $params = $splitedRemaining.Removed
            $desc = $splitedRemaining.Remaining
            Write-HostParsing "$params" -NoNewline

            if ($desc) {
                Write-HostParsing "`e[32m$desc`e[0m"
                Add-Description $desc
            }
            else {
                Write-HostParsing ""
                $Prev = $true
            }
        }
        else {
            Write-HostParsing $line
        }
    }

    return [GitHelpOptions]@{
        Subcommand = '';
        Long       = $longDescriptions;
        Short      = $shortDescriptions;
    }
}

$script:__gitHelpCache = @{}
function Get-GitHelp {
    [OutputType([GitHelp])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Command,
        [switch] $ShowParser
    )

    $gitHelp = $script:__gitHelpCache[$Command]
    if ($gitHelp) { return $gitHelp }

    $Options = [List[GitHelpOptions]]::new()

    [GitHelpOptions]$opt = Convert-ToGitHelpOptions (Invoke-Expression "git $Command -h 2>&1" -ErrorAction Ignore) -ShowParser:$ShowParser
    if ($Command -ceq 'grep') {
        $v = $null
        if ($opt.Long.TryGetValue('--and', [ref]$v)) {
            $opt.Long['--or'] = $v
            $opt.Long['--not'] = $v
        }
    }
    $Options.Add($opt)

    $Subcommands = @(__git $Command --git-completion-helper-all 2>$null) -split '\s+'
    foreach ($Subcommand in $Subcommands) {
        if ($Subcommand.StartsWith('-')) { continue }
        [GitHelpOptions]$opt = Convert-ToGitHelpOptions (Invoke-Expression "git $Command $Subcommand -h 2>&1" -ErrorAction Ignore) -ShowParser:$ShowParser
        $opt.Subcommand = $Subcommand
        $Options.Add($opt)
    }

    return ($script:__gitHelpCache[$Command] = [GitHelp]::new($Options.ToArray()))
}

function Get-GitShortOptions {
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Command,
        [Parameter(Position = 1)]
        [string]
        $Subcommand = '',
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]
        $Current
    )

    if ($Current -cmatch '^(-([^-]*))(?!-)') {
        $Prev = $Matches[1]
        $Exists = $Matches[2]
        $gh = (Get-GitHelp $Command)
        if ($gh) {
            $gh.ShortOptions($Subcommand) | Where-Object {
                !$Exists.Contains($_.Key)
            } | ForEach-Object {
                $key = $_.Key
                [CompletionResult]::new(
                    "$Prev$key",
                    "-$key",
                    'ParameterName',
                    $_.Description
                )
            }
        }
        if ($Current -eq '-') {
            $__helpCompletion
        }
    }
}

function Get-GitOptionsDescription {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string]$Current,
        [Parameter(Mandatory, Position = 1)]
        [string]
        $Command,
        [Parameter(Position = 2)]
        [string]$Subcommand = ''
    )

    if ($Current.EndsWith('=')) {
        $Current = $Current.TrimEnd('=')
    }
    $gh = Get-GitHelp $Command
    $result = $gh.Description($Subcommand, $Current)

    if ($result) { return $result }

    if ($Current.StartsWith('--no-')) {
        $positive = Get-GitOptionsDescription ('--' + $Current.Substring('--no-'.Length)) $Command $Subcommand
        if ($positive) {
            return "[NO] $positive"
        }
    }
    return $null
}

class GitHelpShortOption {
    [string] $Key;
    [string] $Description;

    GitHelpShortOption([string]$Key, [string]$Description) {
        $this.Key = $Key
        $this.Description = $Description
    }
}

class GitHelpOptions {
    [string]$Subcommand;
    [Dictionary[string, string]]$Long;
    [Dictionary[string, string]]$Short;

    [GitHelpShortOption[]]$_shortOptionsCache = $null;

    [GitHelpShortOption[]] ShortOptions() {
        if ($null -ne $this._shortOptionsCache) { return $this._shortOptionsCache }

        $ret = $this.Short.GetEnumerator() | ForEach-Object {
            [GitHelpShortOption]::new($_.Key, $_.Value)
        } | Sort-Object Key -CaseSensitive

        if ($null -eq $ret) {
            return ($this._shortOptionsCache = @()) 
        }
        return ($this._shortOptionsCache = $ret)
    }

    [string] Description([string]$key) {
        $value = $null
        $this.Long.TryGetValue($key, [ref]$value) | Out-Null

        return $value
    }
}
class GitHelp {
    [Dictionary[string, GitHelpOptions]]$Options;

    GitHelp([GitHelpOptions[]]$Options) {
        $this.Options = [Dictionary[string, GitHelpOptions]]::new()
        foreach ($opt in $Options) {
            $this.Options[$opt.Subcommand] = $opt
        }
    }

    [GitHelpShortOption[]] ShortOptions([string]$Subcommand) {
        $opt = $null
        if (!$this.Options.TryGetValue($Subcommand, [ref]$opt)) {
            $opt = $this.Options['']
        }
        return $opt.ShortOptions()
    }

    [string] Description([string]$Subcommand, [string]$long) {
        $opt = $null
        if ($this.Options.TryGetValue($Subcommand, [ref]$opt)) {
            $result = $opt.Description($long)
            if ($result) {
                return $result
            }
        }
        return $this.Options[''].Description($long)
    }
}

# Command
$script:__gitCommandDescriptionAll = $null
function Get-GitCommandDescriptionAll {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param ()

    if ($script:__gitCommandDescriptionAll) { return $script:__gitCommandDescriptionAll }

    $t = @{}
    foreach ($line in (git help --verbose --all --no-external-commands --no-aliases)) {
        if ($line -match '\s\s(\S+)\s+(.+)') {
            $t[$Matches[1]] = $Matches[2]
        }
    }
    $script:__gitCommandDescriptionAll = $t
    return $t
}

function Get-GitCommandDescription {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Command
    )

    return (Get-GitCommandDescriptionAll)[$Command]
}