aboutsummaryrefslogtreecommitdiff
path: root/lib/json/json.tm
diff options
context:
space:
mode:
authorBruce Hill <bruce@bruce-hill.com>2025-08-17 13:33:23 -0400
committerBruce Hill <bruce@bruce-hill.com>2025-08-17 13:33:23 -0400
commitcdc6037af740566d5329cef9d303f06e81682780 (patch)
tree68ee927b9f8a77c597352c89f1c41adad6cc0045 /lib/json/json.tm
parent7e6f81749af2603a7d312c935c475c4223e5a8c1 (diff)
Add JSON module.
Diffstat (limited to 'lib/json/json.tm')
-rw-r--r--lib/json/json.tm173
1 files changed, 173 insertions, 0 deletions
diff --git a/lib/json/json.tm b/lib/json/json.tm
new file mode 100644
index 00000000..8127ce52
--- /dev/null
+++ b/lib/json/json.tm
@@ -0,0 +1,173 @@
+# Base 64 encoding and decoding
+use patterns
+
+enum JSONDecodeResult(
+ Success(json:JSON)
+ Failure(reason:Text)
+)
+ func invalid(text:Text -> JSONDecodeResult)
+ return Failure("Unrecognized JSON: $(text.quoted())")
+
+extend Text
+ func json_quoted(text:Text -> Text)
+ return '"' ++ text.translate({
+ "\\"="\\\\",
+ '"'='\\"',
+ "\f"="\\f",
+ "\r"="\\r",
+ "\n"="\\n",
+ "\b"="\\b",
+ "\t"="\\t",
+ }) ++ '"'
+
+enum JSON(
+ Object(items:{Text=JSON})
+ Array(items:[JSON])
+ Boolean(value:Bool)
+ String(text:Text)
+ Number(n:Num)
+ Null
+)
+ func encode(j:JSON -> Text)
+ when j is Object(items)
+ return "{" ++ ", ".join([
+ '$(k.json_quoted()): $(v.encode())'
+ for k,v in items
+ ]) ++ "}"
+ is Array(items)
+ return "[" ++ ", ".join([item.encode() for item in items]) ++ "]"
+ is Boolean(value)
+ return (if value then "true" else "false")
+ is String(text)
+ return text.json_quoted()
+ is Number(n)
+ return "$n"
+ is Null
+ return "null"
+
+ func pretty_print(j:JSON, max_line:Int=80, indent:Text=" ", current_indent:Text="" -> Text)
+ inline := j.encode()
+ if inline.length > max_line
+ next_indent := current_indent ++ indent
+ when j is Object(items)
+ return "{\n$next_indent" ++ ",\n$next_indent".join([
+ '$(k.json_quoted()): $(v.pretty_print(max_line, indent, next_indent))'
+ for k,v in items
+ ]) ++ "\n$current_indent}"
+ is Array(items)
+ return "[\n$next_indent" ++ ",\n$next_indent".join([item.pretty_print(max_line, indent, next_indent) for item in items]) ++ "\n$current_indent]"
+ else pass
+
+ return inline
+
+ func parse_text(text:Text, remainder:&Text? = none -> JSONDecodeResult)
+ if text.starts_with('"')
+ string := ""
+ pos := 2
+ escapes := {"n"="\n", "t"="\t", "r"="\r", '"'='"', "\\"="\\", "/"="/", "b"="\b", "f"="\f"}
+ while pos <= text.length
+ c := text[pos]
+ if c == '"'
+ if remainder
+ remainder[] = text.from(pos + 1)
+ return Success(JSON.String(string))
+
+ if c == "\\"
+ stop if pos + 1 > text.length
+
+ if esc := escapes[text[pos+1]]
+ string ++= esc
+ pos += 2
+ else if m := text.matching_pattern($Pat/u{4 digit}/)
+ string ++= Text.from_codepoints([Int32.parse(m.captures[1])!])
+ pos += 1 + m.text.length
+ else
+ if remainder
+ remainder[] = text
+ return JSONDecodeResult.invalid(text)
+ else
+ string ++= c
+ pos += 1
+
+ if remainder
+ remainder[] = text
+ return JSONDecodeResult.invalid(text)
+
+ func parse(text:Text, remainder:&Text? = none, trailing_commas:Bool=no -> JSONDecodeResult)
+ if text.starts_with("true", remainder)
+ return Success(JSON.Boolean(yes))
+ else if text.starts_with("false", remainder)
+ return Success(JSON.Boolean(no))
+ else if text.starts_with("null", remainder)
+ return Success(JSON.Null)
+ else if n := Num.parse(text, remainder)
+ return Success(JSON.Number(n))
+ else if text.starts_with('"')
+ return JSON.parse_text(text, remainder)
+ else if text.starts_with("[")
+ elements : &[JSON]
+ text = text.from(2).trim_pattern($Pat"{whitespace}", right=no)
+ repeat
+ when JSON.parse(text, &text) is Success(elem)
+ elements.insert(elem)
+ else stop
+
+ if delim := text.matching_pattern($Pat'{0+ ws},{0+ ws}')
+ text = text.from(delim.text.length + 1)
+ else stop
+
+ if trailing_commas
+ if delim := text.matching_pattern($Pat'{0+ ws},{0+ ws}')
+ text = text.from(delim.text.length + 1)
+
+ if terminator := text.matching_pattern($Pat'{0+ ws}]')
+ if remainder
+ remainder[] = text.from(terminator.text.length + 1)
+ return Success(JSON.Array(elements))
+ else if text.starts_with("{")
+ object : &{Text=JSON}
+ text = text.from(2).trim_pattern($Pat"{whitespace}", right=no)
+ repeat
+ key_text := text
+ when JSON.parse_text(text, &text) is Success(key)
+ if separator := text.matching_pattern($Pat'{0+ ws}:{0+ ws}')
+ text = text.from(separator.text.length + 1)
+ else
+ return JSONDecodeResult.invalid(text)
+
+ when JSON.parse(text, &text) is Success(value)
+ when key is String(str)
+ object[str] = value
+ else
+ return JSONDecodeResult.invalid(key_text)
+ else
+ return JSONDecodeResult.invalid(text)
+ else stop
+
+ if delim := text.matching_pattern($Pat'{0+ ws},{0+ ws}')
+ text = text.from(delim.text.length + 1)
+ else stop
+
+ if trailing_commas
+ if delim := text.matching_pattern($Pat'{0+ ws},{0+ ws}')
+ text = text.from(delim.text.length + 1)
+
+ if terminator := text.matching_pattern($Pat'{0+ ws}{}}')
+ if remainder
+ remainder[] = text.from(terminator.text.length + 1)
+ return Success(JSON.Object(object))
+
+ return JSONDecodeResult.invalid(text)
+
+func main(input=(/dev/stdin), pretty_print:Bool = no, trailing_commas:Bool = yes)
+ text := (input.read() or exit("Invalid file: $input")).trim_pattern($Pat"{whitespace}")
+ while text.length > 0
+ when JSON.parse(text, remainder=&text, trailing_commas=trailing_commas) is Success(json)
+ if pretty_print
+ say(json.pretty_print())
+ else
+ say(json.encode())
+ is Failure(msg)
+ exit("\033[31;1m$msg\033[m", code=1)
+
+ text = text.trim_pattern($Pat"{whitespace}")