diff --git a/internal/json/lex.go b/internal/json/lex.go index 9003bf1e..19ccd9d7 100644 --- a/internal/json/lex.go +++ b/internal/json/lex.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020, 2021, Maxime Soulé +// Copyright (c) 2020-2022, Maxime Soulé // All rights reserved. // // This source code is licensed under the BSD-style license found in the @@ -19,6 +19,8 @@ import ( "github.com/maxatome/go-testdeep/internal/util" ) +const delimiters = " \t\r\n,}]()" + type Position struct { bpos int Pos int @@ -57,7 +59,6 @@ type json struct { type ParseOpts struct { Placeholders []any PlaceholdersByName map[string]any - OpShortcutFn func(string, Position) (any, bool) OpFn func(Operator, Position) (any, error) } @@ -71,13 +72,11 @@ func Parse(buf []byte, opts ...ParseOpts) (any, error) { if len(opts) > 0 { j.opts = opts[0] } - yyParse(&j) - if len(j.errs) > 0 { + if !j.parse() { if len(j.errs) == 1 { return nil, j.errs[0] } - errStr := bytes.NewBufferString(j.errs[0].Error()) for _, err := range j.errs[1:] { errStr.WriteByte('\n') @@ -85,10 +84,15 @@ func Parse(buf []byte, opts ...ParseOpts) (any, error) { } return nil, errors.New(errStr.String()) } - return j.value, nil } +// parse returns true if no errors occurred during parsing. +func (j *json) parse() bool { + yyParse(j) + return len(j.errs) == 0 +} + // Lex implements yyLexer interface. func (j *json) Lex(lval *yySymType) int { return j.nextToken(lval) @@ -114,6 +118,19 @@ func (j *json) Error(s string) { } } +func (j *json) newOperator(name string, params []any) any { + if name == "" { + return nil // an operator error is in progress + } + opPos := j.popPos() + op, err := j.getOperator(Operator{Name: name, Params: params}, opPos) + if err != nil { + j.fatal(err.Error(), opPos) + return nil + } + return op +} + func (j *json) pushPos(pos Position) { j.stackPos = append(j.stackPos, pos) } @@ -130,13 +147,6 @@ func (j *json) moveHoriz(bytes int, runes ...int) { j.curSize = 0 } -func (j *json) getOperatorShortcut(operator string, opPos Position) (any, bool) { - if j.opts.OpShortcutFn == nil { - return nil, false - } - return j.opts.OpShortcutFn(operator, opPos) -} - func (j *json) getOperator(operator Operator, opPos Position) (any, error) { if j.opts.OpFn == nil { return nil, fmt.Errorf("unknown operator %q", operator.Name) @@ -160,23 +170,20 @@ func (j *json) nextToken(lval *yySymType) int { if !ok { return 0 } + return j.analyzeStringContent(s, firstPos, lval) - // Check for placeholder ($1 or $name) or operator shortcut ($^Nil) - if len(s) <= 1 || !strings.HasPrefix(s, "$") { - lval.string = s - return STRING - } - // Double $$ at start of strings escape a $ - if strings.HasPrefix(s[1:], "$") { - lval.string = s[1:] - return STRING + case 'r': // raw string, aka r!str! or r (ws possible bw r & start delim) + if !j.skipWs() { + j.fatal("cannot find r start delimiter") + return 0 } - token, value := j.parseDollarToken(s[1:], firstPos) - if token != 0 { - lval.value = value + firstPos := j.pos.incHoriz(1) + s, ok := j.parseRawString() + if !ok { + return 0 } - return token + return j.analyzeStringContent(s, firstPos, lval) case 'n': // null if j.remain() >= 4 && bytes.Equal(j.buf[j.pos.bpos+1:j.pos.bpos+4], []byte(`ull`)) { @@ -210,7 +217,7 @@ func (j *json) nextToken(lval *yySymType) int { case '$': var dollarToken string - end := bytes.IndexAny(j.buf[j.pos.bpos+1:], " \t\r\n,}])") + end := bytes.IndexAny(j.buf[j.pos.bpos+1:], delimiters) if end >= 0 { dollarToken = string(j.buf[j.pos.bpos+1 : j.pos.bpos+1+end]) } else { @@ -221,10 +228,12 @@ func (j *json) nextToken(lval *yySymType) int { return '$' } - token, value := j.parseDollarToken(dollarToken, j.pos) - if token != 0 { - lval.value = value + token, value := j.parseDollarToken(dollarToken, j.pos, false) + if token == OPERATOR { + lval.string = value.(string) + return OPERATOR } + lval.value = value j.moveHoriz(1+len(dollarToken), 1+utf8.RuneCountInString(dollarToken)) return token @@ -325,11 +334,14 @@ str: return "", false } - default: + default: //nolint: gocritic if r < ' ' || r > utf8.MaxRune { j.fatal("invalid character in string") return "", false } + fallthrough + + case '\n', '\r', '\t': // not normally accepted by JSON spec if b != nil { b.WriteRune(r) } @@ -340,6 +352,89 @@ str: return "", false } +func (j *json) parseRawString() (string, bool) { + // j.buf[j.pos.bpos] == first non-ws rune after 'r' → caller responsibility + + savePos := j.pos + startDelim, _ := j.getRune() // cannot fail, caller called j.skipWs() + + var endDelim rune + switch startDelim { + case '(': + endDelim = ')' + case '{': + endDelim = '}' + case '[': + endDelim = ']' + case '<': + endDelim = '>' + default: + if startDelim == '_' || + (!unicode.IsPunct(startDelim) && !unicode.IsSymbol(startDelim)) { + j.fatal(fmt.Sprintf("invalid r delimiter %q, should be either a punctuation or a symbol rune, excluding '_'", + startDelim)) + return "", false + } + endDelim = startDelim + } + + from := j.pos.bpos + j.curSize + + for innerDelim := 0; ; { + r, ok := j.getRune() + if !ok { + break + } + + switch r { + case startDelim: + if startDelim == endDelim { + return string(j.buf[from:j.pos.bpos]), true + } + innerDelim++ + + case endDelim: + if innerDelim == 0 { + return string(j.buf[from:j.pos.bpos]), true + } + innerDelim-- + + case '\n', '\r', '\t': // accept these raw bytes + default: + if r < ' ' || r > utf8.MaxRune { + j.fatal("invalid character in raw string") + return "", false + } + } + } + + j.fatal("unterminated raw string", savePos) + return "", false +} + +// analyzeStringContent checks whether s contains $ prefix or not. If +// yes, it tries to parse it. +func (j *json) analyzeStringContent(s string, strPos Position, lval *yySymType) int { + if len(s) <= 1 || !strings.HasPrefix(s, "$") { + lval.string = s + return STRING + } + // Double $$ at start of strings escape a $ + if strings.HasPrefix(s[1:], "$") { + lval.string = s[1:] + return STRING + } + + // Check for placeholder ($1 or $name) or operator call as $^Empty + // or $^Re(q<\d+>) + token, value := j.parseDollarToken(s[1:], strPos, true) + // in string, j.parseDollarToken can never return an OPERATOR + // token. In case an operator is embedded in string, a SUB_PARSER is + // returned instead. + lval.value = value + return token +} + const ( numInt = 1 << iota numFloat @@ -416,10 +511,10 @@ func (j *json) parseNumber() (float64, bool) { return f, true } -// parseDollarToken parses a $123 or $tag or $^Shortcut token. -// dollarToken is never empty, does not contain '$' and dollarPos -// is the '$' position. -func (j *json) parseDollarToken(dollarToken string, dollarPos Position) (int, any) { +// parseDollarToken parses a $123 or $tag or $^Operator or +// $^Operator(PARAMS…) token. dollarToken is never empty, does not +// contain '$' and dollarPos is the '$' position. +func (j *json) parseDollarToken(dollarToken string, dollarPos Position, inString bool) (int, any) { firstRune, _ := utf8.DecodeRuneInString(dollarToken) // Test for $123 @@ -456,16 +551,42 @@ func (j *json) parseDollarToken(dollarToken string, dollarPos Position) (int, an return PLACEHOLDER, j.opts.Placeholders[np-1] } - // Test for operator shortcut + // Test for operator call $^Operator or $^Operator(…) if firstRune == '^' { - op, ok := j.getOperatorShortcut(dollarToken[1:], dollarPos) + nextRune, _ := utf8.DecodeRuneInString(dollarToken[1:]) + if nextRune < 'A' || nextRune > 'Z' { + j.error(`$^ must be followed by an operator name`, dollarPos) + if inString { + return SUB_PARSER, nil // continue parsing + } + return OPERATOR, "" // continue parsing + } + + if inString { + jr := json{ + buf: []byte(dollarToken[1:]), + pos: Position{ + Pos: dollarPos.Pos + 2, + Line: dollarPos.Line, + Col: dollarPos.Col + 2, + }, + opts: j.opts, + } + if !jr.parse() { + j.errs = append(j.errs, jr.errs...) + return SUB_PARSER, nil // continue parsing + } + return SUB_PARSER, jr.value + } + + j.moveHoriz(2) + j.lastTokenPos = j.pos + operator, ok := j.parseOperator() if !ok { - j.error( - fmt.Sprintf(`bad operator shortcut "$%s"`, dollarToken), - dollarPos) - // continue parsing + return OPERATOR, "" } - return OPERATOR_SHORTCUT, op + j.pushPos(j.lastTokenPos) + return OPERATOR, operator } // Test for $tag @@ -491,17 +612,14 @@ func (j *json) parseOperator() (string, bool) { i := j.pos.bpos + 1 l := len(j.buf) -operator: for ; i < l; i++ { - switch r := j.buf[i]; r { - case ' ', '\t', '\r', '\n', ',', '}', ']', '(': - break operator - - default: - if (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') { - j.fatal(fmt.Sprintf(`invalid operator name %q`, string(j.buf[j.pos.bpos:i+1]))) - return "", false - } + if bytes.ContainsAny(j.buf[i:i+1], delimiters) { + break + } + if r := j.buf[i]; (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') { + j.fatal(fmt.Sprintf(`invalid operator name %q`, string(j.buf[j.pos.bpos:i+1]))) + j.moveHoriz(i - j.pos.bpos) + return "", false } } diff --git a/internal/json/parser.go b/internal/json/parser.go index cef938cb..5dff0fc6 100644 --- a/internal/json/parser.go +++ b/internal/json/parser.go @@ -1,5 +1,5 @@ // Code generated by goyacc -l -o parser.go parser.y. DO NOT EDIT. -// Copyright (c) 2020, 2021, Maxime Soulé +// Copyright (c) 2020-2022, Maxime Soulé // All rights reserved. // // This source code is licensed under the BSD-style license found in the @@ -42,7 +42,7 @@ const FALSE = 57347 const NULL = 57348 const NUMBER = 57349 const PLACEHOLDER = 57350 -const OPERATOR_SHORTCUT = 57351 +const SUB_PARSER = 57351 const STRING = 57352 const OPERATOR = 57353 @@ -55,7 +55,7 @@ var yyToknames = [...]string{ "NULL", "NUMBER", "PLACEHOLDER", - "OPERATOR_SHORTCUT", + "SUB_PARSER", "STRING", "OPERATOR", "'{'", @@ -85,20 +85,20 @@ const yyPrivate = 57344 const yyLast = 86 var yyAct = [...]int{ - 22, 2, 8, 9, 10, 7, 11, 14, 6, 15, - 12, 21, 24, 37, 13, 27, 5, 39, 38, 8, - 9, 10, 7, 11, 14, 6, 15, 12, 34, 23, - 36, 13, 25, 26, 30, 18, 31, 4, 36, 8, - 9, 10, 7, 11, 14, 6, 15, 12, 17, 3, - 1, 13, 35, 8, 9, 10, 7, 11, 14, 6, - 15, 12, 33, 0, 0, 13, 20, 8, 9, 10, - 7, 11, 14, 6, 15, 12, 0, 19, 29, 13, + 22, 2, 9, 10, 11, 8, 12, 6, 7, 15, + 13, 21, 24, 37, 14, 27, 5, 39, 38, 9, + 10, 11, 8, 12, 6, 7, 15, 13, 34, 23, + 36, 14, 25, 26, 30, 18, 31, 4, 36, 9, + 10, 11, 8, 12, 6, 7, 15, 13, 17, 3, + 1, 14, 35, 9, 10, 11, 8, 12, 6, 7, + 15, 13, 33, 0, 0, 14, 20, 9, 10, 11, + 8, 12, 6, 7, 15, 13, 0, 19, 29, 14, 32, 28, 19, 0, 0, 16, } var yyPact = [...]int{ 63, -1000, -1000, -1000, -1000, -1000, -1000, -1000, -1000, -1000, - -1000, -1000, 72, 49, -1000, -6, -1000, 19, -1000, 0, + -1000, -1000, -1000, 72, 49, -6, -1000, 19, -1000, 0, -1000, 64, -1000, -1000, 15, -1000, 67, 63, -1000, 35, -1000, -1, -1000, -1000, -1000, -1000, -1000, -2, -1000, -1000, } @@ -109,28 +109,28 @@ var yyPgo = [...]int{ var yyR1 = [...]int{ 0, 1, 8, 8, 8, 8, 8, 8, 8, 8, - 8, 2, 2, 2, 3, 3, 4, 5, 5, 5, - 6, 6, 7, 7, 7, 9, 9, + 8, 8, 2, 2, 2, 3, 3, 4, 5, 5, + 5, 6, 6, 7, 7, 7, 9, 9, } var yyR2 = [...]int{ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 2, 3, 4, 1, 3, 3, 2, 3, 4, - 1, 3, 2, 3, 4, 1, 2, + 1, 1, 2, 3, 4, 1, 3, 3, 2, 3, + 4, 1, 3, 2, 3, 4, 2, 1, } var yyChk = [...]int{ - -1000, -1, -8, -2, -5, -9, 10, 7, 4, 5, - 6, 8, 12, 16, 9, 11, 13, -3, -4, 10, + -1000, -1, -8, -2, -5, -9, 9, 10, 7, 4, + 5, 6, 8, 12, 16, 11, 13, -3, -4, 10, 17, -6, -8, -7, 18, 13, 14, 15, 17, 14, 19, -6, 13, -4, -8, 17, -8, 14, 19, 19, } var yyDef = [...]int{ 0, -2, 1, 2, 3, 4, 5, 6, 7, 8, - 9, 10, 0, 0, 25, 0, 11, 0, 14, 0, - 17, 0, 20, 26, 0, 12, 0, 0, 18, 0, - 22, 0, 13, 15, 16, 19, 21, 0, 23, 24, + 9, 10, 11, 0, 0, 27, 12, 0, 15, 0, + 18, 0, 21, 26, 0, 13, 0, 0, 19, 0, + 23, 0, 14, 16, 17, 20, 22, 0, 24, 25, } var yyTok1 = [...]int{ @@ -514,39 +514,44 @@ yydefault: yyVAL.value = yyDollar[1].value } case 5: + yyDollar = yyS[yypt-1 : yypt+1] + { + yyVAL.value = yyDollar[1].value + } + case 6: yyDollar = yyS[yypt-1 : yypt+1] { yyVAL.value = yyDollar[1].string } - case 11: + case 12: yyDollar = yyS[yypt-2 : yypt+1] { yyVAL.object = map[string]any{} } - case 12: + case 13: yyDollar = yyS[yypt-3 : yypt+1] { yyVAL.object = yyDollar[2].object } - case 13: + case 14: yyDollar = yyS[yypt-4 : yypt+1] { yyVAL.object = yyDollar[2].object } - case 14: + case 15: yyDollar = yyS[yypt-1 : yypt+1] { yyVAL.object = map[string]any{ yyDollar[1].member.key: yyDollar[1].member.value, } } - case 15: + case 16: yyDollar = yyS[yypt-3 : yypt+1] { yyDollar[1].object[yyDollar[3].member.key] = yyDollar[3].member.value yyVAL.object = yyDollar[1].object } - case 16: + case 17: yyDollar = yyS[yypt-3 : yypt+1] { yyVAL.member = member{ @@ -554,42 +559,42 @@ yydefault: value: yyDollar[3].value, } } - case 17: + case 18: yyDollar = yyS[yypt-2 : yypt+1] { yyVAL.array = []any{} } - case 18: + case 19: yyDollar = yyS[yypt-3 : yypt+1] { yyVAL.array = yyDollar[2].array } - case 19: + case 20: yyDollar = yyS[yypt-4 : yypt+1] { yyVAL.array = yyDollar[2].array } - case 20: + case 21: yyDollar = yyS[yypt-1 : yypt+1] { yyVAL.array = []any{yyDollar[1].value} } - case 21: + case 22: yyDollar = yyS[yypt-3 : yypt+1] { yyVAL.array = append(yyDollar[1].array, yyDollar[3].value) } - case 22: + case 23: yyDollar = yyS[yypt-2 : yypt+1] { yyVAL.array = []any{} } - case 23: + case 24: yyDollar = yyS[yypt-3 : yypt+1] { yyVAL.array = yyDollar[2].array } - case 24: + case 25: yyDollar = yyS[yypt-4 : yypt+1] { yyVAL.array = yyDollar[2].array @@ -597,11 +602,17 @@ yydefault: case 26: yyDollar = yyS[yypt-2 : yypt+1] { - j := yylex.(*json) - opPos := j.popPos() - op, err := j.getOperator(Operator{Name: yyDollar[1].string, Params: yyDollar[2].array}, opPos) - if err != nil { - j.fatal(err.Error(), opPos) + op := yylex.(*json).newOperator(yyDollar[1].string, yyDollar[2].array) + if op == nil { + return 1 + } + yyVAL.value = op + } + case 27: + yyDollar = yyS[yypt-1 : yypt+1] + { + op := yylex.(*json).newOperator(yyDollar[1].string, nil) + if op == nil { return 1 } yyVAL.value = op diff --git a/internal/json/parser.y b/internal/json/parser.y index ba36e05b..c141240a 100644 --- a/internal/json/parser.y +++ b/internal/json/parser.y @@ -1,5 +1,5 @@ %{ -// Copyright (c) 2020, 2021, Maxime Soulé +// Copyright (c) 2020-2022, Maxime Soulé // All rights reserved. // // This source code is licensed under the BSD-style license found in the @@ -37,7 +37,7 @@ func finalize(l yyLexer, value any) { %start json -%token TRUE FALSE NULL NUMBER PLACEHOLDER OPERATOR_SHORTCUT +%token TRUE FALSE NULL NUMBER PLACEHOLDER SUB_PARSER %token STRING OPERATOR %type object members @@ -57,6 +57,7 @@ value: object { $$ = $1 } | array { $$ = $1 } | operator { $$ = $1 } + | SUB_PARSER { $$ = $1 } | STRING { $$ = $1 } | NUMBER | TRUE @@ -134,14 +135,18 @@ op_params: '(' ')' } operator: - OPERATOR_SHORTCUT - | OPERATOR op_params - { - j := yylex.(*json) - opPos := j.popPos() - op, err := j.getOperator(Operator{Name: $1, Params: $2}, opPos) - if err != nil { - j.fatal(err.Error(), opPos) + OPERATOR op_params + { + op := yylex.(*json).newOperator($1, $2) + if op == nil { + return 1 + } + $$ = op + } + | OPERATOR + { + op := yylex.(*json).newOperator($1, nil) + if op == nil { return 1 } $$ = op diff --git a/internal/json/parser_test.go b/internal/json/parser_test.go index 0f85bd82..bbe16580 100644 --- a/internal/json/parser_test.go +++ b/internal/json/parser_test.go @@ -105,87 +105,125 @@ func TestJSON(t *testing.T) { }) t.Run("JSON spec infringements", func(t *testing.T) { - // "," is accepted just before non-empty "}" or "]" - checkJSON(t, `{"foo": "bar", }`, `{"foo":"bar"}`) - checkJSON(t, `{"foo":"bar",}`, `{"foo":"bar"}`) - checkJSON(t, `[ 1, 2, 3, ]`, `[1,2,3]`) - checkJSON(t, `[ 1,2,3,]`, `[1,2,3]`) - - // Extend to golang accepted numbers - // as int64 - checkJSON(t, `+42`, `42`) - - checkJSON(t, `0600`, `384`) - checkJSON(t, `-0600`, `-384`) - checkJSON(t, `+0600`, `384`) - - checkJSON(t, `0xBadFace`, `195951310`) - checkJSON(t, `-0xBadFace`, `-195951310`) - checkJSON(t, `+0xBadFace`, `195951310`) - - // as float64 - checkJSON(t, `0600.123`, `600.123`) // float64 can not be an octal number - checkJSON(t, `0600.`, `600`) // float64 can not be an octal number - checkJSON(t, `.25`, `0.25`) - checkJSON(t, `+123.`, `123`) - - // Extend to golang 1.13 accepted numbers - // as int64 - checkJSON(t, `4_2`, `42`) - checkJSON(t, `+4_2`, `42`) - checkJSON(t, `-4_2`, `-42`) - - checkJSON(t, `0b101010`, `42`) - checkJSON(t, `-0b101010`, `-42`) - checkJSON(t, `+0b101010`, `42`) - - checkJSON(t, `0b10_1010`, `42`) - checkJSON(t, `-0b_10_1010`, `-42`) - checkJSON(t, `+0b10_10_10`, `42`) - - checkJSON(t, `0B101010`, `42`) - checkJSON(t, `-0B101010`, `-42`) - checkJSON(t, `+0B101010`, `42`) - - checkJSON(t, `0B10_1010`, `42`) - checkJSON(t, `-0B_10_1010`, `-42`) - checkJSON(t, `+0B10_10_10`, `42`) - - checkJSON(t, `0_600`, `384`) - checkJSON(t, `-0_600`, `-384`) - checkJSON(t, `+0_600`, `384`) - - checkJSON(t, `0o600`, `384`) - checkJSON(t, `0o_600`, `384`) - checkJSON(t, `-0o600`, `-384`) - checkJSON(t, `-0o6_00`, `-384`) - checkJSON(t, `+0o600`, `384`) - checkJSON(t, `+0o60_0`, `384`) - - checkJSON(t, `0O600`, `384`) - checkJSON(t, `0O_600`, `384`) - checkJSON(t, `-0O600`, `-384`) - checkJSON(t, `-0O6_00`, `-384`) - checkJSON(t, `+0O600`, `384`) - checkJSON(t, `+0O60_0`, `384`) - - checkJSON(t, `0xBad_Face`, `195951310`) - checkJSON(t, `-0x_Bad_Face`, `-195951310`) - checkJSON(t, `+0xBad_Face`, `195951310`) - - checkJSON(t, `0XBad_Face`, `195951310`) - checkJSON(t, `-0X_Bad_Face`, `-195951310`) - checkJSON(t, `+0XBad_Face`, `195951310`) - - // as float64 - checkJSON(t, `0_600.123`, `600.123`) // float64 can not be an octal number - checkJSON(t, `1_5.`, `15`) - checkJSON(t, `0.15e+0_2`, `15`) - checkJSON(t, `0x1p-2`, `0.25`) - checkJSON(t, `0x2.p10`, `2048`) - checkJSON(t, `0x1.Fp+0`, `1.9375`) - checkJSON(t, `0X.8p-0`, `0.5`) - checkJSON(t, `0X_1FFFP-16`, `0.1249847412109375`) + for _, tc := range []struct{ got, expected string }{ + // "," is accepted just before non-empty "}" or "]" + {`{"foo": "bar", }`, `{"foo":"bar"}`}, + {`{"foo":"bar",}`, `{"foo":"bar"}`}, + {`[ 1, 2, 3, ]`, `[1,2,3]`}, + {`[ 1,2,3,]`, `[1,2,3]`}, + + // No need to escape \n, \r & \t + {"\"\n\r\t\"", `"\n\r\t"`}, + + // Extend to golang accepted numbers + // as int64 + {`+42`, `42`}, + + {`0600`, `384`}, + {`-0600`, `-384`}, + {`+0600`, `384`}, + + {`0xBadFace`, `195951310`}, + {`-0xBadFace`, `-195951310`}, + {`+0xBadFace`, `195951310`}, + + // as float64 + {`0600.123`, `600.123`}, // float64 can not be an octal number + {`0600.`, `600`}, // float64 can not be an octal number + {`.25`, `0.25`}, + {`+123.`, `123`}, + + // Extend to golang 1.13 accepted numbers + // as int64 + {`4_2`, `42`}, + {`+4_2`, `42`}, + {`-4_2`, `-42`}, + + {`0b101010`, `42`}, + {`-0b101010`, `-42`}, + {`+0b101010`, `42`}, + + {`0b10_1010`, `42`}, + {`-0b_10_1010`, `-42`}, + {`+0b10_10_10`, `42`}, + + {`0B101010`, `42`}, + {`-0B101010`, `-42`}, + {`+0B101010`, `42`}, + + {`0B10_1010`, `42`}, + {`-0B_10_1010`, `-42`}, + {`+0B10_10_10`, `42`}, + + {`0_600`, `384`}, + {`-0_600`, `-384`}, + {`+0_600`, `384`}, + + {`0o600`, `384`}, + {`0o_600`, `384`}, + {`-0o600`, `-384`}, + {`-0o6_00`, `-384`}, + {`+0o600`, `384`}, + {`+0o60_0`, `384`}, + + {`0O600`, `384`}, + {`0O_600`, `384`}, + {`-0O600`, `-384`}, + {`-0O6_00`, `-384`}, + {`+0O600`, `384`}, + {`+0O60_0`, `384`}, + + {`0xBad_Face`, `195951310`}, + {`-0x_Bad_Face`, `-195951310`}, + {`+0xBad_Face`, `195951310`}, + + {`0XBad_Face`, `195951310`}, + {`-0X_Bad_Face`, `-195951310`}, + {`+0XBad_Face`, `195951310`}, + + // as float64 + {`0_600.123`, `600.123`}, // float64 can not be an octal number + {`1_5.`, `15`}, + {`0.15e+0_2`, `15`}, + {`0x1p-2`, `0.25`}, + {`0x2.p10`, `2048`}, + {`0x1.Fp+0`, `1.9375`}, + {`0X.8p-0`, `0.5`}, + {`0X_1FFFP-16`, `0.1249847412109375`}, + + // Raw strings + {`r"pipo"`, `"pipo"`}, + {`r "pipo"`, `"pipo"`}, + {"r\n'pipo'", `"pipo"`}, + {`r%pipo%`, `"pipo"`}, + {`r·pipo·`, `"pipo"`}, + {"r`pipo`", `"pipo"`}, + {`r/pipo/`, `"pipo"`}, + {"r //comment\n`pipo`", `"pipo"`}, // comments accepted bw r and string + {"r//comment\n`pipo`", `"pipo"`}, + {"r/*comment\n*/|pipo|", `"pipo"`}, + {"r(p\ni\rp\to)", `"p\ni\rp\to"`}, // accepted raw whitespaces + {`r@pi\po\@`, `"pi\\po\\"`}, // backslash has no meaning + // balanced delimiters + {`r(p(i(hey)p)o)`, `"p(i(hey)p)o"`}, + {`r{p{i{hey}p}o}`, `"p{i{hey}p}o"`}, + {`r[p[i[hey]p]o]`, `"p[i[hey]p]o"`}, + {`rp>o>`, `"pp>o"`}, + {`r(pipo)`, `"pipo"`}, + {"r \t\n(pipo)", `"pipo"`}, + {`r{pipo}`, `"pipo"`}, + {`r[pipo]`, `"pipo"`}, + {`r`, `"pipo"`}, + // Not balanced + {`r)pipo)`, `"pipo"`}, + {`r}pipo}`, `"pipo"`}, + {`r]pipo]`, `"pipo"`}, + {`r>pipo>`, `"pipo"`}, + } { + t.Run(tc.got, func(t *testing.T) { + checkJSON(t, tc.got, tc.expected) + }) + } }) t.Run("Special string cases", func(t *testing.T) { @@ -280,148 +318,362 @@ func TestJSON(t *testing.T) { } }) + t.Run("OK", func(t *testing.T) { + opts := json.ParseOpts{ + OpFn: func(op json.Operator, pos json.Position) (any, error) { + if op.Name == "KnownOp" { + return "OK", nil + } + return nil, fmt.Errorf("hmm weird operator %q", op.Name) + }, + } + for _, js := range []string{ + `[ KnownOp ]`, + `[ KnownOp() ]`, + `[ $^KnownOp() ]`, + `[ $^KnownOp ]`, + `[ KnownOp($^KnownOp) ]`, + `[ KnownOp( $^KnownOp() ) ]`, + `[ $^KnownOp(KnownOp) ]`, + } { + _, err := json.Parse([]byte(js), opts) + test.NoError(t, err, "json.Parse OK", js) + } + }) + + t.Run("Reentrant parser", func(t *testing.T) { + opts := json.ParseOpts{ + OpFn: func(op json.Operator, pos json.Position) (any, error) { + if op.Name == "KnownOp" { + return "OK", nil + } + return nil, fmt.Errorf("hmm weird operator %q", op.Name) + }, + } + for _, js := range []string{ + `[ "$^KnownOp(1, 2, 3)" ]`, + `[ "$^KnownOp(1, 2, 3) " ]`, + `[ "$^KnownOp(r<$^KnownOp(11, 12)>, 2, KnownOp(31, 32))" ]`, + } { + _, err := json.Parse([]byte(js), opts) + test.NoError(t, err, "json.Parse OK", js) + } + }) + t.Run("Errors", func(t *testing.T) { - for i, tst := range []struct{ js, err string }{ + for i, tst := range []struct{ nam, js, err string }{ // comment { + nam: "unterminated comment", js: " \n /* unterminated", err: "multi-lines comment not terminated at line 2:3 (pos 5)", }, { + nam: "/ at EOF", js: " \n /", err: "syntax error: unexpected '/' at line 2:1 (pos 3)", }, { + nam: "/toto", js: " \n /toto", err: "syntax error: unexpected '/' at line 2:1 (pos 3)", }, // string { + nam: "unterminated string+multi lines", js: "/* multi\nline\ncomment */ \"...", err: "unterminated string at line 3:11 (pos 25)", }, { + nam: "unterminated string", js: ` "unterminated\`, err: "unterminated string at line 1:2 (pos 2)", }, { + nam: "bad escape", js: `"bad escape \a"`, err: "invalid escape sequence at line 1:13 (pos 13)", }, { + nam: `bad escape \u`, js: `"bad échappe \u123t"`, err: "invalid escape sequence at line 1:14 (pos 14)", }, { + nam: "bad rune", js: "\"bad rune \007\"", err: "invalid character in string at line 1:10 (pos 10)", }, // number { + nam: "bad number", js: " \n 123.345.45", err: "invalid number at line 2:1 (pos 4)", }, // dollar token { + nam: "dollar at EOF", + js: " $", + err: "syntax error: unexpected '$' at line 1:2 (pos 2)", + }, + { + nam: "dollar alone", + js: " $ ", + err: "syntax error: unexpected '$' at line 1:2 (pos 2)", + }, + { + nam: "multi lines+dollar at EOF", js: " \n 123.345$", err: "syntax error: unexpected '$' at line 2:8 (pos 11)", }, { + nam: "bad num placeholder", js: ` $123a `, err: "invalid numeric placeholder at line 1:2 (pos 2)", }, { + nam: "bad num placeholder in string", js: ` "$123a" `, err: "invalid numeric placeholder at line 1:3 (pos 3)", }, { + nam: "bad 0 placeholder", js: ` $00 `, err: `invalid numeric placeholder "$00", it should start at "$1" at line 1:2 (pos 2)`, }, { + nam: "bad 0 placeholder in string", js: ` "$00" `, err: `invalid numeric placeholder "$00", it should start at "$1" at line 1:3 (pos 3)`, }, { + nam: "placeholder/params mismatch", js: ` $1 `, err: `numeric placeholder "$1", but no params given at line 1:2 (pos 2)`, }, { - js: ` "$1" `, + nam: "placeholder in string/params mismatch", + js: `[ "$1", 1, 2 ] `, err: `numeric placeholder "$1", but no params given at line 1:3 (pos 3)`, }, { - js: ` $^AnyOp `, - err: `bad operator shortcut "$^AnyOp" at line 1:2 (pos 2)`, + nam: "invalid operator in string", + js: ` "$^UnknownAndBad>" `, + err: `invalid operator name "UnknownAndBad>" at line 1:4 (pos 4)`, + }, + { + nam: "unknown operator close paren", + js: ` UnknownAndBad)`, + err: `unknown operator "UnknownAndBad" at line 1:1 (pos 1)`, }, { - js: ` "$^AnyOp" `, - err: `bad operator shortcut "$^AnyOp" at line 1:3 (pos 3)`, + nam: "unknown operator close paren in string", + js: ` "$^UnknownAndBad)" `, + err: `unknown operator "UnknownAndBad" at line 1:4 (pos 4)`, }, { + nam: "op and syntax error", + js: ` KnownOp)`, + err: `syntax error: unexpected ')' at line 1:8 (pos 8)`, + }, + { + nam: "op in string and syntax error", + js: ` "$^KnownOp)" `, + err: `syntax error: unexpected ')' at line 1:11 (pos 11)`, + }, + { + nam: "op paren in string and syntax error", + js: ` "$^KnownOp())" `, + err: `syntax error: unexpected ')' at line 1:13 (pos 13)`, + }, + { + nam: "invalid $^", + js: ` $^. `, + err: `$^ must be followed by an operator name at line 1:2 (pos 2)`, + }, + { + nam: "invalid $^ in string", + js: ` "$^."`, + err: `$^ must be followed by an operator name at line 1:2 (pos 2)`, + }, + { + nam: "invalid $^ at EOF", + js: ` $^`, + err: `$^ must be followed by an operator name at line 1:2 (pos 2)`, + }, + { + nam: "invalid $^ in string at EOF", + js: ` "$^"`, + err: `$^ must be followed by an operator name at line 1:2 (pos 2)`, + }, + { + nam: "bad placeholder", js: ` $tag%`, err: `bad placeholder "$tag%" at line 1:2 (pos 2)`, }, { + nam: "bad placeholder in string", js: ` "$tag%"`, err: `bad placeholder "$tag%" at line 1:3 (pos 3)`, }, { + nam: "unknown placeholder", js: ` $tag`, err: `unknown placeholder "$tag" at line 1:2 (pos 2)`, }, { + nam: "unknown placeholder in string", js: ` "$tag"`, err: `unknown placeholder "$tag" at line 1:3 (pos 3)`, }, // operator { + nam: "invalid operator", js: " AnyOpé", err: `invalid operator name "AnyOp\xc3" at line 1:2 (pos 2)`, }, { + nam: "invalid $^operator", + js: " $^AnyOpé", + err: `invalid operator name "AnyOp\xc3" at line 1:4 (pos 4)`, + }, + { + nam: "invalid $^operator in string", + js: ` "$^AnyOpé"`, + err: `invalid operator name "AnyOp\xc3" at line 1:5 (pos 5)`, + }, + { + nam: "unknown operator", + js: " AnyOp", + err: `unknown operator "AnyOp" at line 1:2 (pos 2)`, + }, + { + nam: "unknown operator paren", js: " AnyOp()", err: `unknown operator "AnyOp" at line 1:2 (pos 2)`, }, + { + nam: "unknown $^operator", + js: "$^AnyOp", + err: `unknown operator "AnyOp" at line 1:2 (pos 2)`, + }, + { + nam: "unknown $^operator paren", + js: "$^AnyOp()", + err: `unknown operator "AnyOp" at line 1:2 (pos 2)`, + }, + { + nam: "unknown $^operator in string", + js: `"$^AnyOp"`, + err: `unknown operator "AnyOp" at line 1:3 (pos 3)`, + }, + { + nam: "unknown $^operator paren in string", + js: `"$^AnyOp()"`, + err: `unknown operator "AnyOp" at line 1:3 (pos 3)`, + }, + { + nam: "unknown $^operator in rawstring", + js: `r<$^AnyOp>`, + err: `unknown operator "AnyOp" at line 1:4 (pos 4)`, + }, + { + nam: "unknown $^operator paren in rawstring", + js: `r<$^AnyOp()>`, + err: `unknown operator "AnyOp" at line 1:4 (pos 4)`, + }, // syntax error { + nam: "syntax error num+bool", js: " \n 123.345true", err: "syntax error: unexpected TRUE at line 2:8 (pos 11)", }, { + nam: "syntax error num+%", js: " \n 123.345%", err: "syntax error: unexpected '%' at line 2:8 (pos 11)", }, { + nam: "syntax error num+ESC", js: " \n 123.345\x1f", err: `syntax error: unexpected '\u001f' at line 2:8 (pos 11)`, }, { + nam: "syntax error num+unicode", js: " \n 123.345\U0002f500", err: `syntax error: unexpected '\U0002f500' at line 2:8 (pos 11)`, }, // multiple errors { - js: "[$1,$2,", + nam: "multi errors placeholders", + js: "[$1,$2,", err: `numeric placeholder "$1", but no params given at line 1:1 (pos 1) numeric placeholder "$2", but no params given at line 1:4 (pos 4) syntax error: unexpected EOF at line 1:6 (pos 6)`, }, + { + nam: "multi errors placeholder+operator", + js: `[$1,"$^Unknown1()","$^Unknown2()"]`, + err: `numeric placeholder "$1", but no params given at line 1:1 (pos 1) +invalid operator name "Unknown1" at line 1:7 (pos 7) +invalid operator name "Unknown2" at line 1:22 (pos 22)`, + }, + // raw strings + { + nam: "rawstring start delimiter", + js: " \n r ", + err: `cannot find r start delimiter at line 2:7 (pos 10)`, + }, + { + nam: "rawstring start delimiter EOF", + js: " \n r", + err: `cannot find r start delimiter at line 2:4 (pos 7)`, + }, + { + nam: "rawstring bad delimiter", + js: ` rxpipox`, + err: `invalid r delimiter 'x', should be either a punctuation or a symbol rune, excluding '_' at line 1:3 (pos 3)`, + }, + { + nam: "rawstring bad underscore delimiter", + js: ` r_pipo_`, + err: `invalid r delimiter '_', should be either a punctuation or a symbol rune, excluding '_' at line 1:3 (pos 3)`, + }, + { + nam: "rawstring bad rune", + js: " r:bad rune \007:", + err: `invalid character in raw string at line 1:13 (pos 13)`, + }, + { + nam: "unterminated rawstring", + js: ` r!pipo...`, + err: `unterminated raw string at line 1:3 (pos 3)`, + }, } { - _, err := json.Parse([]byte(tst.js)) - if test.Error(t, err, `#%d \n, json.Parse fails`, i) { - test.EqualStr(t, err.Error(), tst.err, `#%d \n, err OK`, i) - } + t.Run(tst.nam, func(t *testing.T) { + opts := json.ParseOpts{ + OpFn: func(op json.Operator, pos json.Position) (any, error) { + if op.Name == "KnownOp" { + return "OK", nil + } + return nil, fmt.Errorf("unknown operator %q", op.Name) + }, + } + _, err := json.Parse([]byte(tst.js), opts) + if test.Error(t, err, `#%d \n, json.Parse fails`, i) { + test.EqualStr(t, err.Error(), tst.err, `#%d \n, err OK`, i) + } - _, err = json.Parse([]byte(strings.Replace(tst.js, "\n", "\r", -1))) //nolint: gocritic - if test.Error(t, err, `#%d \r, json.Parse fails`, i) { - test.EqualStr(t, err.Error(), tst.err, `#%d \r, err OK`, i) - } + _, err = json.Parse([]byte(strings.ReplaceAll(tst.js, "\n", "\r")), opts) + if test.Error(t, err, `#%d \r, json.Parse fails`, i) { + test.EqualStr(t, err.Error(), tst.err, `#%d \r, err OK`, i) + } - _, err = json.Parse([]byte(strings.Replace(tst.js, "\n", "\r\n", -1))) //nolint: gocritic - if test.Error(t, err, `#%d \r\n, json.Parse fails`, i) { - test.EqualStr(t, err.Error(), tst.err, `#%d \r\n, err OK`, i) - } + _, err = json.Parse([]byte(strings.ReplaceAll(tst.js, "\n", "\r\n")), opts) + if test.Error(t, err, `#%d \r\n, json.Parse fails`, i) { + test.EqualStr(t, err.Error(), tst.err, `#%d \r\n, err OK`, i) + } + }) } _, err := json.Parse( @@ -442,47 +694,45 @@ syntax error: unexpected EOF at line 1:6 (pos 6)`, `numeric placeholder "$3", but only 2 params given at line 1:1 (pos 1)`) } - var anyOpPos json.Position - _, err = json.Parse([]byte(` KnownOp( AnyOp() )`), - json.ParseOpts{ - OpFn: func(op json.Operator, pos json.Position) (any, error) { - if op.Name == "KnownOp" { - return "OK", nil - } - anyOpPos = pos - return nil, fmt.Errorf("hmm weird operator %q", op.Name) - }, - }) - if test.Error(t, err, "json.Parse fails") { - test.EqualInt(t, anyOpPos.Pos, 12) - test.EqualInt(t, anyOpPos.Line, 1) - test.EqualInt(t, anyOpPos.Col, 12) - test.EqualStr(t, err.Error(), - `hmm weird operator "AnyOp" at line 1:12 (pos 12)`) - } - for _, js := range []string{ - ` [ $^KnownOp, $^AnyOp ]`, - ` [ "$^KnownOp", "$^AnyOp" ]`, + ` KnownOp( AnyOp() )`, + ` KnownOp( AnyOp )`, + ` KnownOp("$^AnyOp()" )`, + ` KnownOp("$^AnyOp" )`, + ` KnownOp( $^AnyOp() )`, + ` $^KnownOp( AnyOp )`, + ` "$^KnownOp( AnyOp )"`, + ` "$^KnownOp( AnyOp() )"`, + ` "$^KnownOp( $^AnyOp() )"`, + `"$^KnownOp(r'$^AnyOp()')"`, } { - _, err := json.Parse([]byte(js), - json.ParseOpts{ - OpShortcutFn: func(name string, pos json.Position) (any, bool) { - if name == "KnownOp" { - return "OK", true + t.Run(js, func(t *testing.T) { + var anyOpPos json.Position + _, err = json.Parse([]byte(js), json.ParseOpts{ + OpFn: func(op json.Operator, pos json.Position) (any, error) { + if op.Name == "KnownOp" { + return "OK", nil } anyOpPos = pos - return nil, false + return nil, fmt.Errorf("hmm weird operator %q", op.Name) }, }) - if test.Error(t, err, "json.Parse fails", js) { - test.EqualInt(t, anyOpPos.Pos, 18) - test.EqualInt(t, anyOpPos.Line, 1) - test.EqualInt(t, anyOpPos.Col, 18) - test.EqualStr(t, err.Error(), - `bad operator shortcut "$^AnyOp" at line 1:18 (pos 18)`, - js) - } + if test.Error(t, err, "json.Parse fails") { + test.EqualInt(t, anyOpPos.Pos, 15) + test.EqualInt(t, anyOpPos.Line, 1) + test.EqualInt(t, anyOpPos.Col, 15) + test.EqualStr(t, err.Error(), + `hmm weird operator "AnyOp" at line 1:15 (pos 15)`) + } + }) + } + }) + + t.Run("no operators", func(t *testing.T) { + _, err := json.Parse([]byte(" Operator")) + if test.Error(t, err, "json.Parse fails") { + test.EqualStr(t, err.Error(), + `unknown operator "Operator" at line 1:2 (pos 2)`) } }) } diff --git a/td/example_cmp_test.go b/td/example_cmp_test.go index 7b73e689..a5200a48 100644 --- a/td/example_cmp_test.go +++ b/td/example_cmp_test.go @@ -1286,6 +1286,75 @@ func ExampleCmpJSON_embedding() { // check got with complex operators, w/placeholder args: true } +func ExampleCmpJSON_rawStrings() { + t := &testing.T{} + + type details struct { + Address string `json:"address"` + Car string `json:"car"` + } + + got := &struct { + Fullname string `json:"fullname"` + Age int `json:"age"` + Details details `json:"details"` + }{ + Fullname: "Foo Bar", + Age: 42, + Details: details{ + Address: "something", + Car: "Peugeot", + }, + } + + ok := td.CmpJSON(t, got, ` +{ + "fullname": HasPrefix("Foo"), + "age": Between(41, 43), + "details": SuperMapOf({ + "address": NotEmpty, // () are optional when no parameters + "car": Any("Peugeot", "Tesla", "Jeep") // any of these + }) +}`, nil) + fmt.Println("Original:", ok) + + ok = td.CmpJSON(t, got, ` +{ + "fullname": "$^HasPrefix(\"Foo\")", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({\n\"address\": NotEmpty,\n\"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\")\n})" +}`, nil) + fmt.Println("JSON compliant:", ok) + + ok = td.CmpJSON(t, got, ` +{ + "fullname": "$^HasPrefix(\"Foo\")", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({ + \"address\": NotEmpty, // () are optional when no parameters + \"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these + })" +}`, nil) + fmt.Println("JSON multilines strings:", ok) + + ok = td.CmpJSON(t, got, ` +{ + "fullname": "$^HasPrefix(r)", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({ + r
: NotEmpty, // () are optional when no parameters + r: Any(r, r, r) // any of these + })" +}`, nil) + fmt.Println("Raw strings:", ok) + + // Output: + // Original: true + // JSON compliant: true + // JSON multilines strings: true + // Raw strings: true +} + func ExampleCmpJSON_file() { t := &testing.T{} diff --git a/td/example_t_test.go b/td/example_t_test.go index 6a69b372..e1eb2af8 100644 --- a/td/example_t_test.go +++ b/td/example_t_test.go @@ -1286,6 +1286,75 @@ func ExampleT_JSON_embedding() { // check got with complex operators, w/placeholder args: true } +func ExampleT_JSON_rawStrings() { + t := td.NewT(&testing.T{}) + + type details struct { + Address string `json:"address"` + Car string `json:"car"` + } + + got := &struct { + Fullname string `json:"fullname"` + Age int `json:"age"` + Details details `json:"details"` + }{ + Fullname: "Foo Bar", + Age: 42, + Details: details{ + Address: "something", + Car: "Peugeot", + }, + } + + ok := t.JSON(got, ` +{ + "fullname": HasPrefix("Foo"), + "age": Between(41, 43), + "details": SuperMapOf({ + "address": NotEmpty, // () are optional when no parameters + "car": Any("Peugeot", "Tesla", "Jeep") // any of these + }) +}`, nil) + fmt.Println("Original:", ok) + + ok = t.JSON(got, ` +{ + "fullname": "$^HasPrefix(\"Foo\")", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({\n\"address\": NotEmpty,\n\"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\")\n})" +}`, nil) + fmt.Println("JSON compliant:", ok) + + ok = t.JSON(got, ` +{ + "fullname": "$^HasPrefix(\"Foo\")", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({ + \"address\": NotEmpty, // () are optional when no parameters + \"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these + })" +}`, nil) + fmt.Println("JSON multilines strings:", ok) + + ok = t.JSON(got, ` +{ + "fullname": "$^HasPrefix(r)", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({ + r
: NotEmpty, // () are optional when no parameters + r: Any(r, r, r) // any of these + })" +}`, nil) + fmt.Println("Raw strings:", ok) + + // Output: + // Original: true + // JSON compliant: true + // JSON multilines strings: true + // Raw strings: true +} + func ExampleT_JSON_file() { t := td.NewT(&testing.T{}) diff --git a/td/example_test.go b/td/example_test.go index 548b75ac..1db6d70b 100644 --- a/td/example_test.go +++ b/td/example_test.go @@ -1487,6 +1487,79 @@ func ExampleJSON_embedding() { // check got with complex operators, w/placeholder args: true } +func ExampleJSON_rawStrings() { + t := &testing.T{} + + type details struct { + Address string `json:"address"` + Car string `json:"car"` + } + + got := &struct { + Fullname string `json:"fullname"` + Age int `json:"age"` + Details details `json:"details"` + }{ + Fullname: "Foo Bar", + Age: 42, + Details: details{ + Address: "something", + Car: "Peugeot", + }, + } + + ok := td.Cmp(t, got, + td.JSON(` +{ + "fullname": HasPrefix("Foo"), + "age": Between(41, 43), + "details": SuperMapOf({ + "address": NotEmpty, // () are optional when no parameters + "car": Any("Peugeot", "Tesla", "Jeep") // any of these + }) +}`)) + fmt.Println("Original:", ok) + + ok = td.Cmp(t, got, + td.JSON(` +{ + "fullname": "$^HasPrefix(\"Foo\")", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({\n\"address\": NotEmpty,\n\"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\")\n})" +}`)) + fmt.Println("JSON compliant:", ok) + + ok = td.Cmp(t, got, + td.JSON(` +{ + "fullname": "$^HasPrefix(\"Foo\")", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({ + \"address\": NotEmpty, // () are optional when no parameters + \"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these + })" +}`)) + fmt.Println("JSON multilines strings:", ok) + + ok = td.Cmp(t, got, + td.JSON(` +{ + "fullname": "$^HasPrefix(r)", + "age": "$^Between(41, 43)", + "details": "$^SuperMapOf({ + r
: NotEmpty, // () are optional when no parameters + r: Any(r, r, r) // any of these + })" +}`)) + fmt.Println("Raw strings:", ok) + + // Output: + // Original: true + // JSON compliant: true + // JSON multilines strings: true + // Raw strings: true +} + func ExampleJSON_file() { t := &testing.T{} diff --git a/td/td_json.go b/td/td_json.go index 2907cf28..bff394cf 100644 --- a/td/td_json.go +++ b/td/td_json.go @@ -55,20 +55,6 @@ var forbiddenOpsInJSON = map[string]string{ "TruncTime": "", } -// jsonOpShortcuts contains operator that can be used as -// $^OperatorName inside JSON, SubJSONOf or SuperJSONOf. -var jsonOpShortcuts = map[string]func() TestDeep{ - "Empty": Empty, - "Ignore": Ignore, - "NaN": NaN, - "Nil": Nil, - "NotEmpty": NotEmpty, - "NotNaN": NotNaN, - "NotNil": NotNil, - "NotZero": NotZero, - "Zero": Zero, -} - // tdJSONUnmarshaler handles the JSON unmarshaling of JSON, SubJSONOf // and SuperJSONOf first parameter. type tdJSONUnmarshaler struct { @@ -161,7 +147,6 @@ func (u tdJSONUnmarshaler) unmarshal(expectedJSON any, params []any) (any, *ctxe final, err := json.Parse(b, json.ParseOpts{ Placeholders: params, PlaceholdersByName: byTag, - OpShortcutFn: u.resolveOpShortcut(), OpFn: u.resolveOp(), }) if err != nil { @@ -297,22 +282,6 @@ func (u tdJSONUnmarshaler) resolveOp() func(json.Operator, json.Position) (any, } } -// resolveOpShortcut returns a closure usable as json.ParseOpts.OpShortcutFn. -func (u tdJSONUnmarshaler) resolveOpShortcut() func(string, json.Position) (any, bool) { - return func(opName string, posInJSON json.Position) (any, bool) { - opFn := jsonOpShortcuts[opName] - if opFn != nil { - tdOp := opFn() - - // replace the location by the JSON/SubJSONOf/SuperJSONOf one - u.replaceLocation(tdOp, posInJSON) - - return newJSONNamedPlaceholder("^"+opName, tdOp), true - } - return nil, false - } -} - // tdJSONSmuggler is the base type for tdJSONPlaceholder & tdJSONEmbedded. type tdJSONSmuggler struct { tdSmugglerBase // ignored by tools/gen_funcs.pl @@ -394,22 +363,16 @@ func (p *tdJSONPlaceholder) MarshalJSON() ([]byte, error) { var b bytes.Buffer - start := b.Len() if p.num == 0 { fmt.Fprintf(&b, `"$%s"`, p.name) - - // Don't add a comment for operator shortcuts (aka $^NotZero) - if p.name[0] == '^' { - return b.Bytes(), nil - } } else { fmt.Fprintf(&b, `"$%d"`, p.num) } b.WriteString(` /* `) - indent := "\n" + strings.Repeat(" ", b.Len()-start) - b.WriteString(strings.Replace(p.String(), "\n", indent, -1)) //nolint: gocritic + indent := "\n" + strings.Repeat(" ", b.Len()) + b.WriteString(strings.ReplaceAll(p.String(), "\n", indent)) b.WriteString(` */`) @@ -586,11 +549,14 @@ func jsonify(ctx ctxerr.Context, got reflect.Value) (any, *ctxerr.Error) { // // Other JSON divergences: // - ',' can precede a '}' or a ']' (as in go); +// - strings can contain non-escaped \n, \r and \t; +// - raw strings are accepted (r{raw}, r!raw!, …), see below; // - int_lit & float_lit numbers as defined in go spec are accepted; // - numbers can be prefixed by '+'. // // Most operators can be directly embedded in JSON without requiring -// any placeholder. +// any placeholder. If an operators does not take any parameter, the +// parenthesis can be omitted. // // td.Cmp(t, gotValue, // td.JSON(` @@ -598,7 +564,7 @@ func jsonify(ctx ctxerr.Context, got reflect.Value) (any, *ctxerr.Error) { // "fullname": HasPrefix("Foo"), // "age": Between(41, 43), // "details": SuperMapOf({ -// "address": NotEmpty(), +// "address": NotEmpty, // () are optional when no parameters // "car": Any("Peugeot", "Tesla", "Jeep") // any of these // }) // }`)) @@ -622,35 +588,74 @@ func jsonify(ctx ctxerr.Context, got reflect.Value) (any, *ctxerr.Error) { // [SubSetOf], [SuperBagOf], [SuperMapOf], [SuperSetOf], [Values] // and [Zero]. // -// Operators taking no parameters can also be directly embedded in -// JSON data using $^OperatorName or "$^OperatorName" notation. They -// are named shortcut operators (they predate the above operators embedding -// but they still subsist for compatibility): +// It is also possible to embed operators in JSON strings. This way, +// the JSON specification can be fulfilled. To avoid collision with +// possible strings, just prefix the first operator name with +// "$^". The previous example becomes: // -// td.Cmp(t, gotValue, td.JSON(`{"id": $1}`, td.NotZero())) +// td.Cmp(t, gotValue, +// td.JSON(` +// { +// "fullname": "$^HasPrefix(\"Foo\")", +// "age": "$^Between(41, 43)", +// "details": "$^SuperMapOf({ +// \"address\": NotEmpty, // () are optional when no parameters +// \"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these +// })" +// }`)) // -// can be written as: +// As you can see, in this case, strings in strings have to be +// escaped. Fortunately, newlines are accepted, but unfortunately they +// are forbidden by JSON specification. To avoid too much escaping, +// raw strings are accepted. A raw string is a "r" followed by a +// delimiter, the corresponding delimiter closes the string. The +// following raw strings are all the same as "foo\\bar(\"zip\")!": +// - r'foo\bar"zip"!' +// - r,foo\bar"zip"!, +// - r%foo\bar"zip"!% +// - r(foo\bar("zip")!) +// - r{foo\bar("zip")!} +// - r[foo\bar("zip")!] +// - r +// +// So non-bracketing delimiters use the same character before and +// after, but the 4 sorts of ASCII brackets (round, angle, square, +// curly) all nest: r[x[y]z] equals "x[y]z". The end delimiter cannot +// be escaped. +// +// With raw strings, the previous example becomes: // -// td.Cmp(t, gotValue, td.JSON(`{"id": $^NotZero}`)) +// td.Cmp(t, gotValue, +// td.JSON(` +// { +// "fullname": "$^HasPrefix(r)", +// "age": "$^Between(41, 43)", +// "details": "$^SuperMapOf({ +// r
: NotEmpty, // () are optional when no parameters +// r: Any(r, r, r) // any of these +// })" +// }`)) // -// or +// Note that raw strings are accepted anywhere, not only in original +// JSON strings. // +// To be complete, $^ can prefix an operator even outside a +// string. This is accepted for compatibility purpose as the first +// operator embedding feature used this way to embed some operators. +// +// So the following calls are all equivalent: +// +// td.Cmp(t, gotValue, td.JSON(`{"id": $1}`, td.NotZero())) +// td.Cmp(t, gotValue, td.JSON(`{"id": NotZero}`)) +// td.Cmp(t, gotValue, td.JSON(`{"id": NotZero()}`)) +// td.Cmp(t, gotValue, td.JSON(`{"id": $^NotZero}`)) +// td.Cmp(t, gotValue, td.JSON(`{"id": $^NotZero()}`)) // td.Cmp(t, gotValue, td.JSON(`{"id": "$^NotZero"}`)) +// td.Cmp(t, gotValue, td.JSON(`{"id": "$^NotZero()"}`)) // // As for placeholders, there is no differences between $^NotZero and // "$^NotZero". // -// The allowed shortcut operators follow: -// - [Empty] → $^Empty -// - [Ignore] → $^Ignore -// - [NaN] → $^NaN -// - [Nil] → $^Nil -// - [NotEmpty] → $^NotEmpty -// - [NotNaN] → $^NotNaN -// - [NotNil] → $^NotNil -// - [NotZero] → $^NotZero -// - [Zero] → $^Zero -// // TypeBehind method returns the [reflect.Type] of the expectedJSON // once JSON unmarshaled. So it can be bool, string, float64, []any, // map[string]any or any in case expectedJSON is "null". @@ -855,11 +860,14 @@ var _ TestDeep = &tdMapJSON{} // // Other JSON divergences: // - ',' can precede a '}' or a ']' (as in go); +// - strings can contain non-escaped \n, \r and \t; +// - raw strings are accepted (r{raw}, r!raw!, …), see below; // - int_lit & float_lit numbers as defined in go spec are accepted; // - numbers can be prefixed by '+'. // // Most operators can be directly embedded in SubJSONOf without requiring -// any placeholder. +// any placeholder. If an operators does not take any parameter, the +// parenthesis can be omitted. // // td.Cmp(t, gotValue, // td.SubJSONOf(` @@ -867,7 +875,7 @@ var _ TestDeep = &tdMapJSON{} // "fullname": HasPrefix("Foo"), // "age": Between(41, 43), // "details": SuperMapOf({ -// "address": NotEmpty(), +// "address": NotEmpty, // () are optional when no parameters // "car": Any("Peugeot", "Tesla", "Jeep") // any of these // }) // }`)) @@ -892,35 +900,74 @@ var _ TestDeep = &tdMapJSON{} // [SubSetOf], [SuperBagOf], [SuperMapOf], [SuperSetOf], [Values] // and [Zero]. // -// Operators taking no parameters can also be directly embedded in -// JSON data using $^OperatorName or "$^OperatorName" notation. They -// are named shortcut operators (they predate the above operators embedding -// but they subsist for compatibility): +// It is also possible to embed operators in JSON strings. This way, +// the JSON specification can be fulfilled. To avoid collision with +// possible strings, just prefix the first operator name with +// "$^". The previous example becomes: // -// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": $1, "bar": 42}`, td.NotZero())) +// td.Cmp(t, gotValue, +// td.SubJSONOf(` +// { +// "fullname": "$^HasPrefix(\"Foo\")", +// "age": "$^Between(41, 43)", +// "details": "$^SuperMapOf({ +// \"address\": NotEmpty, // () are optional when no parameters +// \"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these +// })" +// }`)) // -// can be written as: +// As you can see, in this case, strings in strings have to be +// escaped. Fortunately, newlines are accepted, but unfortunately they +// are forbidden by JSON specification. To avoid too much escaping, +// raw strings are accepted. A raw string is a "r" followed by a +// delimiter, the corresponding delimiter closes the string. The +// following raw strings are all the same as "foo\\bar(\"zip\")!": +// - r'foo\bar"zip"!' +// - r,foo\bar"zip"!, +// - r%foo\bar"zip"!% +// - r(foo\bar("zip")!) +// - r{foo\bar("zip")!} +// - r[foo\bar("zip")!] +// - r +// +// So non-bracketing delimiters use the same character before and +// after, but the 4 sorts of ASCII brackets (round, angle, square, +// curly) all nest: r[x[y]z] equals "x[y]z". The end delimiter cannot +// be escaped. +// +// With raw strings, the previous example becomes: // -// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": $^NotZero, "bar": 42}`)) +// td.Cmp(t, gotValue, +// td.SubJSONOf(` +// { +// "fullname": "$^HasPrefix(r)", +// "age": "$^Between(41, 43)", +// "details": "$^SuperMapOf({ +// r
: NotEmpty, // () are optional when no parameters +// r: Any(r, r, r) // any of these +// })" +// }`)) // -// or +// Note that raw strings are accepted anywhere, not only in original +// JSON strings. // -// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": "$^NotZero", "bar": 42}`)) +// To be complete, $^ can prefix an operator even outside a +// string. This is accepted for compatibility purpose as the first +// operator embedding feature used this way to embed some operators. +// +// So the following calls are all equivalent: +// +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": $1}`, td.NotZero())) +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": NotZero}`)) +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": NotZero()}`)) +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": $^NotZero}`)) +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": $^NotZero()}`)) +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": "$^NotZero"}`)) +// td.Cmp(t, gotValue, td.SubJSONOf(`{"id": "$^NotZero()"}`)) // // As for placeholders, there is no differences between $^NotZero and // "$^NotZero". // -// The allowed shortcut operators follow: -// - [Empty] → $^Empty -// - [Ignore] → $^Ignore -// - [NaN] → $^NaN -// - [Nil] → $^Nil -// - [NotEmpty] → $^NotEmpty -// - [NotNaN] → $^NotNaN -// - [NotNil] → $^NotNil -// - [NotZero] → $^NotZero -// - [Zero] → $^Zero -// // TypeBehind method returns the map[string]any type. // // See also [JSON], [JSONPointer] and [SuperJSONOf]. @@ -1079,11 +1126,14 @@ func SubJSONOf(expectedJSON any, params ...any) TestDeep { // // Other JSON divergences: // - ',' can precede a '}' or a ']' (as in go); +// - strings can contain non-escaped \n, \r and \t; +// - raw strings are accepted (r{raw}, r!raw!, …), see below; // - int_lit & float_lit numbers as defined in go spec are accepted; // - numbers can be prefixed by '+'. // // Most operators can be directly embedded in SuperJSONOf without requiring -// any placeholder. +// any placeholder. If an operators does not take any parameter, the +// parenthesis can be omitted. // // td.Cmp(t, gotValue, // td.SuperJSONOf(` @@ -1091,7 +1141,7 @@ func SubJSONOf(expectedJSON any, params ...any) TestDeep { // "fullname": HasPrefix("Foo"), // "age": Between(41, 43), // "details": SuperMapOf({ -// "address": NotEmpty(), +// "address": NotEmpty, // () are optional when no parameters // "car": Any("Peugeot", "Tesla", "Jeep") // any of these // }) // }`)) @@ -1115,35 +1165,74 @@ func SubJSONOf(expectedJSON any, params ...any) TestDeep { // [SubSetOf], [SuperBagOf], [SuperMapOf], [SuperSetOf], [Values] // and [Zero]. // -// Operators taking no parameters can also be directly embedded in -// JSON data using $^OperatorName or "$^OperatorName" notation. They -// are named shortcut operators (they predate the above operators embedding -// but they subsist for compatibility): +// It is also possible to embed operators in JSON strings. This way, +// the JSON specification can be fulfilled. To avoid collision with +// possible strings, just prefix the first operator name with +// "$^". The previous example becomes: // -// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": $1}`, td.NotZero())) +// td.Cmp(t, gotValue, +// td.SuperJSONOf(` +// { +// "fullname": "$^HasPrefix(\"Foo\")", +// "age": "$^Between(41, 43)", +// "details": "$^SuperMapOf({ +// \"address\": NotEmpty, // () are optional when no parameters +// \"car\": Any(\"Peugeot\", \"Tesla\", \"Jeep\") // any of these +// })" +// }`)) // -// can be written as: +// As you can see, in this case, strings in strings have to be +// escaped. Fortunately, newlines are accepted, but unfortunately they +// are forbidden by JSON specification. To avoid too much escaping, +// raw strings are accepted. A raw string is a "r" followed by a +// delimiter, the corresponding delimiter closes the string. The +// following raw strings are all the same as "foo\\bar(\"zip\")!": +// - r'foo\bar"zip"!' +// - r,foo\bar"zip"!, +// - r%foo\bar"zip"!% +// - r(foo\bar("zip")!) +// - r{foo\bar("zip")!} +// - r[foo\bar("zip")!] +// - r +// +// So non-bracketing delimiters use the same character before and +// after, but the 4 sorts of ASCII brackets (round, angle, square, +// curly) all nest: r[x[y]z] equals "x[y]z". The end delimiter cannot +// be escaped. +// +// With raw strings, the previous example becomes: // -// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": $^NotZero}`)) +// td.Cmp(t, gotValue, +// td.SuperJSONOf(` +// { +// "fullname": "$^HasPrefix(r)", +// "age": "$^Between(41, 43)", +// "details": "$^SuperMapOf({ +// r
: NotEmpty, // () are optional when no parameters +// r: Any(r, r, r) // any of these +// })" +// }`)) +// +// Note that raw strings are accepted anywhere, not only in original +// JSON strings. +// +// To be complete, $^ can prefix an operator even outside a +// string. This is accepted for compatibility purpose as the first +// operator embedding feature used this way to embed some operators. // -// or +// So the following calls are all equivalent: // +// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": $1}`, td.NotZero())) +// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": NotZero}`)) +// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": NotZero()}`)) +// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": $^NotZero}`)) +// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": $^NotZero()}`)) // td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": "$^NotZero"}`)) +// td.Cmp(t, gotValue, td.SuperJSONOf(`{"id": "$^NotZero()"}`)) // // As for placeholders, there is no differences between $^NotZero and // "$^NotZero". // -// The allowed shortcut operators follow: -// - [Empty] → $^Empty -// - [Ignore] → $^Ignore -// - [NaN] → $^NaN -// - [Nil] → $^Nil -// - [NotEmpty] → $^NotEmpty -// - [NotNaN] → $^NotNaN -// - [NotNil] → $^NotNil -// - [NotZero] → $^NotZero -// - [Zero] → $^Zero -// // TypeBehind method returns the map[string]any type. // // See also [JSON], [JSONPointer] and [SubJSONOf]. diff --git a/td/td_json_test.go b/td/td_json_test.go index 430b3535..868c08b4 100644 --- a/td/td_json_test.go +++ b/td/td_json_test.go @@ -111,11 +111,23 @@ func TestJSON(t *testing.T) { // Tag placeholders + nil checkOK(t, nil, td.JSON(`$all`, td.Tag("all", nil))) - // Mixed placeholders + operator shortcut - checkOK(t, got, - td.JSON(`{"name":"$name","age":$1,"gender":$^NotEmpty}`, - td.Tag("age", td.Between(40, 45)), - td.Tag("name", td.Re(`^Bob`)))) + // Mixed placeholders + operator + for _, op := range []string{ + "NotEmpty", + "NotEmpty()", + "$^NotEmpty", + "$^NotEmpty()", + `"$^NotEmpty"`, + `"$^NotEmpty()"`, + `r<$^NotEmpty>`, + `r<$^NotEmpty()>`, + } { + checkOK(t, got, + td.JSON(`{"name":"$name","age":$1,"gender":`+op+`}`, + td.Tag("age", td.Between(40, 45)), + td.Tag("name", td.Re(`^Bob`))), + "using operator %s", op) + } checkOK(t, got, td.JSON(`{"name":Re("^Bo\\w"),"age":Between(40,45),"gender":NotEmpty()}`)) @@ -126,6 +138,31 @@ func TestJSON(t *testing.T) { "age": Between(40,45), "gender": NotEmpty() }`)) + checkOK(t, got, + td.JSON(` +{ + "name": All(Re("^Bo\\w"), HasPrefix("Bo"), HasSuffix("ob")), + "age": Between(40,45), + "gender": NotEmpty +}`)) + + // Same but operators in strings using "$^" + checkOK(t, got, + td.JSON(`{"name":Re("^Bo\\w"),"age":"$^Between(40,45)","gender":"$^NotEmpty()"}`)) + checkOK(t, got, // using classic "" string, so each \ has to be escaped + td.JSON(` +{ + "name": "$^All(Re(\"^Bo\\\\w\"), HasPrefix(\"Bo\"), HasSuffix(\"ob\"))", + "age": "$^Between(40,45)", + "gender": "$^NotEmpty()", +}`)) + checkOK(t, got, // using raw strings, no escape needed + td.JSON(` +{ + "name": "$^All(Re(r(^Bo\\w)), HasPrefix(r{Bo}), HasSuffix(r'ob'))", + "age": "$^Between(40,45)", + "gender": "$^NotEmpty()", +}`)) // …with comments… checkOK(t, got, @@ -138,7 +175,7 @@ func TestJSON(t *testing.T) { - placeholder unquoted, but could be without any change - to demonstrate a multi-lines comment */ - "gender": $^NotEmpty // Shortcut to operator NotEmpty + "gender": $^NotEmpty // Operator NotEmpty }`, td.Tag("age", td.Between(40, 45)), td.Tag("name", td.Re(`^Bob`)))) @@ -338,13 +375,21 @@ func TestJSON(t *testing.T) { Summary: mustBe(`JSON unmarshal error: numeric placeholder "$3", but only one param given at line 1:7 (pos 7)`), }) - // operator shortcut + // $^Operator + checkError(t, "never tested", + td.JSON(`[1, $^bad%]`), + expectedError{ + Message: mustBe("bad usage of JSON operator"), + Path: mustBe("DATA"), + Summary: mustBe(`JSON unmarshal error: $^ must be followed by an operator name at line 1:4 (pos 4)`), + }) + checkError(t, "never tested", td.JSON(`[1, "$^bad%"]`), expectedError{ Message: mustBe("bad usage of JSON operator"), Path: mustBe("DATA"), - Summary: mustBe(`JSON unmarshal error: bad operator shortcut "$^bad%" at line 1:5 (pos 5)`), + Summary: mustBe(`JSON unmarshal error: $^ must be followed by an operator name at line 1:5 (pos 5)`), }) // named placeholders @@ -382,6 +427,28 @@ JSON([ test.EqualStr(t, td.JSON(` null `).String(), `JSON(null)`) + test.EqualStr(t, + td.JSON(`[ $1, $name, $2, Nil(), $nil, 26, Between(5, 6), Len(34), Len(Between(5, 6)), 28 ]`, + td.Between(12, 20), + "test", + td.Tag("name", td.Code(func(s string) bool { return len(s) > 0 })), + td.Tag("nil", nil), + 14, + ).String(), + ` +JSON([ + "$1" /* 12 ≤ got ≤ 20 */, + "$name" /* Code(func(string) bool) */, + "test", + nil, + null, + 26, + 5.0 ≤ got ≤ 6.0, + len=34, + len: 5.0 ≤ got ≤ 6.0, + 28 + ])`[1:]) + test.EqualStr(t, td.JSON(`[ $1, $name, $2, $^Nil, $nil ]`, td.Between(12, 20), @@ -394,7 +461,7 @@ JSON([ "$1" /* 12 ≤ got ≤ 20 */, "$name" /* Code(func(string) bool) */, "test", - "$^Nil", + nil, null ])`[1:]) @@ -419,7 +486,7 @@ JSON({ JSON({ "name": "$1" /* HasPrefix("Alice") */ })) */, - "zip": "$^NotZero" + "zip": NotZero() })`[1:]) test.EqualStr(t, @@ -784,12 +851,24 @@ func TestSubJSONOf(t *testing.T) { td.Tag("age", td.Between(40, 45)), td.Tag("gender", td.NotEmpty()))) - // Mixed placeholders + operator shortcut - checkOK(t, got, - td.SubJSONOf( - `{"name":"$name","age":$1,"gender":$^NotEmpty,"details":{}}`, - td.Tag("age", td.Between(40, 45)), - td.Tag("name", td.Re(`^Bob`)))) + // Mixed placeholders + operator + for _, op := range []string{ + "NotEmpty", + "NotEmpty()", + "$^NotEmpty", + "$^NotEmpty()", + `"$^NotEmpty"`, + `"$^NotEmpty()"`, + `r<$^NotEmpty>`, + `r<$^NotEmpty()>`, + } { + checkOK(t, got, + td.SubJSONOf( + `{"name":"$name","age":$1,"gender":`+op+`,"details":{}}`, + td.Tag("age", td.Between(40, 45)), + td.Tag("name", td.Re(`^Bob`))), + "using operator %s", op) + } // // Errors @@ -848,13 +927,21 @@ func TestSubJSONOf(t *testing.T) { Summary: mustBe(`JSON unmarshal error: numeric placeholder "$3", but only one param given at line 1:7 (pos 7)`), }) - // operator shortcut + // $^Operator + checkError(t, "never tested", + td.SubJSONOf(`[1, $^bad%]`), + expectedError{ + Message: mustBe("bad usage of SubJSONOf operator"), + Path: mustBe("DATA"), + Summary: mustBe(`JSON unmarshal error: $^ must be followed by an operator name at line 1:4 (pos 4)`), + }) + checkError(t, "never tested", td.SubJSONOf(`[1, "$^bad%"]`), expectedError{ Message: mustBe("bad usage of SubJSONOf operator"), Path: mustBe("DATA"), - Summary: mustBe(`JSON unmarshal error: bad operator shortcut "$^bad%" at line 1:5 (pos 5)`), + Summary: mustBe(`JSON unmarshal error: $^ must be followed by an operator name at line 1:5 (pos 5)`), }) // named placeholders @@ -912,7 +999,7 @@ SubJSONOf({ SubJSONOf({ "name": "$1" /* HasPrefix("Alice") */ })) */, - "zip": "$^NotZero" + "zip": NotZero() })`[1:]) // Erroneous op @@ -964,12 +1051,24 @@ func TestSuperJSONOf(t *testing.T) { td.Tag("name", td.Re(`^Bob`)), td.Tag("gender", td.NotEmpty()))) - // Mixed placeholders + operator shortcut - checkOK(t, got, - td.SuperJSONOf( - `{"name":"$name","age":$1,"gender":$^NotEmpty}`, - td.Tag("age", td.Between(40, 45)), - td.Tag("name", td.Re(`^Bob`)))) + // Mixed placeholders + operator + for _, op := range []string{ + "NotEmpty", + "NotEmpty()", + "$^NotEmpty", + "$^NotEmpty()", + `"$^NotEmpty"`, + `"$^NotEmpty()"`, + `r<$^NotEmpty>`, + `r<$^NotEmpty()>`, + } { + checkOK(t, got, + td.SuperJSONOf( + `{"name":"$name","age":$1,"gender":`+op+`}`, + td.Tag("age", td.Between(40, 45)), + td.Tag("name", td.Re(`^Bob`))), + "using operator %s", op) + } // …with comments… checkOK(t, got, @@ -1044,13 +1143,21 @@ func TestSuperJSONOf(t *testing.T) { Summary: mustBe(`JSON unmarshal error: numeric placeholder "$3", but only one param given at line 1:7 (pos 7)`), }) - // operator shortcut + // $^Operator + checkError(t, "never tested", + td.SuperJSONOf(`[1, $^bad%]`), + expectedError{ + Message: mustBe("bad usage of SuperJSONOf operator"), + Path: mustBe("DATA"), + Summary: mustBe(`JSON unmarshal error: $^ must be followed by an operator name at line 1:4 (pos 4)`), + }) + checkError(t, "never tested", td.SuperJSONOf(`[1, "$^bad%"]`), expectedError{ Message: mustBe("bad usage of SuperJSONOf operator"), Path: mustBe("DATA"), - Summary: mustBe(`JSON unmarshal error: bad operator shortcut "$^bad%" at line 1:5 (pos 5)`), + Summary: mustBe(`JSON unmarshal error: $^ must be followed by an operator name at line 1:5 (pos 5)`), }) // named placeholders @@ -1108,7 +1215,7 @@ SuperJSONOf({ SuperJSONOf({ "name": "$1" /* HasPrefix("Alice") */ })) */, - "zip": "$^NotZero" + "zip": NotZero() })`[1:]) // Erroneous op