show-syntax.psm1

#Requires -Version 5.1

$ESC = [char]27

$script:LanguageHighlightRules = @{
    ps1  = @(
        @{ Pattern = '\b(function|param|begin|process|end|return|if|elseif|else|switch|foreach|for|while|do|until|break|continue|try|catch|finally|throw|exit|filter|workflow|configuration|class|enum|using|namespace|module|requires|define|from|in|inlinescript|parallel|sequence|dynamicparam)\b'; Color = "$ESC[95m" }
        @{ Pattern = '\b(Write-Host|Write-Output|Write-Error|Write-Warning|Write-Verbose|Write-Debug|Write-Information|Get-Content|Set-Content|Add-Content|Remove-Item|Get-Item|Set-Item|Copy-Item|Move-Item|Get-ChildItem|Test-Path|Import-Module|Export-ModuleMember|New-Object|Select-Object|Where-Object|ForEach-Object|Sort-Object|Group-Object|Measure-Object|Compare-Object|Invoke-Command|Start-Process|Stop-Process|Get-Process|Get-Service|Start-Service|Stop-Service)\b'; Color = "$ESC[96m" }
        @{ Pattern = '(\$[\w:]+)'; Color = "$ESC[33m" }
        @{ Pattern = '(#.*)$'; Color = "$ESC[90m" }
        @{ Pattern = '("[^"]*"|''[^'']*'')'; Color = "$ESC[32m" }
        @{ Pattern = '\b(\d+(\.\d+)?)\b'; Color = "$ESC[36m" }
    )
    psm1 = $null
    psd1 = $null

    json = @(
        @{ Pattern = '("(?:[^"\\]|\\.)*")(\s*:)'; Color = "$ESC[96m" }
        @{ Pattern = ':\s*("(?:[^"\\]|\\.)*")'; Color = "$ESC[32m" }
        @{ Pattern = '\b(true|false|null)\b'; Color = "$ESC[33m" }
        @{ Pattern = '\b(\d+(\.\d+)?([eE][+-]?\d+)?)\b'; Color = "$ESC[36m" }
        @{ Pattern = '([{}\[\]])'; Color = "$ESC[93m" }
    )

    xml  = @(
        @{ Pattern = '(<!--.*?-->)'; Color = "$ESC[90m" }
        @{ Pattern = '(<[/?!]?[\w:.-]+)'; Color = "$ESC[96m" }
        @{ Pattern = '([\w:.-]+=)'; Color = "$ESC[33m" }
        @{ Pattern = '("(?:[^"\\]|\\.)*"|''[^'']*'')'; Color = "$ESC[32m" }
        @{ Pattern = '(/?>)'; Color = "$ESC[96m" }
    )

    md   = @(
        @{ Pattern = '^(#{1,6}\s+.*)$'; Color = "$ESC[93m" }
        @{ Pattern = '(\*\*[^*]+\*\*|__[^_]+__)'; Color = "$ESC[1m" }
        @{ Pattern = '(\*[^*]+\*|_[^_]+_)'; Color = "$ESC[3m" }
        @{ Pattern = '(`[^`]+`)'; Color = "$ESC[32m" }
        @{ Pattern = '^(```.*|```)$'; Color = "$ESC[35m" }
        @{ Pattern = '(\[.+?\]\(.+?\))'; Color = "$ESC[36m" }
        @{ Pattern = '^(\s*[-*+]\s+.*)$'; Color = "$ESC[37m" }
    )

    py   = @(
        @{ Pattern = '\b(def|class|import|from|return|if|elif|else|for|while|in|not|and|or|is|None|True|False|try|except|finally|raise|with|as|pass|break|continue|lambda|yield|global|nonlocal|del|assert|async|await)\b'; Color = "$ESC[95m" }
        @{ Pattern = '(#.*)$'; Color = "$ESC[90m" }
        @{ Pattern = '("(?:[^"\\]|\\.)*"|''[^'']*'')'; Color = "$ESC[32m" }
        @{ Pattern = '\b(\d+(\.\d+)?)\b'; Color = "$ESC[36m" }
        @{ Pattern = '\b(print|len|range|type|str|int|float|list|dict|set|tuple|bool|open|input|enumerate|zip|map|filter|sorted|sum|max|min)\b'; Color = "$ESC[96m" }
    )

    js   = @(
        @{ Pattern = '\b(var|let|const|function|return|if|else|switch|case|default|for|while|do|break|continue|new|delete|typeof|instanceof|in|of|class|extends|import|export|from|try|catch|finally|throw|async|await|yield|this|super|null|undefined|true|false)\b'; Color = "$ESC[95m" }
        @{ Pattern = '(//[^\n]*)'; Color = "$ESC[90m" }
        @{ Pattern = '("(?:[^"\\]|\\.)*"|''[^'']*'')'; Color = "$ESC[32m" }
        @{ Pattern = '\b(\d+(\.\d+)?)\b'; Color = "$ESC[36m" }
        @{ Pattern = '\b(console|Math|JSON|Object|Array|String|Number|Boolean|Promise|setTimeout|setInterval|require|module|exports)\b'; Color = "$ESC[96m" }
    )

    ts   = $null

    yaml = @(
        @{ Pattern = '^(---|\.\.\.)$'; Color = "$ESC[93m" }
        @{ Pattern = '^([\w.-]+)\s*:'; Color = "$ESC[96m" }
        @{ Pattern = ':\s*(.+)$'; Color = "$ESC[32m" }
        @{ Pattern = '(#.*)$'; Color = "$ESC[90m" }
        @{ Pattern = '\b(true|false|null|yes|no)\b'; Color = "$ESC[33m" }
    )

    sh   = @(
        @{ Pattern = '\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|exit|in|break|continue|local|export|readonly|source|unset|shift|set)\b'; Color = "$ESC[95m" }
        @{ Pattern = '(#.*)$'; Color = "$ESC[90m" }
        @{ Pattern = '("(?:[^"\\]|\\.)*"|''[^'']*'')'; Color = "$ESC[32m" }
        @{ Pattern = '(\$[\w{}\(\)]+)'; Color = "$ESC[33m" }
        @{ Pattern = '\b(\d+)\b'; Color = "$ESC[36m" }
    )

    css  = @(
        @{ Pattern = '(/\*[\s\S]*?\*/)'; Color = "$ESC[90m" }
        @{ Pattern = '([.#]?[\w-]+)\s*\{'; Color = "$ESC[96m" }
        @{ Pattern = '([\w-]+)\s*:'; Color = "$ESC[33m" }
        @{ Pattern = ':\s*([^;{]+)'; Color = "$ESC[32m" }
    )

    html = @(
        @{ Pattern = '(<!--[\s\S]*?-->)'; Color = "$ESC[90m" }
        @{ Pattern = '(<[/?!]?[\w.-]+)'; Color = "$ESC[96m" }
        @{ Pattern = '([\w-]+=)'; Color = "$ESC[33m" }
        @{ Pattern = '("(?:[^"\\]|\\.)*"|''[^'']*'')'; Color = "$ESC[32m" }
        @{ Pattern = '(/?>)'; Color = "$ESC[96m" }
    )

    csv  = @(
        @{ Pattern = '^([^,\n]+)'; Color = "$ESC[96m" }
        @{ Pattern = ',"([^"]*)"'; Color = "$ESC[32m" }
    )

    txt  = @()
    log  = @(
        @{ Pattern = '\b(ERROR|FATAL|CRITICAL)\b'; Color = "$ESC[91m" }
        @{ Pattern = '\b(WARN|WARNING)\b'; Color = "$ESC[93m" }
        @{ Pattern = '\b(INFO|INFORMATION)\b'; Color = "$ESC[96m" }
        @{ Pattern = '\b(DEBUG|TRACE|VERBOSE)\b'; Color = "$ESC[90m" }
    )
}

$script:ExtensionToLanguage = @{
    '.ps1'  = 'ps1'
    '.psm1' = 'ps1'
    '.psd1' = 'ps1'
    '.json' = 'json'
    '.xml'  = 'xml'
    '.md'   = 'md'
    '.markdown' = 'md'
    '.py'   = 'py'
    '.js'   = 'js'
    '.mjs'  = 'js'
    '.cjs'  = 'js'
    '.ts'   = 'js'
    '.tsx'  = 'js'
    '.jsx'  = 'js'
    '.yaml' = 'yaml'
    '.yml'  = 'yaml'
    '.sh'   = 'sh'
    '.bash' = 'sh'
    '.zsh'  = 'sh'
    '.css'  = 'css'
    '.scss' = 'css'
    '.less' = 'css'
    '.html' = 'html'
    '.htm'  = 'html'
    '.csv'  = 'csv'
    '.txt'  = 'txt'
    '.log'  = 'log'
}

function Get-LanguageFromExtension {
    [CmdletBinding()]
    param (
        [string]$Extension
    )

    $ext = $Extension.ToLowerInvariant()
    if ($script:ExtensionToLanguage.ContainsKey($ext)) {
        $lang = $script:ExtensionToLanguage[$ext]
        if ($null -ne $lang -and $script:LanguageHighlightRules.ContainsKey($lang)) {
            return $lang
        }
    }
    return $null
}

function Get-HighlightRules {
    [CmdletBinding()]
    param (
        [string]$Language
    )

    if ([string]::IsNullOrEmpty($Language)) {
        return $null
    }

    $lang = $Language.ToLowerInvariant()

    # Aliases
    if ($lang -eq 'powershell') { $lang = 'ps1' }
    if ($lang -eq 'python')     { $lang = 'py' }
    if ($lang -eq 'javascript') { $lang = 'js' }
    if ($lang -eq 'typescript') { $lang = 'js' }
    if ($lang -eq 'bash')       { $lang = 'sh' }
    if ($lang -eq 'markdown')   { $lang = 'md' }

    # psm1/psd1 fall back to ps1 rules
    if ($lang -in 'psm1', 'psd1') { $lang = 'ps1' }
    if ($lang -eq 'ts')            { $lang = 'js' }

    if ($script:LanguageHighlightRules.ContainsKey($lang)) {
        $rules = $script:LanguageHighlightRules[$lang]
        if ($null -eq $rules) {
            # Null value means it's an alias resolved above, re-lookup
            return $null
        }
        return $rules
    }
    return $null
}

function Apply-SyntaxHighlighting {
    [CmdletBinding()]
    param (
        [string]$Line,
        [hashtable[]]$Rules
    )

    if ($null -eq $Rules -or $Rules.Count -eq 0) {
        return $Line
    }

    $reset = "$ESC[0m"
    $result = $Line

    foreach ($rule in $Rules) {
        $replacement = $rule.Color + '$0' + $reset
        $result = [regex]::Replace($result, $rule.Pattern, $replacement, [System.Text.RegularExpressions.RegexOptions]::Multiline)
    }

    return $result
}

function Show-Syntax {
    <#
    .SYNOPSIS
        Displays file content with ANSI syntax highlighting, inspired by the Rust bat tool.
 
    .DESCRIPTION
        Show-Syntax reads one or more files and outputs their content to the console with
        basic ANSI escape-code-based syntax highlighting derived from the file extension.
        It supports optional line numbers and manual language override.
 
    .PARAMETER Path
        One or more paths to the files to display. Accepts pipeline input.
 
    .PARAMETER ShowLineNumbers
        When specified, prepends each output line with its line number.
 
    .PARAMETER Language
        Overrides automatic language detection based on file extension.
        Supported values: ps1, json, xml, md, py, js, ts, yaml, sh, css, html, csv, txt, log,
        and their common aliases (e.g. powershell, python, javascript, bash, markdown).
 
    .EXAMPLE
        Show-Syntax -Path .\script.ps1
        Displays script.ps1 with PowerShell syntax highlighting.
 
    .EXAMPLE
        Show-Syntax -Path .\data.json -ShowLineNumbers
        Displays data.json with JSON highlighting and line numbers.
 
    .EXAMPLE
        Get-ChildItem *.ps1 | Show-Syntax -ShowLineNumbers
        Pipes multiple .ps1 files into Show-Syntax with line numbers.
 
    .EXAMPLE
        Show-Syntax -Path .\notes.txt -Language md
        Forces Markdown highlighting on a .txt file.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'FilePath')]
        [string[]]$Path,

        [switch]$ShowLineNumbers,

        [string]$Language
    )

    process {
        foreach ($filePath in $Path) {
            $resolved = $null
            try {
                $resolved = Resolve-Path -LiteralPath $filePath -ErrorAction Stop
            }
            catch {
                Write-Error "Cannot find file: '$filePath'. $_"
                continue
            }

            if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) {
                Write-Error "Path is not a file or does not exist: '$filePath'"
                continue
            }

            # Determine highlighting language
            $effectiveLang = $null
            if (-not [string]::IsNullOrWhiteSpace($Language)) {
                $effectiveLang = $Language
            }
            else {
                $ext = [System.IO.Path]::GetExtension($resolved.Path)
                $effectiveLang = Get-LanguageFromExtension -Extension $ext
            }

            $rules = Get-HighlightRules -Language $effectiveLang

            # Print a header similar to bat
            $header = "$ESC[2m$ESC[36m$('─' * 4) $($resolved.Path) $('─' * 4)$ESC[0m"
            Write-Host $header

            $lineNumber = 1
            $reader = [System.IO.StreamReader]::new($resolved.Path)
            try {
                while ($null -ne ($rawLine = $reader.ReadLine())) {
                    $displayLine = Apply-SyntaxHighlighting -Line $rawLine -Rules $rules

                    if ($ShowLineNumbers) {
                        $linePrefix = "$ESC[2m{0,4}$ESC[0m " -f $lineNumber
                        Write-Host "$linePrefix$displayLine"
                    }
                    else {
                        Write-Host $displayLine
                    }

                    $lineNumber++
                }
            }
            finally {
                $reader.Dispose()
            }

            Write-Host "$ESC[2m$ESC[36m$('─' * 30)$ESC[0m"
        }
    }
}

Export-ModuleMember -Function Show-Syntax