commit f07be540647910f50789afdff6cf4ef8e9eb6e45 Author: LilyRose2798 Date: Thu Mar 21 22:05:04 2024 +1100 Initial commit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..916edea --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26.0.2" + gleam-version: "1.0.0" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/README.md b/README.md new file mode 100644 index 0000000..f01f025 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# jasper + +[![Package Version](https://img.shields.io/hexpm/v/jasper)](https://hex.pm/packages/jasper) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/jasper/) + +```sh +gleam add jasper +``` +```gleam +import gleam/io +import jasper.{parse_json, query_json, String, Root, Key, Index} + +pub fn main() { + let assert Ok(json) = parse_json("{ \"foo\": [1, true, \"hi\"] }") + let assert Ok(String(str)) = query_json(json, Root |> Key("foo") |> Index(2)) + io.println(str) +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +gleam shell # Run an Erlang shell +``` diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..14328dc --- /dev/null +++ b/gleam.toml @@ -0,0 +1,13 @@ +name = "jasper" +version = "1.0.0" + +description = "Utilities for parsing and querying JSON data" +licences = ["AGPL-3.0-only"] +repository = { type = "github", user = "LilyRose2798", repo = "jasper" } + +[dependencies] +gleam_stdlib = "~> 0.34 or ~> 1.0" +pears = "~> 0.3" + +[dev-dependencies] +gleeunit = "~> 1.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..8c0721b --- /dev/null +++ b/manifest.toml @@ -0,0 +1,13 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" }, + { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, + { name = "pears", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "pears", source = "hex", outer_checksum = "F823EDF5C7F8606A0B7764C071B6EE8515FC341D6FC69902E81120633DF7E983" }, +] + +[requirements] +gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } +gleeunit = { version = "~> 1.0" } +pears = { version = "~> 0.3"} diff --git a/src/jasper.gleam b/src/jasper.gleam new file mode 100644 index 0000000..ec79277 --- /dev/null +++ b/src/jasper.gleam @@ -0,0 +1,197 @@ +import gleam/float +import gleam/int +import gleam/string +import gleam/result +import gleam/option.{None, Some} +import gleam/dict.{type Dict} +import gleam/list +import pears.{type Parser} +import pears/chars.{type Char, digit, string} +import pears/combinators.{ + alt, between, choice, eof, just, lazy, left, many0, many1, map, maybe, none_of, + one_of, pair, recognize, right, sep_by0, seq, to, +} + +pub type JsonObject = + Dict(String, JsonValue) + +pub type JsonArray = + List(JsonValue) + +pub type JsonValue { + Object(JsonObject) + Array(JsonArray) + String(String) + Number(Float) + Boolean(Bool) + Null +} + +fn whitespace0() -> Parser(Char, List(Char)) { + one_of([" ", "\n", "\r", "\t"]) + |> many0() +} + +fn value_parser() -> Parser(Char, JsonValue) { + let padded = fn(parser: Parser(_, a)) { left(parser, whitespace0()) } + let symbol = fn(s: String) { padded(string(s)) } + + let hex_digit = + one_of([ + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", + "f", "A", "B", "C", "D", "E", "F", + ]) + + let unicode_escape_digits = + recognize(seq([hex_digit, hex_digit, hex_digit, hex_digit])) + + let escape = + just("\\") + |> right( + choice([ + just("\\"), + just("/"), + just("\""), + to(just("b"), "\u{0008}"), + to(just("f"), "\u{000C}"), + to(just("n"), "\n"), + to(just("r"), "\r"), + to(just("t"), "\t"), + map(right(just("u"), unicode_escape_digits), fn(value) { + let assert Ok(number) = int.base_parse(string.concat(value), 16) + let assert Ok(codepoint) = string.utf_codepoint(number) + string.from_utf_codepoints([codepoint]) + }), + ]), + ) + + let str = + none_of(["\""]) + |> alt(escape) + |> many0() + |> map(string.concat) + |> between(just("\""), just("\"")) + + let value = lazy(value_parser) + + let num = + maybe(just("-")) + |> pair( + alt( + to(just("0"), ["0"]), + recognize(pair( + one_of(["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + many0(digit()), + )), + ) + |> map(string.concat), + ) + |> pair(maybe( + just(".") + |> right(many1(digit())) + |> map(string.concat), + )) + |> pair( + recognize(maybe( + alt(just("e"), just("E")) + |> pair(maybe(one_of(["+", "-"]))) + |> pair(many1(digit())), + )) + |> map(string.concat), + ) + |> map(fn(p) { + case p { + #(#(#(neg, ns), ds), ex) -> { + { + option.unwrap(neg, "") <> ns <> "." <> option.unwrap(ds, "0") <> ex + } + |> float.parse + |> result.unwrap(case neg { + Some(_) -> -1.7976931348623158e308 + None -> 1.7976931348623158e308 + }) + |> Number + } + } + }) + + let bool = + alt(to(string("true"), Boolean(True)), to(string("false"), Boolean(False))) + + let null = to(string("null"), Null) + + let array = + sep_by0(value, symbol(",")) + |> between(symbol("["), symbol("]")) + |> map(Array) + + let obj = + str + |> left(symbol(":")) + |> pair(value) + |> sep_by0(symbol(",")) + |> map(dict.from_list) + |> between(symbol("{"), symbol("}")) + |> map(Object) + + choice([num, bool, null, map(str, String), array, obj]) + |> padded() +} + +fn json_parser() -> Parser(Char, JsonValue) { + value_parser() + |> between(whitespace0(), eof()) +} + +pub type JsonParseError { + UnexpectedToken(found: Char) + UnexpectedEndOfInput +} + +pub fn parse_json(value: String) -> Result(JsonValue, JsonParseError) { + case json_parser()(chars.input(value)) { + Ok(pears.Parsed(_, j)) -> Ok(j) + Error(e) -> + Error(case e { + pears.UnexpectedToken(_, _, f) -> UnexpectedToken(f) + pears.UnexpectedEndOfInput(_, _) -> UnexpectedEndOfInput + }) + } +} + +pub type JsonQuery { + Root + Key(JsonQuery, key: String) + Index(JsonQuery, index: Int) +} + +pub type JsonQueryError { + UnexpectedType(JsonValue) + MissingObjectKey(JsonValue, key: String) + IndexOutOfBounds(JsonValue, index: Int) +} + +pub fn query_json( + json: JsonValue, + query: JsonQuery, +) -> Result(JsonValue, JsonQueryError) { + case query { + Root -> Ok(json) + Key(q, k) -> + case query_json(json, q) { + Ok(Object(o) as j) -> + dict.get(o, k) + |> result.replace_error(MissingObjectKey(j, k)) + Ok(j) -> Error(UnexpectedType(j)) + x -> x + } + Index(q, i) -> + case query_json(json, q) { + Ok(Array(a) as j) -> + list.at(a, i) + |> result.replace_error(IndexOutOfBounds(j, i)) + Ok(j) -> Error(UnexpectedType(j)) + x -> x + } + } +} diff --git a/test/jasper_test.gleam b/test/jasper_test.gleam new file mode 100644 index 0000000..ac0014f --- /dev/null +++ b/test/jasper_test.gleam @@ -0,0 +1,121 @@ +import gleeunit +import gleeunit/should +import gleam/dict +import jasper.{ + type JsonValue, Array, Boolean, Index, IndexOutOfBounds, Key, MissingObjectKey, + Null, Number, Object, Root, String, UnexpectedType, parse_json, query_json, +} + +pub fn main() { + gleeunit.main() +} + +fn should_parse(json: String, result: JsonValue) { + json + |> parse_json + |> should.equal(Ok(result)) +} + +pub fn parse_numbers_test() { + should_parse("4.2", Number(4.2)) + should_parse("42", Number(42.0)) +} + +pub fn parse_booleans_test() { + should_parse("true", Boolean(True)) + should_parse("false", Boolean(False)) +} + +pub fn parse_null_test() { + should_parse("null", Null) +} + +pub fn parse_strings_test() { + should_parse("\"hello\"", String("hello")) +} + +pub fn parse_arrays_test() { + should_parse("[]", Array([])) + should_parse("[1, 2, 3]", Array([Number(1.0), Number(2.0), Number(3.0)])) + should_parse( + "[true, false, null]", + Array([Boolean(True), Boolean(False), Null]), + ) + should_parse( + "[\"hello\", \"world\"]", + Array([String("hello"), String("world")]), + ) +} + +pub fn parse_objects_test() { + should_parse("{}", Object(dict.new())) + should_parse( + "{\"a\": 1, \"b\": 2}", + Object(dict.from_list([#("a", Number(1.0)), #("b", Number(2.0))])), + ) + should_parse( + "{\"a\": true, \"b\": false, \"c\": null}", + Object( + dict.from_list([ + #("a", Boolean(True)), + #("b", Boolean(False)), + #("c", Null), + ]), + ), + ) + should_parse( + "{\"a\": \"hello\", \"b\": \"world\"}", + Object(dict.from_list([#("a", String("hello")), #("b", String("world"))])), + ) + should_parse( + "{\"👋\": [1, 2, 3], \"b\": {\"c\": 4}}", + Object( + dict.from_list([ + #("👋", Array([Number(1.0), Number(2.0), Number(3.0)])), + #("b", Object(dict.from_list([#("c", Number(4.0))]))), + ]), + ), + ) +} + +pub fn query_test() { + query_json(String("foo"), Root) + |> should.equal(Ok(String("foo"))) + query_json( + String("foo"), + Root + |> Key("foo"), + ) + |> should.equal(Error(UnexpectedType(String("foo")))) + query_json( + String("foo"), + Root + |> Index(2), + ) + |> should.equal(Error(UnexpectedType(String("foo")))) + query_json( + Array([String("foo")]), + Root + |> Index(2), + ) + |> should.equal(Error(IndexOutOfBounds(Array([String("foo")]), 2))) + query_json( + Object(dict.from_list([#("bar", Array([String("foo")]))])), + Root + |> Key("bar") + |> Index(2), + ) + |> should.equal(Error(IndexOutOfBounds(Array([String("foo")]), 2))) + query_json( + Object(dict.from_list([#("bar", Array([String("foo")]))])), + Root + |> Key("foo") + |> Index(2), + ) + |> should.equal( + Error(MissingObjectKey( + Object(dict.from_list([#("bar", Array([String("foo")]))])), + "foo", + )), + ) +}