plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


plum-std/Json.plum
module std

import std/Option
import std/Result
import std/List
import std/Map
import std/Number
import std/Str
import std/Buffer
import std/Err
import std/Bool

# A parsed JSON value. Numbers are always stored as `Float` (JSON doesn't
# distinguish integers from floats the way Plum does), and `JsonList`/
# `JsonMap` nest `Json` itself — the same self-referential shape `List[T]`
# already uses internally for `Node[T].next: Option[Node[T]]`.
enum Json =
  | JsonNull
  | JsonBool(Bool)
  | JsonFloat(Float)
  | JsonStr(Str)
  | JsonList(List[Json])
  | JsonMap(Map[Str, Json])

  # Parses `s` as a single JSON document, requiring the whole string (aside
  # from surrounding whitespace) to be consumed. Called as `Json.fromStr(s)`,
  # matching `Int.fromStr`/`Float.fromStr`'s naming.
  fun fromStr(s: Str) -> Result[Json, JsonParseError] =
    parser := JsonParser(src: s, pos: 0)
    parser.skipSpace()
    r := parser.parseValue()
    match r
      Err(e) =>
        return Err(e)
      Ok(v) =>
        parser.skipSpace()
        if parser.pos < parser.src.length()
          return Err(parser.fail("unexpected trailing characters"))
        return Ok(v)

  # Renders `self` back to its JSON text form. A literal `{`/`}` is written
  # doubled (`{{`/`}}`) — a single, un-doubled brace in a Plum string starts
  # or ends a `{expr}` interpolation instead of standing for itself.
  fun toStr(self) -> Str =
    match self
      JsonNull => "null"
      JsonBool(b) => b ? "true" : "false"
      JsonFloat(f) => f.toStr()
      JsonStr(s) => "\"" + escapeJsonStr(s) + "\""
      JsonList(items) => "[" + items.join(",") + "]"
      JsonMap(pairs) => "{{" + jsonMapToStr(pairs) + "}}"

  fun isNull(self) -> Bool =
    match self
      JsonNull => True
      _ => False

  fun asBool(self) -> Option[Bool] =
    match self
      JsonBool(b) => Some(b)
      _ => None

  fun asFloat(self) -> Option[Float] =
    match self
      JsonFloat(f) => Some(f)
      _ => None

  fun asStr(self) -> Option[Str] =
    match self
      JsonStr(s) => Some(s)
      _ => None

  fun asList(self) -> Option[List[Json]] =
    match self
      JsonList(items) => Some(items)
      _ => None

  fun asMap(self) -> Option[Map[Str, Json]] =
    match self
      JsonMap(pairs) => Some(pairs)
      _ => None

# Carries where in the source a parse failure happened, alongside the
# message — implements the shared `Err` trait (`libs/std/err.plum`) rather
# than being a plain `Str`, so a caller that wants the position can get it
# without re-parsing the message.
enum JsonParseError(Err) =
  | JsonParseError(pos: Int, text: Str)

  fun code(self) -> Int =
    1

  fun msg(self) -> Str =
    "json parse error at position {self.pos}: {self.text}"

  # A bare payload-free `None` here can't be disambiguated to one specific
  # `Option` instantiation from this expression alone (see the README's
  # "Known gaps" note on this residual generics limitation — unrelated to the
  # 3 points fixed above) — an explicit `Option[JsonParseError]` return type
  # sidesteps it.
  fun cause(self) -> Option[JsonParseError] =
    None

# A recursive-descent parser over a `Str` source, tracking its read position
# in `pos`. (The original design threaded a `Readable` stream through here,
# but `Readable` is an undefined trait with no methods anywhere in `libs/std`
# — there is nothing to call on it — so this reads directly from a `Str`
# instead, the same way every other stdlib parser, e.g. `Int.fromStr`, does.)
enum JsonParser =
  | JsonParser(src: Str, pos: Int)

  fun fail(self, text: Str) -> JsonParseError =
    JsonParseError(pos: self.pos, text: text)

  # The raw byte at the current position, or 0 (never a valid JSON byte) past
  # the end of `src` — lets every caller test for "end of input" the same way
  # it tests for any other byte, with no separate bounds check.
  fun peek(self) -> Int =
    self.pos >= self.src.length() ? 0 : self.src.byteAt(self.pos)

  fun peekAt(self, offset: Int) -> Int =
    i := self.pos + offset
    i >= self.src.length() ? 0 : self.src.byteAt(i)

  fun advance(self) -> Unit =
    self.pos = self.pos + 1

  fun skipSpace(self) -> Unit =
    while isSpace(self.peek())
      self.advance()

  fun parseValue(self) -> Result[Json, JsonParseError] =
    self.skipSpace()
    c := self.peek()
    if c == 34
      return self.parseString()
    if c == 91
      return self.parseArray()
    if c == 123
      return self.parseObject()
    if c == 116
      return self.parseLiteral("true", JsonBool(True))
    if c == 102
      return self.parseLiteral("false", JsonBool(False))
    if c == 110
      return self.parseLiteral("null", JsonNull)
    if c == 45 || isDigit(c)
      return self.parseNumber()
    return Err(self.fail("unexpected character"))

  fun parseLiteral(self, lit: Str, value: Json) -> Result[Json, JsonParseError] =
    n := lit.length()
    if !matchesAt(self.src, lit, self.pos, n)
      return Err(self.fail("invalid literal, expected '{lit}'"))
    self.pos = self.pos + n
    return Ok(value)

  fun parseNumber(self) -> Result[Json, JsonParseError] =
    start := self.pos
    if self.peek() == 45
      self.advance()
    if !isDigit(self.peek())
      return Err(self.fail("invalid number"))
    while isDigit(self.peek())
      self.advance()
    if self.peek() == 46
      self.advance()
      if !isDigit(self.peek())
        return Err(self.fail("invalid number"))
      while isDigit(self.peek())
        self.advance()
    if self.peek() == 101 || self.peek() == 69
      self.advance()
      if self.peek() == 43 || self.peek() == 45
        self.advance()
      if !isDigit(self.peek())
        return Err(self.fail("invalid number"))
      while isDigit(self.peek())
        self.advance()
    return self.parseNumberText(self.src.sub(start, self.pos))

  # `Float.fromStr` (`libs/std/float.plum`) doesn't understand exponent
  # notation, so a number that has one is split into its mantissa and
  # exponent around the `e`/`E` and recombined here instead.
  fun parseNumberText(self, text: Str) -> Result[Json, JsonParseError] =
    e_index := exponentIndex(text)
    if e_index < 0
      r := parseFloat(text)
      match r
        Ok(f) =>
          return Ok(JsonFloat(f))
        Err(m) =>
          return Err(self.fail(m))
    mantissa := parseFloat(text.sub(0, e_index))
    exponent := parseInt(text.sub(e_index + 1, text.length()))
    match mantissa
      Err(m) =>
        return Err(self.fail(m))
      Ok(mv) =>
        match exponent
          Err(m) =>
            return Err(self.fail(m))
          Ok(ev) =>
            return Ok(JsonFloat(mv * 10.pow(Float(ev))))

  fun parseString(self) -> Result[Json, JsonParseError] =
    self.advance()
    return self.parseStringBody(Buffer())

  fun parseStringBody(self, buf: Buffer) -> Result[Json, JsonParseError] =
    c := self.peek()
    if c == 0
      return Err(self.fail("unterminated string"))
    if c == 34
      self.advance()
      return Ok(JsonStr(buf.toStr()))
    if c == 92
      return self.parseEscape(buf)
    n := self.src.codePointByteLength(self.pos)
    buf.write(self.src.sub(self.pos, self.pos + n))
    self.pos = self.pos + n
    return self.parseStringBody(buf)

  fun parseEscape(self, buf: Buffer) -> Result[Json, JsonParseError] =
    self.advance()
    esc := self.peek()
    if esc == 34
      buf.write("\"")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 92
      buf.write("\\")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 47
      buf.write("/")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 98
      buf.write("\b")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 102
      buf.write("\f")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 110
      buf.write("\n")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 114
      buf.write("\r")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 116
      buf.write("\t")
      self.advance()
      return self.parseStringBody(buf)
    if esc == 117
      self.advance()
      return self.parseUnicodeEscape(buf)
    return Err(self.fail("invalid escape character"))

  fun parseHex4(self) -> Result[Int, JsonParseError] =
    return self.parseHex4From(0, 0)

  fun parseHex4From(self, value: Int, count: Int) -> Result[Int, JsonParseError] =
    if count == 4
      return Ok(value)
    d := hexDigitValue(self.peek())
    if d < 0
      return Err(self.fail("invalid unicode escape"))
    self.advance()
    return self.parseHex4From(value * 16 + d, count + 1)

  fun parseUnicodeEscape(self, buf: Buffer) -> Result[Json, JsonParseError] =
    hr := self.parseHex4()
    match hr
      Err(e) =>
        return Err(e)
      Ok(cp) =>
        return self.parseUnicodeEscapeCont(buf, cp)

  # A high surrogate (`0xD800`..`0xDBFF`) must be followed immediately by a
  # low surrogate (`0xDC00`..`0xDFFF`) `\u` escape; the pair together encodes
  # one codepoint above `0xFFFF`. Any other codepoint is used as-is.
  fun parseUnicodeEscapeCont(self, buf: Buffer, cp: Int) -> Result[Json, JsonParseError] =
    if cp >= 0xD800 && cp <= 0xDBFF && self.peek() == 92 && self.peekAt(1) == 117
      self.advance()
      self.advance()
      hr := self.parseHex4()
      match hr
        Err(e) =>
          return Err(e)
        Ok(low) =>
          if low < 0xDC00 || low > 0xDFFF
            return Err(self.fail("invalid low surrogate"))
          combined := 0x10000 + {{cp - 0xD800} << 10} + {low - 0xDC00}
          buf.writeRune(combined)
          return self.parseStringBody(buf)
    buf.writeRune(cp)
    return self.parseStringBody(buf)

  fun parseArray(self) -> Result[Json, JsonParseError] =
    self.advance()
    self.skipSpace()
    items := List[Json](head: None, tail: None, size: 0)
    if self.peek() == 93
      self.advance()
      return Ok(JsonList(items))
    return self.parseArrayItems(items)

  fun parseArrayItems(self, items: List[Json]) -> Result[Json, JsonParseError] =
    self.skipSpace()
    r := self.parseValue()
    match r
      Err(e) =>
        return Err(e)
      Ok(v) =>
        items.add(v)
        self.skipSpace()
        c := self.peek()
        if c == 44
          self.advance()
          return self.parseArrayItems(items)
        if c == 93
          self.advance()
          return Ok(JsonList(items))
        return Err(self.fail("expected ',' or ']'"))

  fun parseObject(self) -> Result[Json, JsonParseError] =
    self.advance()
    self.skipSpace()
    pairs := Map[Str, Json]()
    if self.peek() == 125
      self.advance()
      return Ok(JsonMap(pairs))
    return self.parseObjectItems(pairs)

  fun parseObjectItems(self, pairs: Map[Str, Json]) -> Result[Json, JsonParseError] =
    self.skipSpace()
    if self.peek() != 34
      return Err(self.fail("expected string key"))
    kr := self.parseString()
    match kr
      Err(e) =>
        return Err(e)
      Ok(keyJson) =>
        return self.parseObjectValue(pairs, jsonStrValue(keyJson))

  fun parseObjectValue(self, pairs: Map[Str, Json], key: Str) -> Result[Json, JsonParseError] =
    self.skipSpace()
    if self.peek() != 58
      return Err(self.fail("expected ':'"))
    self.advance()
    self.skipSpace()
    vr := self.parseValue()
    match vr
      Err(e) =>
        return Err(e)
      Ok(v) =>
        pairs.set(key, v)
        self.skipSpace()
        c := self.peek()
        if c == 44
          self.advance()
          return self.parseObjectItems(pairs)
        if c == 125
          self.advance()
          return Ok(JsonMap(pairs))
        return Err(self.fail("expected ',' or '}}'"))

fun jsonStrValue(j: Json) -> Str =
  match j
    JsonStr(s) => s
    _ => ""

# `Map` has no built-in `toStr`/`join` (unlike `List`, which uses `join` for
# `Json.toStr`'s `JsonList` case above) — a plain free function taking the
# `Buffer` as a captured-by-reference parameter instead of a closure sidesteps
# `libs/std/list.plum`'s `join` note on closures: reassigning a CAPTURED
# variable inside a closure body doesn't persist across that closure's own
# repeated calls, but mutating a heap object's fields THROUGH a captured
# reference (as `appendJsonPair` does to `buf`) does.
fun jsonMapToStr(pairs: Map[Str, Json]) -> Str =
  buf := Buffer()
  pairs.each(|k, v| appendJsonPair(buf, k, v))
  return buf.toStr()

fun appendJsonPair(buf: Buffer, k: Str, v: Json) -> Unit =
  if !buf.isEmpty()
    buf.write(",")
  buf.write("\"")
  buf.write(escapeJsonStr(k))
  buf.write("\":")
  buf.write(v.toStr())

fun exponentIndex(text: Str) -> Int =
  e := text.indexOf("e")
  e >= 0 ? e : text.indexOf("E")

fun escapeJsonStr(s: Str) -> Str =
  escapeJsonFrom(s, 0, s.length())

# Byte-at-a-time except for the pass-through case, which copies a whole
# UTF-8 codepoint at once — copying a lone continuation byte as its own
# "character" (as a naive byte-by-byte pass-through would) would corrupt any
# non-ASCII text, the same hazard documented on `Str`'s own `escapeChar` in
# `libs/std/str.plum`.
fun escapeJsonFrom(s: Str, i: Int, len: Int) -> Str =
  if i >= len
    return ""
  b := s.byteAt(i)
  if b == 34
    return "\\\"" + escapeJsonFrom(s, i + 1, len)
  if b == 92
    return "\\\\" + escapeJsonFrom(s, i + 1, len)
  if b == 10
    return "\\n" + escapeJsonFrom(s, i + 1, len)
  if b == 13
    return "\\r" + escapeJsonFrom(s, i + 1, len)
  if b == 9
    return "\\t" + escapeJsonFrom(s, i + 1, len)
  if b == 8
    return "\\b" + escapeJsonFrom(s, i + 1, len)
  if b == 12
    return "\\f" + escapeJsonFrom(s, i + 1, len)
  if b < 0x20
    return "\\u00" + hexByteStr(b) + escapeJsonFrom(s, i + 1, len)
  n := s.codePointByteLength(i)
  return s.sub(i, i + n) + escapeJsonFrom(s, i + n, len)

fun hexByteStr(b: Int) -> Str =
  hexDigitChar(b / 16) + hexDigitChar(b % 16)

fun hexDigitChar(d: Int) -> Str =
  match d
    0 => "0"
    1 => "1"
    2 => "2"
    3 => "3"
    4 => "4"
    5 => "5"
    6 => "6"
    7 => "7"
    8 => "8"
    9 => "9"
    10 => "a"
    11 => "b"
    12 => "c"
    13 => "d"
    14 => "e"
    15 => "f"
    _ => "0"

fun hexDigitValue(b: Int) -> Int =
  if b >= 48 && b <= 57
    return b - 48
  if b >= 97 && b <= 102
    return b - 97 + 10
  if b >= 65 && b <= 70
    return b - 65 + 10
  return -1

fun isSpace(c: Int) -> Bool =
  c == 32 || c >= 9 && c <= 13

fun isDigit(c: Int) -> Bool =
  c >= 48 && c <= 57

# ---- regression tests ----

fun parseFloatValue(s: Str) -> Float =
  r := Json.fromStr(s)
  match r
    Ok(v) => v.asFloat().unwrapOr(-1.0f)
    Err(_) => -1.0f

test "parses null, true, and false"
  assert Json.fromStr("null").unwrap().isNull()
  assert Json.fromStr("true").unwrap().asBool().unwrap()
  assert !Json.fromStr("false").unwrap().asBool().unwrap()

test "parses integers, decimals, negatives, and exponents"
  assert parseFloatValue("42") == 42.0f
  assert parseFloatValue("-7") == -7.0f
  assert parseFloatValue("3.5") == 3.5f
  assert parseFloatValue("1e2") == 100.0f
  assert parseFloatValue("1.5e-2") == 0.015f

test "parses a plain string, with escapes"
  assert Json.fromStr("\"hello\"").unwrap().asStr().unwrap() == "hello"
  assert Json.fromStr("\"a\\nb\"").unwrap().asStr().unwrap() == "a\nb"
  assert Json.fromStr("\"\\u0041\"").unwrap().asStr().unwrap() == "A"

test "parses an array of mixed values"
  v := Json.fromStr("[1, \"two\", true, null]").unwrap()
  items := v.asList().unwrap()
  assert items.length() == 4
  assert items.get(0).unwrap().asFloat().unwrap() == 1.0f
  assert items.get(1).unwrap().asStr().unwrap() == "two"
  assert items.get(2).unwrap().asBool().unwrap()
  assert items.get(3).unwrap().isNull()

test "parses a nested object"
  v := Json.fromStr("{{\"a\": 1, \"b\": {{\"c\": [1, 2, 3]}}}}").unwrap()
  pairs := v.asMap().unwrap()
  assert pairs.get("a").unwrap().asFloat().unwrap() == 1.0f
  nested := pairs.get("b").unwrap().asMap().unwrap()
  inner := nested.get("c").unwrap().asList().unwrap()
  assert inner.length() == 3
  assert inner.get(2).unwrap().asFloat().unwrap() == 3.0f

test "reports an error on malformed input"
  r := Json.fromStr("{{\"a\": }}")
  assert r.isErr()

test "round-trips a value through toStr and back"
  original := "[1,\"two\",true,null,{{\"k\":3.5}}]"
  v := Json.fromStr(original).unwrap()
  again := Json.fromStr(v.toStr()).unwrap()
  assert again.toStr() == v.toStr()

test "toStr escapes special characters in strings"
  v := JsonStr("a\"b\\c\nd")
  assert v.toStr() == "\"a\\\"b\\\\c\\nd\""