diff --git a/__tests__/VJsonParserTests.res b/__tests__/VJsonParserTests.res index e327abe..9c89a60 100644 --- a/__tests__/VJsonParserTests.res +++ b/__tests__/VJsonParserTests.res @@ -3,13 +3,14 @@ open Expect open VJsonTypes let expectParse = (input, handler) => - switch (input |> VJson.parseWith(VJson.defaultParseVariable), handler) { + switch (input->VJson.parse, handler) { | (Ok(vj), Ok(checks)) => vj->checks | (Error(err), Error(checks)) => err->checks - | (Error(ReludeParse.Parser.ParseError.ParseError(message)), Ok(_)) => - failwith("Expected an Ok result, but got error: " ++ message) + | (Error(e), Ok(_)) => failwith("Expected an Ok result, but got error: " ++ e) | (Ok(vj), Error(_)) => - failwith("Expected an Error result, but successfully parsed: " ++ Json.stringify(Obj.magic(vj))) + failwith( + "Expected an Error result, but successfully parsed: " ++ Js.Json.stringify(Obj.magic(vj)), + ) } // Checks that parsing is successful and the output matches the expected. @@ -20,14 +21,14 @@ let expectOkParse = (input, output) => input->expectParse(Ok(vj => expect(vj)->t let expectParseFail = (input, errMessageChecks) => input->expectParse(Error(errMessageChecks)) // Tests failure in a generic way, just that it's an Error of some kind. -let expectSomeParseFail = input => input->expectParseFail(_ => ()) +let expectSomeParseFail = input => input->expectParseFail(e => Js.log2("Some error", e)) module ParserTests = { describe("null", () => { test("single value", () => expectOkParse("null", Null)) test("array", () => expectOkParse("[null, null]", Array([Null, Null]))) test("bad array (missing closing brackets)", () => - expectParseFail("[ null, null", (ParseError(m)) => expect(m)->toEqual(stringContaining("]"))) + expectParseFail("[ null, null", m => expect(m)->toEqual(stringContaining("]"))) ) test("bad array (no comma)", () => expectSomeParseFail("[ 1 true ]")) }) @@ -44,6 +45,7 @@ module ParserTests = { } }` expectSomeParseFail(rawText) + // expectParseFail(rawText, m => expect(m)->toEqual(stringContaining("}}"))) }) }) @@ -78,17 +80,6 @@ module ParserTests = { test("single letter", () => expectOkParse("{{x}}", Variable("x"))) test("with numbers", () => expectOkParse("{{x123}}", Variable("x123"))) test("with underscore", () => expectOkParse("{{_123}}", Variable("_123"))) - test("with custom format", () => - expect("{{123}}" |> VJson.parseWithRegex(%re(`/\\d+/`)) |> Belt.Result.getExn)->toEqual( - Variable("123"), - ) - ) - test("with different type", () => - expect( - ("{{123}}" |> VJson.parseWithRegex(%re(`/\\d+/`)) |> Belt.Result.getExn) - ->VJson.map(int_of_string), - )->toEqual(Variable(123)) - ) }) describe("array", () => { @@ -112,19 +103,19 @@ module ParserTests = { }) describe("object", () => { - test("empty", () => expectOkParse("{}", Object(JsMap.empty()))) - test("empty with whitespace", () => expectOkParse("{ }", Object(JsMap.empty()))) + test("empty", () => expectOkParse("{}", Object(Js.Dict.empty()))) + test("empty with whitespace", () => expectOkParse("{ }", Object(Js.Dict.empty()))) test("single key", () => - expectOkParse("{\"x\": 1}", Object([("x", Number(1.0))]->JsMap.fromArray)) + expectOkParse("{\"x\": 1}", Object([("x", Number(1.0))]->Js.Dict.fromArray)) ) test("multiple keys", () => expectOkParse( "{\"x\": 1, \"YYY\": \"hey there!\"}", - Object([("x", Number(1.0)), ("YYY", String("hey there!"))]->JsMap.fromArray), + Object([("x", Number(1.0)), ("YYY", String("hey there!"))]->Js.Dict.fromArray), ) ) test("extra whitespace", () => - expectOkParse(" { \"x\" : 1 } ", Object([("x", Number(1.0))]->JsMap.fromArray)) + expectOkParse(" { \"x\" : 1 } ", Object([("x", Number(1.0))]->Js.Dict.fromArray)) ) test("nested", () => expectOkParse( @@ -138,10 +129,10 @@ module ParserTests = { [ ("z", Variable("myZVariable")), ("blib", Array([Number(123.0), String("hey there!")])), - ]->JsMap.fromArray, + ]->Js.Dict.fromArray, ), ), - ]->JsMap.fromArray, + ]->Js.Dict.fromArray, ), ) ) @@ -162,16 +153,16 @@ module ParserTests = { ( "variables", Object( - [("searchTerm", String("bag")), ("sortBy", Variable("sortBy"))]->JsMap.fromArray, + [("searchTerm", String("bag")), ("sortBy", Variable("sortBy"))]->Js.Dict.fromArray, ), ), ("query", String(longValue)), - ]->JsMap.fromArray, + ]->Js.Dict.fromArray, ), ) }) test("extra line", () => - expectOkParse("{\n\"id\": 5}", Object([("id", Number(5.0))]->JsMap.fromArray)) + expectOkParse("{\n\"id\": 5}", Object([("id", Number(5.0))]->Js.Dict.fromArray)) ) }) } diff --git a/__tests__/VJsonTests.res b/__tests__/VJsonTests.res index 38b4f24..b80db5a 100644 --- a/__tests__/VJsonTests.res +++ b/__tests__/VJsonTests.res @@ -6,21 +6,16 @@ let exampleJson: Js.Json.t = %raw(`{x: 1, y: 2, z: [1, 2, "hello"], q: { x: [fal let example = { open VJson - parseWith( - defaultParseVariable, - "{ + parse("{ \"id\": {{id}}, \"color\": {{color}}, \"size\": {{size}}, - }", - ) + }") }->Belt.Result.getExn let moreComplexExample = { open VJson - parseWith( - defaultParseVariable, - "{ + parse("{ \"id\": {{id}}, \"color\": {{color}}, \"size\": {{size}}, @@ -29,8 +24,7 @@ let moreComplexExample = { \"q\": 3, \"z\": {{z}} } - }", - ) + }") }->Belt.Result.getExn module FindVariablesTests = { @@ -46,13 +40,13 @@ module MapTests = { test("complex with variables", () => { open VJson.Builder let vj = object_([ - ("x", variable(1234)), - ("z", vjsonArray([float(1.0), number(2.0), variable(4321)])), + ("x", variable("1234")), + ("z", vjsonArray([float(1.0), number(2.0), variable("4321")])), ]) - expect(vj->VJson.map(string_of_int))->toEqual( + expect(vj->VJson.map(int_of_string))->toEqual( object_([ - ("x", variable("1234")), - ("z", vjsonArray([float(1.0), number(2.0), variable("4321")])), + ("x", variable(1234)), + ("z", vjsonArray([float(1.0), number(2.0), variable(4321)])), ]), ) }) @@ -61,7 +55,7 @@ module MapTests = { module SerializeTests = { let expectSerialize = (vj: vjson, str) => { - let serialized: string = vj |> VJson.serializeWrapCurlyBraces(s => s) + let serialized: string = VJson.serializeWrapCurlyBraces(s => s, vj) expect(serialized)->toEqual(str) } test("simple values", () => { @@ -85,8 +79,8 @@ module SerializeTests = { ]) } expect(vj->VJson.serialize(s => s))->toMatchSnapshot() - let serialized: string = vj |> VJson.serializeWrapCurlyBraces(s => s) - expect(serialized |> VJson.parseDefaultExn)->toEqual(vj) + let serialized: string = VJson.serializeWrapCurlyBraces(s => s, vj) + expect(serialized->VJson.parse->Belt.Result.getExn)->toEqual(vj) }) test("serializing VJson without variables produces valid json", () => { @@ -99,8 +93,8 @@ module SerializeTests = { ("q", object_([("x", Array([Bool(false)]))])), ]) } - let serialized: string = vj |> VJson.serializeWrapCurlyBraces(s => s) - expect(serialized |> Js.Json.parseExn |> Obj.magic)->toEqual({ + let serialized: string = VJson.serializeWrapCurlyBraces(s => s, vj) + expect(Obj.magic(Js.Json.parseExn(serialized)))->toEqual({ "x": 1.0, "y": 2, "z": [1.0, 2.0], @@ -121,10 +115,10 @@ module FromJsonTests = { [ ("x", number(1.0)), ("y", number(2.0)), - ("z", [number(1.0), number(2.0), string("hello")] |> array(id)), - ("q", object_([("x", [false, true] |> array(bool)), ("y", null)])), + ("z", array(id, [number(1.0), number(2.0), string("hello")])), + ("q", object_([("x", array(bool, [false, true])), ("y", null)])), ] - }->JsMap.fromArray, + }->Js.Dict.fromArray, ), ) ) @@ -135,24 +129,24 @@ module ToJsonTests = { describe("toJson", () => { let variableToJson: string => Js.Json.t = v => switch v { - | "id" => Json.Encode.int(123) - | "color" => Json.Encode.string("pink") - | _ => Json.Encode.null + | "id" => Js.Json.number(123.0) + | "color" => Js.Json.string("pink") + | _ => Js.Json.null } - expect(example |> toJson(variableToJson))->toEqual(%raw(`{id: 123, size: null, color: "pink"}`)) + expect(toJson(variableToJson)(example))->toEqual(%raw(`{id: 123, size: null, color: "pink"}`)) let variableToJson: string => Js.Json.t = v => switch v { - | "id" => Json.Encode.int(123) - | "color" => Json.Encode.string("pink") - | "x" => Json.Encode.int(-5) - | "y" => Json.Encode.bool(false) - | "z" => Json.Encode.string("yo") - | _ => Json.Encode.null + | "id" => Js.Json.number(123.0) + | "color" => Js.Json.string("pink") + | "x" => Js.Json.number(-5.0) + | "y" => Js.Json.boolean(false) + | "z" => Js.Json.string("yo") + | _ => Js.Json.null } - expect(moreComplexExample |> toJson(variableToJson))->toEqual( + expect(toJson(variableToJson)(moreComplexExample))->toEqual( %raw(`{id: 123, size: null, color: "pink", array: [-5, false], obj: {q: 3, z: "yo"}}`), ) }) @@ -160,22 +154,22 @@ module ToJsonTests = { describe("toJsonOptional", () => { let variableToJson: string => option = v => switch v { - | "id" => Json.Encode.int(123)->Some - | "color" => Json.Encode.string("pink")->Some + | "id" => Js.Json.number(123.0)->Some + | "color" => Js.Json.string("pink")->Some | _ => None } - expect(example |> toJsonOptional(variableToJson))->toEqual(%raw(`{id: 123, color: "pink"}`)) + expect(toJsonOptional(variableToJson, example))->toEqual(%raw(`{id: 123, color: "pink"}`)) let variableToJson = v => switch v { - | "id" => Json.Encode.int(123)->Some - | "color" => Json.Encode.string("blue")->Some - | "y" => Json.Encode.bool(false)->Some + | "id" => Js.Json.number(123.0)->Some + | "color" => Js.Json.string("blue")->Some + | "y" => Js.Json.boolean(false)->Some | _ => None } - expect(moreComplexExample |> toJsonOptional(variableToJson))->toEqual( + expect(toJsonOptional(variableToJson, moreComplexExample))->toEqual( %raw(`{id: 123, array: [null, false], color: "blue", obj: { q: 3}}`), ) }) diff --git a/bsconfig.json b/bsconfig.json index 34e1466..f800560 100644 --- a/bsconfig.json +++ b/bsconfig.json @@ -1,6 +1,6 @@ { "name": "@draftbit/variable-json", - "version": "0.8.1", + "version": "2.2.2", "sources": [ { "dir": "src", @@ -17,11 +17,7 @@ "in-source": true }, "suffix": ".bs.js", - "bs-dependencies": [ - "@glennsl/bs-json", - "rescript-js-collections", - "relude-parse" - ], + "bs-dependencies": [], "bs-dev-dependencies": ["reason-jest"], "warnings": { "number": "-44-23", diff --git a/package.json b/package.json index b07b936..c477a7e 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,19 @@ { "name": "@draftbit/variable-json", "private": false, - "version": "1.1.1", + "version": "2.2.2", "declaration": true, "main": "src/VJson.bs", "types": "src/index.d.ts", "scripts": { "copy-git-hooks": "if [ -e ./hooks ]; then cp -v ./hooks/* ./.git/hooks; fi", "prepare": "mkdir -p ./.git/hooks && yarn copy-git-hooks && yarn re:build", - "re:clean": "bsb -clean-world", - "re:build": "bsb -make-world", + "re:clean": "rescript clean", + "re:build": "rescript build", "re:cleanbuild": "yarn re:clean && yarn re:build", "re:formatall": "find src __tests__ -name '*.re' | xargs yarn bsrefmt --in-place", "re:formatchanged": "git diff --name-only --diff-filter=d HEAD -- '*.re' | xargs -I{} realpath --relative-to=. $(git rev-parse --show-toplevel)/'{}' | while read f; do yarn bsrefmt --in-place \"$f\"; done", - "re:watch": "bsb -make-world -w", + "re:watch": "rescript build -w", "test": "jest __tests__/*.bs.js", "npm:publish": "npm publish --access public", "npm:publish-patch": "npm version patch && yarn npm:publish && git push --tags", @@ -30,20 +30,14 @@ "author": "Allen Nelson ", "license": "MIT", "devDependencies": { - "@glennsl/bs-json": "^5.0.2", - "bs-platform": "^9.0.2", + "rescript": "^11", "jest": "^26.4.1", "lint-staged": "^10.2.11", "prettier": "^2.0.5", - "reason-jest": "^2.2.0", - "relude": "^0.59.0", - "relude-parse": "^0.8.0" + "reason-jest": "^3.0.0" }, "peerDependencies": { - "@glennsl/bs-json": "^5.0.2", - "bs-platform": "^7 || ^8 || ^9", - "relude": "^0.59.0", - "relude-parse": "^0.8.0", + "rescript": "^10 || ^11", "rescript-js-collections": "^1.8.2" }, "lint-staged": { @@ -58,7 +52,7 @@ ] }, "resolutions": { - "bs-bastet": "adnelson/bastet#no-bisect" + "bs-bastet": "adnelson/bastet#rescript-11.0" }, "dependencies": { "rescript-js-collections": "^1.8.2" diff --git a/src/Regexes.res b/src/Regexes.res new file mode 100644 index 0000000..00508ad --- /dev/null +++ b/src/Regexes.res @@ -0,0 +1,5 @@ +let number = %re(`/^([-+]?\d*\.?\d+)(?:[eE]([-+]?\d+))?/`) +let string = %re(`/^"((?:[^"\\]|\\.)*)"/`) +let escapedQuote = %re(`/\\"/g`) +let variable = %re(`/[a-zA-Z_][a-zA-Z0-9_]*/`) +let whitespace = %re(`/^\s*/`) diff --git a/src/StringParser.res b/src/StringParser.res new file mode 100644 index 0000000..d1bf25d --- /dev/null +++ b/src/StringParser.res @@ -0,0 +1,118 @@ +open VJsonUtil + +type success<'res> = {str: string, res: 'res} +type failure = {expected: string, remaining: string} +type parser<'res> = string => result, failure> + +let map: (parser<'a>, 'a => 'b) => parser<'b> = (parser, f) => str => + str->parser->Belt.Result.map(({str, res}) => {str, res: f(res)}) + +let opt: parser<'a> => parser> = parser => str => + switch str->parser { + | Ok({str: str2, res}) => Ok({str: str2, res: Some(res)}) + | _ => Ok({str, res: None}) + } + +let or_: (parser<'a>, parser<'a>) => parser<'a> = (parser1, parser2) => str => + switch str->parser1 { + | Ok(res) => Ok(res) + | Error(e1) => + switch str->parser2 { + | Ok(res) => Ok(res) + // Prefer the error message of whichever one consumed more input + | Error(e2) => Error(e1.remaining->Js.String.length < e2.remaining->Js.String.length ? e1 : e2) + } + } + +let and_: (parser<'a>, parser<'b>) => parser<('a, 'b)> = (parser1, parser2) => str1 => + switch str1->parser1 { + | Ok({str: str2, res: res1}) => + str2->parser2->Belt.Result.map(({str: str3, res: res2}) => {str: str3, res: (res1, res2)}) + | Error(e) => Error(e) + } + +let left: (parser<'a>, parser<_>) => parser<'a> = (parser1, parser2) => + parser1->and_(parser2)->map(fst) +let right: (parser<_>, parser<'a>) => parser<'a> = (parser1, parser2) => + parser1->and_(parser2)->map(snd) + +// Parse as many items of the first parser as possible. After each parse, attempt to +// parse a separator. If successful, keep parsing. Otherwise, parse with the end parser. +let manySepEnd: 'a. (parser<'a>, ~sep: parser<_>, ~end_: parser<_>) => parser> = ( + parser, + ~sep, + ~end_, +) => startStr => { + let results = [] + let str = ref(startStr) + let keepGoing = ref(true) + let error = ref(None) + let endLoop = (lastError, ~combine) => { + keepGoing := false + switch str.contents->end_ { + // The results we parsed are only valid if the endParser also matches. + | Ok({str: next}) => str := next + // Otherwise, the last failure is the "real" error. + | Error(e) => + error := + Some( + combine + ? {...lastError, expected: lastError.expected->concatIfNotIncluded(` OR ${e.expected}`)} + : lastError, + ) + } + } + + while keepGoing.contents { + switch str.contents->parser { + | Ok({str: next, res}) => { + results->Js.Array2.push(res)->ignore + switch next->sep { + | Ok({str: next2}) => str := next2 + | Error(e) => { + str := next + // After a successful item parse, either a separator or an end parse is + // valid, so if both fail, combine their errors + endLoop(e, ~combine=true) + } + } + } + // If the item parse fails, only an end parse is valid, don't combine errors + | Error(e) => endLoop(e, ~combine=false) + } + } + switch error.contents { + | None => Ok({str: str.contents, res: results}) + | Some(e) => Error(e) + } +} + +let sliceN = (str, from) => str->Js.String2.sliceToEnd(~from) + +let literal: string => parser = expected => str => + str->Js.String2.startsWith(expected) + ? Ok({str: str->sliceN(expected->Js.String.length), res: expected}) + : Error({expected: expected->quote, remaining: str}) + +let regex: (Js.Re.t, ~index: int=?, string) => parser = ( + regex, + // Which index of the returned regex capture array to return + ~index=0, + // Used to generate error message if the parser fails + description, +) => str => + switch str->Js.String2.match_(regex) { + | Some(arr) => + Ok({ + str: str->sliceN(arr[0]->Belt.Option.getExn->Js.String.length), + res: arr[index]->Belt.Option.getExn, + }) + | _ => Error({expected: description, remaining: str}) + } + +let whitespace: parser = regex(Regexes.whitespace, "whitespace") + +let lexeme: parser<'a> => parser<'a> = parser => left(parser, whitespace) + +let eof: parser = str => + str === "" ? Ok({str: "", res: ()}) : Error({expected: "end of file", remaining: str}) diff --git a/src/VJson.res b/src/VJson.res index 0cdcabc..236482c 100644 --- a/src/VJson.res +++ b/src/VJson.res @@ -1,9 +1,9 @@ // Some useful functions that operate on VJson -module Types = VJsonTypes -include Types +include VJsonTypes module Builder = VJsonBuilder +open VJsonUtil -// Traverse a JSON structure with a function +// Map over a JSON structure with a function operating on variables let rec map = (vjson, f) => switch vjson { | Variable(v) => Variable(f(v)) @@ -11,10 +11,11 @@ let rec map = (vjson, f) => | Null => Null | String(s) => String(s) | Number(n) => Number(n) - | Array(arr) => arr |> Builder.array(vj => vj->map(f)) - | Object(obj) => obj |> Builder.jsMap(k => k, vj => vj->map(f)) + | Array(arr) => Builder.array(vj => vj->map(f), arr) + | Object(obj) => Builder.dict(vj => vj->map(f), obj) } +// `map` in significant-data-last order let mapL = (f, vjson) => map(vjson, f) // Traverse a JSON structure with a function @@ -27,56 +28,49 @@ let rec reduce = (vjson, result, f) => { | Number(_) | Variable(_) => newResult | Array(arr) => arr->Belt.Array.reduce(newResult, (r, j) => j->reduce(r, f)) - | Object(obj) => obj->JsMap.valuesArray->Belt.Array.reduce(newResult, (r, j) => j->reduce(r, f)) + | Object(obj) => obj->Js.Dict.values->Belt.Array.reduce(newResult, (r, j) => j->reduce(r, f)) } } +// `reduce` in significant-data-last order let reduceL = (start, f, vjson) => reduce(vjson, start, f) // Translate to JSON, given a conversion function which must return a JSON value for each variable. let rec toJson = variableToJson => { - open Json.Encode - x => switch x { - | Null => null - | Bool(b) => b |> bool - | Number(n) => n |> float - | String(s) => s |> string - | Array(arr) => arr |> array(toJson(variableToJson)) - | Object(d) => d |> JsMap.toJson(~k=s => s, ~v=toJson(variableToJson)) - | Variable(var) => var |> variableToJson + | Null => Js.Json.null + | Bool(b) => Js.Json.boolean(b) + | Number(n) => Js.Json.number(n) + | String(s) => Js.Json.string(s) + | Array(arr) => arr->Js.Array2.map(toJson(variableToJson))->Js.Json.array + | Object(d) => d->mapDict(toJson(variableToJson))->Js.Json.object_ + | Variable(var) => variableToJson(var) } } // Translate to JSON, given a conversion function which will optionally return a JSON value for each variable. -let rec toJsonOptional: ( - 'v => option, - vjson<'v>, -) => option = variableToJson => { - open Json.Encode - - x => - switch x { - | Null => null->Some - | Bool(b) => b->bool->Some - | Number(n) => n->float->Some - | String(s) => s->string->Some - | Array(arr) => - arr - ->Js.Array2.map(vj => - vj->toJsonOptional(variableToJson, _)->Belt.Option.getWithDefault(Js.Json.null) - ) - ->Js.Json.array - ->Some - | Object(d) => - d->JsMap.keepMap(toJsonOptional(variableToJson))->JsMap.toDict->Js.Json.object_->Some - | Variable(var) => var->variableToJson - } +let rec toJsonOptional: 'v. ('v => option, vjson<'v>) => option = ( + variableToJson, + x, +) => { + switch x { + | Null => Js.Json.null->Some + | Bool(b) => b->Js.Json.boolean->Some + | Number(n) => n->Js.Json.number->Some + | String(s) => s->Js.Json.string->Some + | Array(arr) => + arr + ->Js.Array2.map(vj => + vj->(toJsonOptional(variableToJson, _))->Belt.Option.getWithDefault(Js.Json.null) + ) + ->Js.Json.array + ->Some + | Object(d) => d->keepMapDict(vj => toJsonOptional(variableToJson, vj))->Js.Json.object_->Some + | Variable(var) => var->variableToJson + } } -let quote: string => string = s => s->Js.Json.string->Js.Json.stringify - // Convert to a string of valid vjson syntax, using whatever method to serialize a variable. // It's up to the user to guarantee that the variable name serializer produces valid output, // so be careful. @@ -93,8 +87,8 @@ let rec serialize = (vj, variableToString) => | Object(o) => "{" ++ (o - ->JsMap.toArray - ->Belt.Array.map(((k, v)) => `${k->quote}: ${v->serialize(variableToString)}`) + ->Js.Dict.entries + ->Js.Array2.map(((k, v)) => `${k->quote}: ${v->serialize(variableToString)}`) ->Js.Array2.joinWith(", ") ++ "}") } @@ -111,32 +105,25 @@ let findVariables = root => vars }) -let defaultVariableRegex = %re(`/[a-zA-Z_][a-zA-Z0-9_]*/`) - -let parseWith = (parseVariable, input) => { - open ReludeParse.Parser - // Ensure that we have consumed the whole string - \"<*"(\"*>"(ws, VJsonParse.parseVJsonWithVariable(parseVariable)), eof) |> runParser(input) -} - -let parseWithExn = (parseVariable, input) => - switch parseWith(parseVariable, input) { - | Ok(vjson) => vjson - | Error(ReludeParse.Parser.ParseError.ParseError(message)) => failwith(message) - } - -// Turn a regex into a string parser using relude-parse -let parseFromRegex: (Js.Re.t, string) => result = (reg, myString) => - switch { - open ReludeParse.Parser - \"<*"(regex(reg), eof) |> runParser(myString) - } { - | Ok(variable) => Ok(variable) - | Error(ParseError(m)) => Error(m) +let defaultVariableRegex = Regexes.variable + +let parse = (~variableRegex=defaultVariableRegex, s) => { + let p = + StringParser.whitespace + ->StringParser.right(VJsonParse.parseTerm(variableRegex)) + ->StringParser.left(StringParser.eof) + switch s->p { + | Ok(r) => Ok(r.res) + | Error(e) => { + let posn = s->Js.String.length - e.remaining->Js.String.length - 1 + let location = switch getLineAndColumn(s, posn) { + | Some(lineAndCol) => renderLineAndColumn(lineAndCol) + | None => `position ${posn->Js.Int.toString}` + } + let remaining = + e.remaining->Js.String2.replaceByRe(%re(`/\s+/`), " ")->Js.String2.slice(~from=0, ~to_=20) + let remaining = remaining === "" ? "end of file" : remaining->quote + Error(`At ${location}: Expected ${e.expected} but got ${remaining}`) + } } - -let defaultParseVariable = parseFromRegex(defaultVariableRegex) -let parseAnyStringVariable = s => Ok(s) -let parseDefault = parseWith(defaultParseVariable) -let parseDefaultExn = parseWithExn(defaultParseVariable) -let parseWithRegex = regex => parseWith(parseFromRegex(regex)) +} diff --git a/src/VJson.resi b/src/VJson.resi deleted file mode 100644 index a46d41d..0000000 --- a/src/VJson.resi +++ /dev/null @@ -1,115 +0,0 @@ -// Some useful functions that operate on VJson -type vjson<'v> = VJsonTypes.vjson<'v> - -// Abbreviation -type t<'v> = vjson<'v> - -// Alter every variable in a VJson tree -let map: (vjson<'v1>, 'v1 => 'v2) => vjson<'v2> - -// `map` in significant-data-last order -let mapL: ('v1 => 'v2, vjson<'v1>) => vjson<'v2> - -// Traverse a JSON structure with a function -let reduce: (vjson<'v>, 'a, ('a, vjson<'v>) => 'a) => 'a - -// `reduce` in significant-data-last order -let reduceL: ('a, ('a, vjson<'v>) => 'a, vjson<'v>) => 'a - -// Translate to JSON, given a conversion function which must return a JSON value for each variable. -let toJson: ('v => Js.Json.t, vjson<'v>) => Js.Json.t - -// Translate to JSON, given a conversion function which will optionally return a JSON value for each variable. -let toJsonOptional: ('v => option, vjson<'v>) => option - -// Convert to a string of valid vjson syntax, using whatever method to serialize a variable. -// It's up to the user to guarantee that the variable name serializer produces valid output, -// so be careful. -let serialize: (vjson<'v>, 'v => string) => string - -// Serializes into a pure, JSON-like syntax, wrapping variables in -// pairs of curly braces. The serialization of the variable itself -// is user defined. In many cases the identify function `s => s` works fine. -// If you want to create a string without wrapping variables at all, you -// can use `serialize.` As the inverse of parsing, take care that -// this function never includes `}}` (TODO: support escape sequences) -let serializeWrapCurlyBraces: ('v => string, vjson<'v>) => string - -// Traverse the tree, returning a set of all of the variables. -let findVariables: vjson<'v> => array<'v> - -// The regex used to make the default variable parser. -let defaultVariableRegex: Js.Re.t - -// This is a parser for a basic alphanumeric identifier variable, such as -// `My_variable123`, using `defaultVariableRegex` under the hood. -// This is used in the definition of `parseDefault`. -let defaultParseVariable: string => result - -// Parses an arbitrary string as a variable, so anything between `{{` and `}}` -// will be captured by this function, even an empty string. -let parseAnyStringVariable: string => result - -// Parse a VJson tree with a custom variable parser. -let parseWith: ( - string => result<'v, string>, - string, -) => result, ReludeParse.Parser.ParseError.t> - -// Exception-throwing version of `parseWith` -let parseWithExn: (string => result<'v, string>, string) => vjson<'v> - -// Parse a VJson tree using the given regex to parse variables. -let parseWithRegex: (Js.Re.t, string) => result, ReludeParse.Parser.ParseError.t> - -// Parse a VJson tree with the default variable parser. -let parseDefault: string => result, ReludeParse.Parser.ParseError.t> - -// Exception-throwing version of `parseDefault` -let parseDefaultExn: string => vjson - -module Types: { - type rec vjson<'v> = - | Null - | Bool(bool) - | Number(float) - | String(string) - | Variable('v) - | Array(array>) - | Object(JsMap.t>) - - type t<'v> = vjson<'v> -} - -module Builder: { - let null: vjson<'v> - let bool: bool => vjson<'v> - let number: float => vjson<'v> - let float: float => vjson<'v> - let int: int => vjson<'v> - let string: string => vjson<'v> - - // Create a VJson Variable from any variable type. - let variable: 'v => vjson<'v> - - // Returns its input. Useful if some of your data is already in VJson format. - let id: vjson<'v> => vjson<'v> - - // VJson is a superset of JSON, so any JSON can be converted to VJson. - let json: Js.Json.t => vjson<'v> - - // You can use this to build an array with a conversion function - let array: ('a => vjson<'v>, array<'a>) => vjson<'v> - - // If you already have an array of vjsonObjects, use this to combine them into one - let vjsonArray: array> => vjson<'v> - - // Create a VJson object from a key/value array - let object_: array<(string, vjson<'v>)> => vjson<'v> - - // Use this to convert a dictionary of objects into a VJson object. - let dict: ('a => vjson<'v>, Js.Dict.t<'a>) => vjson<'v> - - // Use this to convert a Map of objects into a VJson object. - let jsMap: ('k => string, 'a => vjson<'v>, JsMap.t<'k, 'a>) => vjson<'v> -} diff --git a/src/VJsonBuilder.res b/src/VJsonBuilder.res index 35a6fc3..f75498b 100644 --- a/src/VJsonBuilder.res +++ b/src/VJsonBuilder.res @@ -1,4 +1,5 @@ include VJsonTypes +open VJsonUtil let null = Null let bool = b => Bool(b) @@ -13,13 +14,9 @@ let array = (toVJson, arr) => Array(arr->Belt.Array.map(toVJson)) let vjsonArray = arr => Array(arr) -let object_ = keyVals => Object(keyVals->JsMap.fromArray) +let object_ = keyVals => Object(keyVals->Js.Dict.fromArray) -let dict = (toVJson, dict) => Object(dict->JsMap.fromDict->JsMap.map(toVJson)) - -let jsMap = (keyToString, vToVJson, m) => Object( - m->JsMap.mapEntries((k, v) => (k->keyToString, v->vToVJson)), -) +let dict = (toVJson, dict) => Object(dict->mapDict(toVJson)) let rec json = j => switch j->Js.Json.classify { @@ -29,5 +26,5 @@ let rec json = j => | JSONString(s) => String(s) | JSONNumber(n) => Number(n) | JSONArray(arr) => Array(arr->Belt.Array.map(json)) - | JSONObject(obj) => Object(obj->JsMap.fromDict->JsMap.map(json)) + | JSONObject(obj) => Object(obj->mapDict(json)) } diff --git a/src/VJsonParse.res b/src/VJsonParse.res index affcc63..48cc5e2 100644 --- a/src/VJsonParse.res +++ b/src/VJsonParse.res @@ -1,91 +1,48 @@ open VJsonTypes - -let parseVJsonWithVariable = parseVariableString => { - open // (variableFromString: string => result('v, string)) => { - - ReludeParse.Parser - - let lexeme: 'a. t<'a> => t< - 'a, - > = // Run a parser and transparently consume all trailing whitespace. - p => \"<*"(p, ws) - - // Parse the "null" keyword" - let parseNull: t> = str("null") |> map(_ => Null) |> lexeme - - // Note: regex taken from https://rgxdb.com/r/1RSPF8MG, but the '$' - // at the end has been omitted because it causes parse errors - let floatRegex: Js.Re.t = %re(`/^([-+]?\\d*\\.?\\d+)(?:[eE]([-+]?\\d+))?/`) - - // Parse a number (float or int) - let parseNumber: t = - \""(regex(floatRegex), "Not a valid number") |> map(Js.Float.fromString) |> lexeme - - // Parse a boolean. - let parseBool: t = - \"<|>"(str("true") |> map(_ => true), str("false") |> map(_ => false)) |> lexeme - - let escapedQuoteRegex = %re(`/\\\\"/gm`) - let nonEscapedQuoteRegex = %re(`/((?:^|[^\\\\])(?:\\\\{2})*)"/gm`) - let inQuotesRegex = %re(`/"(?:[^"\\\\]|\\\\.)*"/`) - // Parse a string. Allows for escaped quotes. - // NOTE: not to be confused with `parse`, which takes a raw string and parses - // a whole AST -- this parses string literal syntax. - let parseString: t = - regex(inQuotesRegex) - |> map(match => { - match - ->Js.String2.replaceByRe(nonEscapedQuoteRegex, `$1`) // First group of the regex is characters before the quote that should be kept - ->Js.String2.replaceByRe(escapedQuoteRegex, `"`) - }) - |> lexeme - - // Parse a variable wrapped in a pair of doubled curly braces `{{ }}`. The - // string of text between the curly braces is parsed by `parseVariable`. - let parseVariable_ = \">>="( - \"*>"( - \"*>"(str("{{"), ws), - manyUntil(str("}}"), \"<|>"(str("\\}}") |> map(_ => "}}"), anyChar)), - ) |> map(l => l->Belt.List.toArray->Js.Array2.joinWith("")->Js.String.trim), - rawVariableString => - switch parseVariableString(rawVariableString) { - | Ok(variable) => pure(variable) - | Error(message) => fail(message) - }, - ) - - // Parse an array of VJson. - // Define these as lazy because of mutual recursion. - let rec parseArray: Lazy.t>>> = lazy ( - betweenSquares(parseVjsonLazy->Lazy.force |> sepByOptEnd(str(",") |> lexeme)) - |> map(Belt.List.toArray) - |> lexeme - ) - - and parseObject: Lazy.t>>> = lazy { - let parseKeyValuePair: t<(string, vjson<'v>)> = tuple2( - \"<*"(parseString, str(":")) |> lexeme, - parseVjsonLazy->Lazy.force, - ) - betweenCurlies(parseKeyValuePair |> sepByOptEnd(str(",") |> lexeme)) - |> map(pairs => pairs->Belt.List.toArray->Js.Dict.fromArray) - |> lexeme - } - - and parseVjsonLazy: Lazy.t>> = lazy ( - \"<|>"( - \"<|>"( - \"<|>"( - \"<|>"(parseNull, parseString |> map(s => String(s))), - parseNumber |> map(n => Number(n)), - ), - parseBool |> map(b => Bool(b)), - ), - parseVariable_ |> map(v => Variable(v)), - ) - |> orElseLazy(~fallback=() => parseArray->Lazy.force |> map(arr => Array(arr))) - |> orElseLazy(~fallback=() => parseObject->Lazy.force |> map(d => Object(d->JsMap.fromDict))) - ) - - parseVjsonLazy->Lazy.force +open StringParser + +// NOTE: these parsers only return a `vjson` with an internal type of `string`. +// In order to use another variable type, use VJson.map and convert the variables +// from strings. + +let lit = s => literal(s)->lexeme + +let parseNull: parser> = lit("null")->map(_ => Null) + +let parseBool: parser> = lit("true")->or_(lit("false"))->map(s => Bool(s === "true")) + +let parseNumber: parser> = + regex(Regexes.number, "a number")->map(s => Number(s->Js.Float.fromString))->lexeme + +let parseString: parser = + regex(Regexes.string, ~index=1, "a string literal") + ->map(str => str->Js.String2.replaceByRe(Regexes.escapedQuote, "\"")) + ->lexeme + +let parseVariable: Js.Re.t => parser> = variableRegex => + lit("{{") + ->right(regex(variableRegex, "a variable")->lexeme) + ->left(lit("}}")) + ->map(v => Variable(v)) + +let rec parseTerm: Js.Re.t => parser> = variableRegex => s => { + let parseArray = + lit("[") + ->right(manySepEnd(parseTerm(variableRegex), ~sep=lit(","), ~end_=lit("]"))) + ->map(vjs => Array(vjs)) + let parseKV = parseString->left(lit(":"))->and_(parseTerm(variableRegex)) + let parseObject = + lit("{") + ->right(manySepEnd(parseKV, ~sep=lit(","), ~end_=lit("}"))) + ->map(kvs => Object(kvs->Js.Dict.fromArray)) + + let p = + parseNull + ->or_(parseBool) + ->or_(parseNumber) + ->or_(parseString->map(s => String(s))) + ->or_(parseVariable(variableRegex)) + ->or_(parseObject) + ->or_(parseArray) + p(s) } diff --git a/src/VJsonParse.resi b/src/VJsonParse.resi deleted file mode 100644 index 1aaa911..0000000 --- a/src/VJsonParse.resi +++ /dev/null @@ -1,26 +0,0 @@ -open VJsonTypes - -// Given a parser for a variable, produces a parser of VJson. -// For example, if you want your variable syntax to be `{{myVariable}}`, then -// you can do something like - -// let parseVJson: ReludeParse.Parser.t(vjson(string)) = -// parseVJsonWithVariable( -// ReludeParse.Parser.( -// regex([%re {|/[a-zA-Z_][a-zA-Z0-9_]*/|}]) -// |> between(str("{{"), str("}}")) -// ), -// ); - -let parseVJsonWithVariable: (string => result<'v, string>) => ReludeParse.Parser.t> - -// Parses a variable in between double curly braces. The parser for the variable -// itself is up to the user. - -// let parseVJson: ReludeParse.Parser.t(vjson(string)) = -// parseVJsonWithDoubleCurlyBracesVariable( -// ReludeParse.Parser.regex([%re {|/[a-zA-Z_][a-zA-Z0-9_]*/|}]) -// ); - -//let parseVJsonWithDoubleCurlyBracesVariable: -// ReludeParse.Parser.t('v) => ReludeParse.Parser.t(vjson('v)); diff --git a/src/VJsonTypes.res b/src/VJsonTypes.res index 8e8458c..3ebb5db 100644 --- a/src/VJsonTypes.res +++ b/src/VJsonTypes.res @@ -5,6 +5,6 @@ type rec vjson<'v> = | String(string) | Variable('v) | Array(array>) - | Object(JsMap.t>) + | Object(Js.Dict.t>) type t<'v> = vjson<'v> diff --git a/src/VJsonTypes.resi b/src/VJsonTypes.resi deleted file mode 100644 index 8e8458c..0000000 --- a/src/VJsonTypes.resi +++ /dev/null @@ -1,10 +0,0 @@ -type rec vjson<'v> = - | Null - | Bool(bool) - | Number(float) - | String(string) - | Variable('v) - | Array(array>) - | Object(JsMap.t>) - -type t<'v> = vjson<'v> diff --git a/src/VJsonUtil.res b/src/VJsonUtil.res new file mode 100644 index 0000000..f9d1804 --- /dev/null +++ b/src/VJsonUtil.res @@ -0,0 +1,33 @@ +let mapDict = (dict, f) => + dict->Js.Dict.entries->Js.Array2.map(((k, v)) => (k, f(v)))->Js.Dict.fromArray +let keepMapDict = (dict, f) => + dict + ->Js.Dict.entries + ->Belt.Array.keepMap(((k, v)) => f(v)->Belt.Option.map(v2 => (k, v2))) + ->Js.Dict.fromArray +let quote: string => string = s => s->Js.Json.string->Js.Json.stringify + +let concatIfNotIncluded = (str, str2) => str->Js.String2.includes(str2) ? str : str ++ str2 + +@gentype +let tapLog = (value, description) => { + Js.log2(description, value) + value +} + +let renderLineAndColumn = ((line, column)) => + `line ${line->Js.Int.toString}, column ${column->Js.Int.toString}` + +// Given an index in a string, return the line and column number at that index. +let getLineAndColumn: (string, int) => option<(int, int)> = (str, index) => + if index >= str->Js.String.length || index < 0 { + None + } else { + let substr = str->Js.String2.slice(~from=0, ~to_=index) + let lines = substr->Js.String2.split("\n") + let nLines = lines->Js.Array2.length + Some(( + nLines, + lines->Belt.Array.get(nLines - 1)->Belt.Option.mapWithDefault(0, Js.String.length), + )) + } diff --git a/yarn.lock b/yarn.lock index 51fdf3f..7052b9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -271,10 +271,9 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@glennsl/bs-json@^5.0.2": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@glennsl/bs-json/-/bs-json-5.0.2.tgz#cfb85d94d370ec6dc17849e0ddb1a51eee08cfcc" - integrity sha512-vVlHJNrhmwvhyea14YiV4L5pDLjqw1edE3GzvMxlbPPQZVhzgO3sTWrUxCpQd2gV+CkMfk4FHBYunx9nWtBoDg== +"@glennsl/bs-json@https://github.com/adnelson/bs-json.git#rescript-10": + version "5.0.4" + resolved "https://github.com/adnelson/bs-json.git#d481c48ffbb6c7c4c4e8004d9f0e0d2efa563e11" "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" @@ -857,17 +856,12 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== -bs-bastet@adnelson/bastet#no-bisect: +bs-bastet@adnelson/bastet#rescript-11.0: version "1.2.5" - resolved "https://codeload.github.com/adnelson/bastet/tar.gz/b40c7d8eeb85f22c507e340f850ff7e3ec161c4c" + resolved "https://codeload.github.com/adnelson/bastet/tar.gz/420f27333fa07bb211a36e6ab95580ce9112eb39" dependencies: bs-stdlib-shims "^0.1.0" -bs-platform@^9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/bs-platform/-/bs-platform-9.0.2.tgz#a6eac70eb8924a322556dacaccbfbc9b2a0d3a37" - integrity sha512-Ye9JqJ4Oa7mcjjoOVRYI8Uc2Cf8N7jQLWDcdUplY7996d/YErSR7WitmV7XnSwr4EvdrbwjEsg1NxNjUQv3ChA== - bs-stdlib-shims@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/bs-stdlib-shims/-/bs-stdlib-shims-0.1.0.tgz#f64166392652a07c37b9be462beebb6c0c66a74e" @@ -2958,10 +2952,10 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" -reason-jest@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/reason-jest/-/reason-jest-2.2.0.tgz#6c8f1ce01fa730b9a1430a5c6b00f520ee154e8f" - integrity sha512-rsueII7rtAk+woYrC10hqBQdn+FJvDxy8aSWxXwEk3KCHaXFFW0hINrO3har5CRuBNG9l+Yof99Q5agbv6EZ6g== +reason-jest@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/reason-jest/-/reason-jest-3.0.0.tgz#1c5e44f10ff6ba87b3d2d79506717ffd7afe61a2" + integrity sha512-MfaISH3+6VzGWIeRPooH1D7hYZvgeJXTMEz9iaBvt8aElpXUoBxTaJJBXenJoWNydyZo1ckgVBuq9XxYRtGqYA== regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" @@ -2971,15 +2965,13 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -relude-parse@^0.8.0: +"relude-parse@https://github.com/adnelson/relude-parse.git#rescript-11.1": version "0.8.0" - resolved "https://registry.yarnpkg.com/relude-parse/-/relude-parse-0.8.0.tgz#6aa7e2b1d5bc1b28d4ee2eb0a304c63f73fad25c" - integrity sha512-KsPpD4Wi5Nswe9FpeT5I2/DvD+pClgB6kmVXYqd94WrS6wxsxHUyl0ueSGWTTjbsrubNSph2YtxD1zElIGdBbA== + resolved "https://github.com/adnelson/relude-parse.git#cd0543e350a65ce3d8e3b44a74b17eb2c0114fa9" -relude@^0.59.0: - version "0.59.0" - resolved "https://registry.yarnpkg.com/relude/-/relude-0.59.0.tgz#b396ecd5ec4d8cd974782d4bf4b1af1d3fafb378" - integrity sha512-Rc+Lr2SjsGJAejuF7yHqzk3Gsr0Ha4JpdRTn+17DYxbQjLVNHPcO24JSGBHaADdyHJMuNbCHqr/1O+LWb2eIaQ== +"relude@https://github.com/adnelson/relude.git#rescript-11.0": + version "0.66.1" + resolved "https://github.com/adnelson/relude.git#24522d276e91bc57e36a7690c4d607b5ebad1dae" remove-trailing-separator@^1.0.1: version "1.1.0" @@ -3053,6 +3045,11 @@ rescript-js-collections@^1.8.2: resolved "https://registry.yarnpkg.com/rescript-js-collections/-/rescript-js-collections-1.8.2.tgz#b75a331f3b3046a3c800b6d812c20a3adc0d2fbe" integrity sha512-ngyYsu6yfgvONCmjstx+ImH+VtX2HcxY4KrQEoR488nVoMtJRfIzp8vGcExsh8yuILLE/AHABxeJmppwodcEBg== +rescript@^11: + version "11.1.4" + resolved "https://registry.yarnpkg.com/rescript/-/rescript-11.1.4.tgz#9a42ebc4fc5363707e39cef5b3188160b63bee42" + integrity sha512-0bGU0bocihjSC6MsE3TMjHjY0EUpchyrREquLS8VsZ3ohSMD+VHUEwimEfB3kpBI1vYkw3UFZ3WD8R28guz/Vw== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"