Public/Find-FancyQuote.ps1

function Find-FancyQuote {
<#
.SYNOPSIS
    Scans a file for "fancy" (non-ASCII) quote characters and reports where they occur.
 
.DESCRIPTION
    Reads each file and searches every line for typographic quote characters that
    are not the plain ASCII apostrophe (') or double quote ("). This includes
    curly/smart quotes, low-9 quotes, prime marks, and guillemets. Each match is
    reported with its line, column, the character, its Unicode code point, and the
    surrounding text so the offending quote can be located and, if desired, replaced
    with an ASCII equivalent.
 
    The file is read as UTF-8 by default. Use -Encoding to scan files stored in a
    legacy code page such as Windows-1252.
 
.PARAMETER Path
    One or more paths to the files to scan.
 
.PARAMETER Encoding
    The encoding used to read the file. Defaults to 'utf8'. Use 'windows-1252'
    (or a code page number) for legacy ANSI files.
 
.PARAMETER IncludeAccents
    Also flag the backtick (`) and acute accent (´), which are sometimes used as
    quotes. Off by default.
 
.EXAMPLE
    Find-FancyQuote -Path .\Documentation.xml
 
.EXAMPLE
    Get-ChildItem -Recurse -Filter *.xml | Find-FancyQuote -Encoding windows-1252
#>

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

    [string]$Encoding = 'utf8',

    [switch]$IncludeAccents
)

begin {
    # Common non-ASCII quote-like characters, grouped by kind.
    $quoteCodePoints = @(
        0x2018, 0x2019, 0x201A, 0x201B,   # ‘ ’ ‚ ‛ single curly / low-9 / reversed
        0x201C, 0x201D, 0x201E, 0x201F,   # “ ” „ ‟ double curly / low-9 / reversed
        0x2032, 0x2033, 0x2035, 0x2036,   # ′ ″ ‵ ‶ primes
        0x00AB, 0x00BB, 0x2039, 0x203A    # « » ‹ › guillemets
    )

    if ($IncludeAccents) {
        $quoteCodePoints += @(0x0060, 0x00B4)   # ` ´ backtick, acute accent
    }

    # Suggested ASCII replacement for each character, keyed by code point.
    $asciiSuggestion = @{
        0x2018 = "'"; 0x2019 = "'"; 0x201A = "'"; 0x201B = "'"
        0x201C = '"'; 0x201D = '"'; 0x201E = '"'; 0x201F = '"'
        0x2032 = "'"; 0x2033 = '"'; 0x2035 = "'"; 0x2036 = '"'
        0x00AB = '"'; 0x00BB = '"'; 0x2039 = "'"; 0x203A = "'"
        0x0060 = "'"; 0x00B4 = "'"
    }

    $pattern = '[' + (($quoteCodePoints | ForEach-Object { '\u{0:X4}' -f $_ }) -join '') + ']'

    # Resolve the requested encoding once.
    $enc = switch -Regex ($Encoding) {
        '^\d+$'                     { [System.Text.Encoding]::GetEncoding([int]$Encoding); break }
        '^utf-?8$'                  { [System.Text.Encoding]::UTF8; break }
        '^(ascii)$'                 { [System.Text.Encoding]::ASCII; break }
        '^(unicode|utf-?16(le)?)$'  { [System.Text.Encoding]::Unicode; break }
        '^utf-?16be$'               { [System.Text.Encoding]::BigEndianUnicode; break }
        default                     { [System.Text.Encoding]::GetEncoding($Encoding) }
    }

    $regex = [regex]::new($pattern)
}

process {
    foreach ($p in $Path) {
        $resolved = Resolve-Path -LiteralPath $p -ErrorAction SilentlyContinue
        if (-not $resolved) {
            Write-Warning "File not found: $p"
            continue
        }

        $file = $resolved.ProviderPath
        $text = [System.IO.File]::ReadAllText($file, $enc)
        $lines = $text -split "`r`n|`n|`r"

        $findings = @()
        for ($n = 0; $n -lt $lines.Length; $n++) {
            $line = $lines[$n]
            foreach ($m in $regex.Matches($line)) {
                $cp = [int][char]$m.Value
                $findings += [pscustomobject]@{
                    File      = $file
                    Line      = $n + 1
                    Column    = $m.Index + 1
                    Char      = $m.Value
                    CodePoint = 'U+{0:X4}' -f $cp
                    Ascii     = $asciiSuggestion[$cp]
                    Text      = $line.Trim()
                }
            }
        }

        if ($findings.Count -eq 0) {
            Write-Host "No fancy quotes: $file" -ForegroundColor Green
        }
        else {
            Write-Host "Fancy quotes found: $file ($($findings.Count) match(es))" -ForegroundColor Yellow
            $findings
        }
    }
}
}