diff --git a/src/parser.ts b/src/parser.ts index b1f96b4..fd01a1e 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -26,9 +26,9 @@ const f = (mu1: MonoType, mu2: MonoType, ...rest: MonoType[]): TypeFunctionAppli const [mu3, ...extra] = rest; return {type: 'ty-app', C: '->', mus: [mu1, f(mu2, mu3, ...extra)]}; }; -const a: TypeVariable = {type: 'ty-var', a: 'a'}; -const b: TypeVariable = {type: 'ty-var', a: 'b'}; -// const c: TypeVariable = {type: 'ty-var', a: 'c'}; +const makeTypeVars = (n: number): TypeVariable[] => + [...'abcdefghijklmnopqrstuvwxyz'].slice(0, n).map(a => ({type: 'ty-var', a})); +const [a, b] = makeTypeVars(2); const forall = (typevars: TypeVariable[], sigma: PolyType): TypeQuantifier => { const [{a: name}, ...rest] = typevars; return {type: 'ty-quantifier', a: name, sigma: rest.length ? forall(rest, sigma) : sigma}; @@ -47,6 +47,7 @@ export const defaultContext = makeContext({ missing_some: f(number, array(string), array(string)), // Logic and Boolean Operations if: forall([a, b], f(a, b, b, b)), + '?:': forall([a, b], f(a, b, b, b)), // ternary from tests.json // TODO: should the parameters of (in-)equaility be of the same type 🤔 // forcing === and !== isn't a eslint rule for nothing... '==': forall([a, b], f(a, b, bool)), @@ -141,12 +142,30 @@ const maybeJsonLogicExpression = (json: JSONValue, context: Context): json is JS */ const repr = (thing: unknown): string => JSON.stringify(thing) || thing?.toString?.() || ''; +/** + * @thing - any JSONValue + * @return a string for display the type of `thing` to the user + */ +const reprType = (thing: JSONValue, context: Context): string => { + if (typeof thing === 'boolean') return 'Boolean'; + if (thing === null) return 'Null'; + if (typeof thing === 'number') return 'Number'; + if (typeof thing === 'string') return 'String'; + if (Array.isArray(thing)) return 'Array'; + try { + if (thing && maybeJsonLogicExpression(thing, context)) { + return 'JsonLogic rule'; + } + } catch (error) {} + return 'Object'; +}; + /** * Turn exp `{"var": "foo"}` into `["var", ["foo"]]` */ const destructureJsonLogicExpression = ( exp: Readonly, - context: Readonly, + context: Readonly ): [operation: string, params: JSONArray] => { const [operation, ...tooMany] = operationsFrom(exp, context); if (tooMany.length) { @@ -287,11 +306,24 @@ export const parseJsonLogicExpression = ( if (operation === 'var') { const [varPath, defaultValue, ...tooMany] = args; // parseErrors + if (varPath === undefined) + throw Error( + `The "var" operation needs a string or positive integer argument.\n` + + `It's missing in ${repr(json)}. Did you mean {"var": [""]} ?` + ); if (!isString(varPath) && !isNatural(varPath)) - throw Error(`The argument of "var" operation should be a string or positive integer - I found a ${typeof varPath} in: ${repr(json)}.`); + throw Error( + `The argument of a "var" operation should be a string or positive integer.\n` + + `I found a ${reprType(varPath, context)} in: ${repr(json)}.${ + !varPath + ? '\nDid you mean {"var": [""]} ?' + : maybeJsonLogicExpression(varPath, context) + ? '\nIt could be correct; "var" can take a rule that describes a string or positive integer value. But I can\'t judge the correctness of its further use, because that completely depends on the data.' + : '' + }` + ); if (tooMany.length) { - throw Error(`JsonLogicExpression may only contain one operation. + throw Error(`The "var" operation takes only one value. I found ${repr(args)} in: ${repr(json)}. Maybe something went wrong with your braces?`); } @@ -317,11 +349,49 @@ export const parseJsonLogicExpression = ( operation = `3-ary ${operation}`; } else if (new Set(['-', '+']).has(operation) && args.length === 1) { operation = `1-ary ${operation}`; - } else if ((operation === '+' || operation === '*') && args.length > 2) { + } else if ((operation === '+' || operation === '*') && args.length != 2) { + // NB unary + should be handled already... // monomorphise sum and product versions of + and * operation = `${args.length}-ary ${operation}`; // add n-ary function to the context as f(n+1 numbers) (one extra for the return value) context[operation] = f(number, number, ...Array(args.length - 1).fill(number)); + } else if ((operation === 'and' || operation === 'or') && args.length != 2) { + if (args.length === 1) + throw Error( + `This ${operation} operation is incomplete ${repr(json)}.\n` + + `Either add more arguments or replace it with just ${args[0]}.` + ); + // variadic and/or + // monomorphise to: forall a b c ... .:: a -> b -> c -> ... -> bool + operation = `${args.length}-ary ${operation}`; + const [a, b, ...cdef] = makeTypeVars(args.length); + context[operation] = forall([a, b, ...cdef], f(a, b, ...cdef, bool)); + } else if (operation === 'if') { + if (args.length % 2 == 0 || args.length == 1) { + throw Error( + `This ${operation} operation is incomplete ${repr(json)}.${ + args.length >= 2 + ? // TODO: add a variable infer its type, (int/string) and suggest resp. 0 and "" + '\n"var" takes an odd number of values. Did you forget the value for the else case?' + : '' + }` + ); + } + if (args.length > 3) { + // it's easy to make mistakes in long "elif chains" + // let's enforce explicit bools instead of truthy values + operation = `${args.length}-ary ${operation}`; + // bool, a, bool, a, ..., a, a + context[operation] = f( + bool, + a, + ...Array((args.length - 3) / 2) + .fill(null) + .flatMap(_ => [bool, a]), + a, + a + ); + } } else if (new Set(['map', 'filter', 'all', 'some', 'none']).has(operation)) { const [newContext, [arrayExp, e2]] = parseValues(args, context); return [ diff --git a/test-d/jsonlogic.test.ts b/test-d/jsonlogic.test.ts index 1737bbf..2f98ea9 100644 --- a/test-d/jsonlogic.test.ts +++ b/test-d/jsonlogic.test.ts @@ -4,11 +4,14 @@ import {W} from '../src/w'; import cases from './tests.json'; type Case = [rule: JSONValue, data: JSONValue, result: JSONValue]; -// type ErrorCase = [rule: JSONValue, data: JSONValue, result: JSONValue, error: string]; // obvious cast; imported json // filter "# commment string" const isCase = (testCase: string | unknown[]): testCase is Case => Array.isArray(testCase) && testCase.length == 3; +type ErrorCase = [rule: JSONValue, data: JSONValue, result: JSONValue, error: string]; + +const isErrorCase = (testCase: string | unknown[]): testCase is ErrorCase => + Array.isArray(testCase) && testCase.length == 4; const inferResultType = (jsonLogic: JSONValue, data: JSONValue): string => { const context = parseContext(data); @@ -39,4 +42,17 @@ describe('Test against JsonLogic suite', () => { expect(t).toBe(getType(result)); } ); + it.each(cases.filter(isErrorCase))( + 'can detect %j, with context %j, does not typecheck with %j and raises some Error containing %j', + (rule: JSONValue, data: JSONValue, result: JSONValue, error: string) => { + if (error.startsWith("TODO")) + expect(() => inferResultType(rule, data)).toThrow("") + else if (error.startsWith("Valid")){ + const t = inferResultType(rule, data); + expect(t).not.toBe(getType(result)); + } else + expect(() => inferResultType(rule, data)).toThrow(error) + + } + ); }); diff --git a/test-d/tests.json b/test-d/tests.json index 0bbe1c7..d115b59 100644 --- a/test-d/tests.json +++ b/test-d/tests.json @@ -24,22 +24,22 @@ [ {">":[2,1]}, {}, true ], [ {">":[1,1]}, {}, false ], [ {">":[1,2]}, {}, false ], - [ {">":["2",1]}, {}, true ], + [ {">":["2",1]}, {}, true, "TODO" ], [ {">=":[2,1]}, {}, true ], [ {">=":[1,1]}, {}, true ], [ {">=":[1,2]}, {}, false ], - [ {">=":["2",1]}, {}, true ], + [ {">=":["2",1]}, {}, true, "TODO" ], [ {"<":[2,1]}, {}, false ], [ {"<":[1,1]}, {}, false ], [ {"<":[1,2]}, {}, true ], - [ {"<":["1",2]}, {}, true ], + [ {"<":["1",2]}, {}, true, "TODO" ], [ {"<":[1,2,3]}, {}, true ], [ {"<":[1,1,3]}, {}, false ], [ {"<":[1,4,3]}, {}, false ], [ {"<=":[2,1]}, {}, false ], [ {"<=":[1,1]}, {}, true ], [ {"<=":[1,2]}, {}, true ], - [ {"<=":["1",2]}, {}, true ], + [ {"<=":["1",2]}, {}, true, "TODO" ], [ {"<=":[1,2,3]}, {}, true ], [ {"<=":[1,4,3]}, {}, false ], [ {"!":[false]}, {}, true ], @@ -54,33 +54,33 @@ [ {"or":[false,false]}, {}, false ], [ {"or":[false,false,true]}, {}, true ], [ {"or":[false,false,false]}, {}, false ], - [ {"or":[false]}, {}, false ], - [ {"or":[true]}, {}, true ], - [ {"or":[1,3]}, {}, 1 ], - [ {"or":[3,false]}, {}, 3 ], - [ {"or":[false,3]}, {}, 3 ], + [ {"or":[false]}, {}, false, "replace it with just false" ], + [ {"or":[true]}, {}, true, "replace it with just true" ], + [ {"or":[1,3]}, {}, 1, "Valid, but not a Number" ], + [ {"or":[3,false]}, {}, 3, "Valid, but not a Number" ], + [ {"or":[false,3]}, {}, 3, "Valid, but not a Number" ], [ {"and":[true,true]}, {}, true ], [ {"and":[false,true]}, {}, false ], [ {"and":[true,false]}, {}, false ], [ {"and":[false,false]}, {}, false ], [ {"and":[true,true,true]}, {}, true ], [ {"and":[true,true,false]}, {}, false ], - [ {"and":[false]}, {}, false ], - [ {"and":[true]}, {}, true ], - [ {"and":[1,3]}, {}, 3 ], + [ {"and":[false]}, {}, false, "replace it with just false" ], + [ {"and":[true]}, {}, true, "replace it with just true" ], + [ {"and":[1,3]}, {}, 3, "Valid, but not a Number" ], [ {"and":[3,false]}, {}, false ], [ {"and":[false,3]}, {}, false ], [ {"?:":[true,1,2]}, {}, 1 ], [ {"?:":[false,1,2]}, {}, 2 ], [ {"in":["Bart",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, true ], [ {"in":["Milhouse",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, false ], - [ {"in":["Spring","Springfield"]}, {}, true ], - [ {"in":["i","team"]}, {}, false ], + [ {"in":["Spring","Springfield"]}, {}, true, "TODO in str overload" ], + [ {"in":["i","team"]}, {}, false, "TODO in str overload" ], [ {"cat":"ice"}, {}, "ice" ], [ {"cat":["ice"]}, {}, "ice" ], [ {"cat":["ice","cream"]}, {}, "icecream" ], - [ {"cat":[1,2]}, {}, "12" ], - [ {"cat":["Robocop",2]}, {}, "Robocop2" ], + [ {"cat":[1,2]}, {}, "12", "TODO" ], + [ {"cat":["Robocop",2]}, {}, "Robocop2", "TODO" ], [ {"cat":["we all scream for ","ice","cream"]}, {}, "we all scream for icecream" ], [ {"%":[1,2]}, {}, 1 ], [ {"%":[2,2]}, {}, 0 ], @@ -97,18 +97,18 @@ [ {"+":[1,2]}, {}, 3 ], [ {"+":[2,2,2]}, {}, 6 ], [ {"+":[1]}, {}, 1 ], - [ {"+":["1",1]}, {}, 2 ], + [ {"+":["1",1]}, {}, 2, "TODO" ], [ {"*":[3,2]}, {}, 6 ], [ {"*":[2,2,2]}, {}, 8 ], [ {"*":[1]}, {}, 1 ], - [ {"*":["1",1]}, {}, 1 ], + [ {"*":["1",1]}, {}, 1, "TODO" ], [ {"-":[2,3]}, {}, -1 ], [ {"-":[3,2]}, {}, 1 ], [ {"-":[3]}, {}, -3 ], - [ {"-":["1",1]}, {}, 0 ], + [ {"-":["1",1]}, {}, 0, "TODO" ], [ {"/":[4,2]}, {}, 2 ], [ {"/":[2,4]}, {}, 0.5 ], - [ {"/":["1",1]}, {}, 1 ], + [ {"/":["1",1]}, {}, 1, "TODO" ], "Substring", [{"substr":["jsonlogic", 4]}, null, "logic"], @@ -129,19 +129,19 @@ [{"merge":[[1, 2], [3]]}, null, [1,2,3]], [{"merge":[[1], [2, 3]]}, null, [1,2,3]], "Given non-array arguments, merge converts them to arrays", - [{"merge":1}, null, [1]], - [{"merge":[1,2]}, null, [1,2]], - [{"merge":[1,[2]]}, null, [1,2]], + [{"merge":1}, null, [1], "TODO: merge doensn't cast"], + [{"merge":[1,2]}, null, [1,2], "TODO: merge doensn't cast"], + [{"merge":[1,[2]]}, null, [1,2], "TODO: merge doensn't cast"], "Too few args", - [{"if":[]}, null, null], - [{"if":[true]}, null, true], - [{"if":[false]}, null, false], - [{"if":["apple"]}, null, "apple"], + [{"if":[]}, null, null, "incomplete"], + [{"if":[true]}, null, true, "incomplete"], + [{"if":[false]}, null, false, "incomplete"], + [{"if":["apple"]}, null, "apple", "incomplete"], "Simple if/then/else cases", - [{"if":[true, "apple"]}, null, "apple"], - [{"if":[false, "apple"]}, null, null], + [{"if":[true, "apple"]}, null, "apple", "incomplete"], + [{"if":[false, "apple"]}, null, null, "incomplete"], [{"if":[true, "apple", "banana"]}, null, "apple"], [{"if":[false, "apple", "banana"]}, null, "banana"], @@ -170,23 +170,23 @@ "Truthy and falsy definitions matter in Boolean operations", [{"!" : [ [] ]}, {}, true], [{"!!" : [ [] ]}, {}, false], - [{"and" : [ [], true ]}, {}, [] ], + [{"and" : [ [], true ]}, {}, [], "Valid, but not and Array" ], [{"or" : [ [], true ]}, {}, true ], [{"!" : [ 0 ]}, {}, true], [{"!!" : [ 0 ]}, {}, false], - [{"and" : [ 0, true ]}, {}, 0 ], + [{"and" : [ 0, true ]}, {}, 0, "Valid, but not a Number" ], [{"or" : [ 0, true ]}, {}, true ], [{"!" : [ "" ]}, {}, true], [{"!!" : [ "" ]}, {}, false], - [{"and" : [ "", true ]}, {}, "" ], + [{"and" : [ "", true ]}, {}, "", "Valid, but not a String" ], [{"or" : [ "", true ]}, {}, true ], [{"!" : [ "0" ]}, {}, false], [{"!!" : [ "0" ]}, {}, true], [{"and" : [ "0", true ]}, {}, true ], - [{"or" : [ "0", true ]}, {}, "0" ], + [{"or" : [ "0", true ]}, {}, "0", "Valid, but not a String" ], "If the conditional is logic, it gets evaluated", [{"if":[ {">":[2,1]}, "apple", "banana"]}, null, "apple"], @@ -197,17 +197,17 @@ [{"if":[ false, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "banana"], "If/then/elseif/then cases", - [{"if":[true, "apple", true, "banana"]}, null, "apple"], - [{"if":[true, "apple", false, "banana"]}, null, "apple"], - [{"if":[false, "apple", true, "banana"]}, null, "banana"], - [{"if":[false, "apple", false, "banana"]}, null, null], + [{"if":[true, "apple", true, "banana"]}, null, "apple", "incomplete"], + [{"if":[true, "apple", false, "banana"]}, null, "apple", "incomplete"], + [{"if":[false, "apple", true, "banana"]}, null, "banana", "incomplete"], + [{"if":[false, "apple", false, "banana"]}, null, null, "incomplete"], [{"if":[true, "apple", true, "banana", "carrot"]}, null, "apple"], [{"if":[true, "apple", false, "banana", "carrot"]}, null, "apple"], [{"if":[false, "apple", true, "banana", "carrot"]}, null, "banana"], [{"if":[false, "apple", false, "banana", "carrot"]}, null, "carrot"], - [{"if":[false, "apple", false, "banana", false, "carrot"]}, null, null], + [{"if":[false, "apple", false, "banana", false, "carrot"]}, null, null, "incomplete"], [{"if":[false, "apple", false, "banana", false, "carrot", "date"]}, null, "date"], [{"if":[false, "apple", false, "banana", true, "carrot", "date"]}, null, "carrot"], [{"if":[false, "apple", true, "banana", false, "carrot", "date"]}, null, "banana"], @@ -219,7 +219,7 @@ "Arrays with logic", [[1, {"var": "x"}, 3], {"x": 2}, [1, 2, 3]], - [{"if": [{"var": "x"}, [{"var": "y"}], 99]}, {"x": true, "y": 42}, [42]], + [{"if": [{"var": "x"}, [{"var": "y"}], 99]}, {"x": true, "y": 42}, [42], "TODO: Array vs Number"], "# Compound Tests", [ {"and":[{">":[3,1]},true]}, {}, true ], @@ -230,28 +230,28 @@ "# Data-Driven", [ {"var":["a"]},{"a":1},1 ], - [ {"var":["b"]},{"a":1},null ], - [ {"var":["a"]},null,null ], + [ {"var":["b"]},{"a":1},null, "Valid or TODO: undefined" ], + [ {"var":["a"]},null,null, "Valid or TODO: undefined" ], [ {"var":"a"},{"a":1},1 ], - [ {"var":"b"},{"a":1},null ], - [ {"var":"a"},null,null ], - [ {"var":["a", 1]},null,1 ], - [ {"var":["b", 2]},{"a":1},2 ], + [ {"var":"b"},{"a":1},null, "Valid or TODO: undefined" ], + [ {"var":"a"},null,null, "Valid or TODO: undefined" ], + [ {"var":["a", 1]},null,1, "rewrite" ], + [ {"var":["b", 2]},{"a":1},2, "rewrite" ], [ {"var":"a.b"},{"a":{"b":"c"}},"c" ], - [ {"var":"a.q"},{"a":{"b":"c"}},null ], - [ {"var":["a.q", 9]},{"a":{"b":"c"}},9 ], + [ {"var":"a.q"},{"a":{"b":"c"}},null, "Valid or TODO: undefined" ], + [ {"var":["a.q", 9]},{"a":{"b":"c"}},9, "rewrite" ], [ {"var":1}, ["apple","banana"], "banana" ], [ {"var":"1"}, ["apple","banana"], "banana" ], [ {"var":"1.1"}, ["apple",["banana","beer"]], "beer" ], [ {"and":[{"<":[{"var":"temp"},110]},{"==":[{"var":"pie.filling"},"apple"]}]},{"temp":100,"pie":{"filling":"apple"}},true ], - [ {"var":[{"?:":[{"<":[{"var":"temp"},110]},"pie.filling","pie.eta"]}]},{"temp":100,"pie":{"filling":"apple","eta":"60s"}},"apple" ], + [ {"var":[{"?:":[{"<":[{"var":"temp"},110]},"pie.filling","pie.eta"]}]},{"temp":100,"pie":{"filling":"apple","eta":"60s"}},"apple", "could be correct" ], [ {"in":[{"var":"filling"},["apple","cherry"]]},{"filling":"apple"},true ], - [ {"var":"a.b.c"}, null, null ], - [ {"var":"a.b.c"}, {"a":null}, null ], - [ {"var":"a.b.c"}, {"a":{"b":null}}, null ], + [ {"var":"a.b.c"}, null, null, "Valid or TODO: undefined" ], + [ {"var":"a.b.c"}, {"a":null}, null, "Valid or TODO: undefined" ], + [ {"var":"a.b.c"}, {"a":{"b":null}}, null, "Valid or TODO: undefined" ], [ {"var":""}, 1, 1 ], - [ {"var":null}, 1, 1 ], - [ {"var":[]}, 1, 1 ], + [ {"var":null}, 1, 1, "Did you mean {\"var\": [\"\"]}" ], + [ {"var":[]}, 1, 1, "Did you mean {\"var\": [\"\"]}" ], "Missing", [{"missing":[]}, null, []], @@ -293,13 +293,15 @@ [ {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, {"financing":true}, - ["vin","apr"] + ["vin","apr"], + "TODO: merge doensn't cast \"vin\" to [\"vin\"]" ], [ {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} }, {"financing":false}, - ["vin"] + ["vin"], + "TODO: merge doensn't cast \"vin\" to [\"vin\"]" ], "Filter, map, all, none, and some",