sourceafJson::JsonReader.fan


** (Service) - 
** Reads Fantom objects from JSON.
** 
** Note 'JsonReader' does NOT convert the resultant maps and lists in to Fantom entities. 
@Js
const class JsonReader {

    // FIXME make pull parser
    
    ** Default ctor.
    new make() { }
    
    ** Reads an object from the given JSON stream and returns one of the following:
    **   - 'null'
    **   - 'Bool'
    **   - 'Int'
    **   - 'Float'
    **   - 'Str'
    **   - 'Str:Obj?'
    **   - 'Obj?[]'
    ** 
    ** If 'closeStream' is 'true', the given 'InStream' is guaranteed to be closed.
    Obj? readJsonFromStream(InStream? in, Bool closeStream := true) {
        if (in == null)
            return null

        ctx := JsonReadCtx(in)
        try {
            ctx.consume
            ctx.skipWhitespace
            return _parseVal(ctx)
            
        } finally
            if (closeStream)
                in.close
    }

    ** Translates the given JSON to its Fantom representation. 
    ** The returned 'Obj' may be any JSON obj.
    ** 
    ** Convenience for 'readJsonFromStream(json?.in)'
    Obj? readJson(Str? json) {
        readJsonFromStream(json?.in)
    }

    ** Translates the given JSON to its Fantom List representation. 
    ** 
    ** Convenience for '(Obj?[]?) readJson(...)'
    Obj?[]? readJsonAsList(Str? json) {
        readJson(json)
    }

    ** Translates the given JSON to its Fantom Map representation. 
    ** 
    ** Convenience for '([Str:Obj?]?) readJson(...)'
    [Str:Obj?]? readJsonAsMap(Str? json) {
        readJson(json)
    }

    ** A simple override hook to alter values *after* they have been read.
    ** 
    ** By default this just returns the given value.  
    virtual Obj? convertHook(Obj? val) { val }


    
    // ---- private methods -----------------------------------------------------------------------
    
    private Obj? _parseVal(JsonReadCtx ctx) {
             if (ctx.cur == JsonToken.quote)        return convertHook(_parseStr(ctx))
        else if (ctx.cur.isDigit || ctx.cur == '-') return convertHook(_parseNum(ctx))
        else if (ctx.cur == JsonToken.objectStart)  return convertHook(_parseObj(ctx))
        else if (ctx.cur == JsonToken.arrayStart)   return convertHook(_parseArray(ctx))
        else if (ctx.cur == 't') {
            "true".size.times |->| { ctx.consume }
            return convertHook(true)
        }
        else if (ctx.cur == 'f') {
            "false".size.times |->| { ctx.consume }
            return convertHook(false)
        }
        else if (ctx.cur == 'n') {
            "null".size.times |->| { ctx.consume }
            return convertHook(null)
        }

        if (ctx.cur < 0) throw ctx.err("Unexpected end of stream")
        throw ctx.err("Unexpected token " + ctx.cur)
    }

    private Str:Obj? _parseObj(JsonReadCtx ctx) {
        pairs := Str:Obj?[:] { ordered = true }

        ctx.skipWhitespace

        ctx.expect(JsonToken.objectStart)

        while (true) {
            ctx.skipWhitespace
            if (ctx.maybe(JsonToken.objectEnd)) return pairs
            _parsePair(ctx, pairs)
            if (!ctx.maybe(JsonToken.comma)) break
        }

        ctx.expect(JsonToken.objectEnd)

        return pairs
    }

    private Void _parsePair(JsonReadCtx ctx, Str:Obj? obj) {
        ctx.skipWhitespace
        key := _parseStr(ctx)

        ctx.skipWhitespace

        ctx.expect(JsonToken.colon)
        ctx.skipWhitespace

        val := _parseVal(ctx)
        ctx.skipWhitespace

        obj[key] = val
    }

    private Obj _parseNum(JsonReadCtx ctx) {
        integral    := StrBuf()
        fractional  := StrBuf()
        exponent    := StrBuf()
        if (ctx.maybe('-'))
            integral.add("-")

        while (ctx.cur.isDigit) {
            integral.addChar(ctx.cur)
            ctx.consume
        }

        if (ctx.cur == '.') {
            decimal := true
            ctx.consume
            while (ctx.cur.isDigit) {
                fractional.addChar(ctx.cur)
                ctx.consume
            }
        }

        if (ctx.cur == 'e' || ctx.cur == 'E') {
            exponent.addChar(ctx.cur)
            ctx.consume
            if (ctx.cur == '+') ctx.consume
            else if (ctx.cur == '-') {
                exponent.addChar(ctx.cur)
                ctx.consume
            }
            while (ctx.cur.isDigit) {
                exponent.addChar(ctx.cur)
                ctx.consume
            }
        }

        Num? num := null
        if (fractional.size > 0)
            num = Float.fromStr(integral.toStr + "." + fractional.toStr + exponent.toStr)
        else if (exponent.size > 0)
            num = Float.fromStr(integral.toStr+exponent.toStr)
        else num = Int.fromStr(integral.toStr)

        return num
    }

    private Str _parseStr(JsonReadCtx ctx) {
        s := StrBuf()
        ctx.expect(JsonToken.quote)
        while (ctx.cur != JsonToken.quote ) {
            if (ctx.cur < 0) throw ctx.err("Unexpected end of str literal")
            if (ctx.cur == '\\') {
                s.addChar(ctx.escape)
            }
            else {
                s.addChar(ctx.cur)
                ctx.consume
            }
        }
        ctx.expect(JsonToken.quote)
        return s.toStr
    }

    private List _parseArray(JsonReadCtx ctx) {
        array := [,]
        ctx.expect(JsonToken.arrayStart)
        ctx.skipWhitespace
        if (ctx.maybe(JsonToken.arrayEnd)) return array

        while (true) {
            ctx.skipWhitespace
            val := _parseVal(ctx)
            array.add(val)
            ctx.skipWhitespace
            if (!ctx.maybe(JsonToken.comma)) break
        }
        ctx.skipWhitespace
        ctx.expect(JsonToken.arrayEnd)
        return array
    }
}

** JsonToken represents the tokens in JSON.
@Js
internal mixin JsonToken {
    static const Int objectStart    := '{'
    static const Int objectEnd      := '}'
    static const Int colon          := ':'
    static const Int arrayStart     := '['
    static const Int arrayEnd       := ']'
    static const Int comma          := ','
    static const Int quote          := '"'
}

@Js
internal class JsonReadCtx {

    private InStream    in
            Int         cur := '?'
            Int         pos := 0

    new make(InStream in) {
        this.in = in
    }
    
    Int escape() {
        // consume slash
        expect('\\')

        // check basics
        switch (cur) {
            case 'b':   consume; return '\b'
            case 'f':   consume; return '\f'
            case 'n':   consume; return '\n'
            case 'r':   consume; return '\r'
            case 't':   consume; return '\t'
            case '"':   consume; return '"'
            case '\\':  consume; return '\\'
            case '/':   consume; return '/'
        }

        // check for uxxxx
        if (cur == 'u') {
            consume
            n3 := cur.fromDigit(16); consume
            n2 := cur.fromDigit(16); consume
            n1 := cur.fromDigit(16); consume
            n0 := cur.fromDigit(16); consume
            if (n3 == null || n2 == null || n1 == null || n0 == null) throw err("Invalid hex value for \\uxxxx")
            return n3.shiftl(12).or(n2.shiftl(8)).or(n1.shiftl(4)).or(n0)
        }

        throw err("Invalid escape sequence")
    }

    Void skipWhitespace() {
        while (cur.isSpace)
            consume
    }

    Void expect(Int tt) {
        if (cur < 0)    throw err("Unexpected end of stream, expected ${tt.toChar}")
        if (cur != tt)  throw err("Expected ${tt.toChar}, got ${cur.toChar} at ${pos}")
        consume
    }

    Bool maybe(Int tt) {
        if (cur != tt) return false
        consume
        return true
    }

    Void consume() {
        cur = in.readChar ?: -1
        pos++
    }
    
    Err err(Str msg) { ParseErr(msg) }
}