PSStringScanner.psm1

# https://github.com/ruby/strscan

class PSStringScanner : ICloneable {
    $pos = 0
    [string]$s
    [System.Text.RegularExpressions.Match]$regexMatch

    PSStringScanner($s) {
        $this.s = $s
    }

    [object]Matches() {
        if ($null -ne $this.regexMatch.Groups) {
            return $this.regexMatch.Groups
        }

        return $null
    }

    Hidden [System.Text.RegularExpressions.Match]MatchResult([string]$value) {
        [regex]$p = $value

        $result = $p.Match($this.s, $this.pos)
        if ($result.Success) {
            $this.regexMatch = $result
        }

        return $result
    }

    [object] Scan([string]$value) {
        $result = $this.MatchResult($value)
        if ($result.success) {
            $this.pos = $result.Index + $result.Length
            return $result.Value
        }

        return $null
    }

    [bool]Check($value) {
        $result = $this.MatchResult($value)

        return $result.success
    }

    [object]Skip($value) {
        $result = $this.MatchResult($value)
        if ($result.success) {
            $this.pos = $result.Index + $result.Length
            return $result.Length
        }

        return $null
    }

    <#
        It "checks" to see whether a scan_until will return a value
    #>

    [object]CheckUntil($value) {
        $result = $this.MatchResult($value)
        if ($result.Success) {
            return $result.Value
        }

        return $null
    }

    <#
        Advances the scan pointer until pattern is matched and consumed. Returns the number of bytes advanced, or null if no match was found.
 
        Look ahead to match pattern, and advance the scan pointer to the end of the match. Return the number of characters advanced, or null if the match was unsuccessful.
 
        It's similar to ScanUntil, but without returning the intervening string.
    #>

    [object]SkipUntil([string]$value) {
        $result = $this.MatchResult($value)

        if ($result.Success) {
            $this.pos = $result.Index + $value.Length
            return $this.pos
        }

        return $null
    }

    <#
        Scans the string until the pattern is matched. Returns the substring up to and including the end of the match, advancing the scan pointer to that location. If there is no match, null is returned
    #>

    [object]ScanUntil($value) {
        $result = $this.MatchResult($value)

        if ($result.Success) {
            $retVal = $this.s.Substring($this.pos, $result.Index + $result.Length - $this.pos)
            $this.pos = $result.Index + $result.Length
            return $retVal
        }

        return $null
    }

    Terminate() {
        $this.pos = $this.s.Length
    }

    [bool]EoS() {
        return ($this.pos -eq $this.s.Length)
    }

    Reset() {
        $this.pos = 0
    }

    <#
        call-seq: peek(len)
 
        Extracts a string corresponding to string[pos,len], without advancing the scan pointer.
 
          $s = New-PSStringScanner 'test string'
          $s.peek(7) # => "test st"
          $s.peek(7) # => "test st"
    #>

    [object]Peek($length) {
        if ($length + $this.pos -gt $this.s.length) {
            $length = $this.s.length - $this.pos
        }

        return $this.s.substring($this.pos, $length)
    }

    <#
        Scans one character and returns it.
            $s = New-PSStringScanner "ab"
            $s.GetCh() # => "a"
            $s.GetCh() # => "b"
            $s.GetCh() # => $null
    #>

    [object]GetCh() {

        if ($this.EoS()) {
            return $null
        }

        $retVal = $this.s.substring($this.pos, 1)
        $this.pos += 1

        return $retVal
    }

    [Object] Clone() {

        $newPSStringScanner = [PSStringScanner]::new($this.s)
        $newPSStringScanner.pos = $this.pos

        return $newPSStringScanner
    }
}

function New-PSStringScanner {
    param(
        [Parameter(Mandatory)]
        $text
    )

    [PSStringScanner]::new($text)
}

Update-TypeData -Force -TypeName String -MemberType ScriptMethod -MemberName Scan -Value {
    param($v)

    $scanner = New-PSStringScanner $this
    do {
        $token = $scanner.Scan($v)
        if ($null -ne $token) {$token}
    } until([string]::IsNullOrEmpty($token))
}