diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..985f399 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,43 @@ +name: Test + +on: + pull_request: + branches: + - main + types: + - opened + - edited + - synchronize + - reopened + +jobs: + build: + name: Conventional pull request names + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-test: + name: Build & Test + if: ${{ github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: true + - name: Set up Go + uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version-file: "go.mod" + - name: Set up Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: "18.x" + registry-url: "https://registry.npmjs.org" + - name: Set up gotestfmt + run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest + - name: Build + run: go build ./... + - run: go test -json -v -p 1 ./... | gotestfmt \ No newline at end of file diff --git a/integration_test.go b/integration_test.go index e46eeae..79db502 100644 --- a/integration_test.go +++ b/integration_test.go @@ -54,11 +54,17 @@ func TestJSONPathComplianceTestSuite(t *testing.T) { // Test case for a valid selector jp, err := jsonpath.NewPath(test.Selector) if test.InvalidSelector { - require.Error(t, err, "Expected an error for invalid selector, but got none") + require.Error(t, err, "Expected an error for invalid selector, but got none for path", jp.String()) return } else { - require.NoError(t, err, "Failed to parse JSONPath selector") + require.NoError(t, err, "Failed to parse JSONPath selector", jp.String()) } + + // expect stability of ToString() + stringified := jp.String() + recursive, err := jsonpath.NewPath(stringified) + require.NoError(t, err, "Failed to parse recursive JSONPath selector. expected=%s got=%s", test.Selector, jp.String()) + require.Equal(t, stringified, recursive.String(), "JSONPath selector does not match test case") // interface{} to yaml.Node toYAML := func(i interface{}) *yaml.Node { o, err := yaml.Marshal(i) diff --git a/pkg/jsonpath/filter.go b/pkg/jsonpath/filter.go index 5ecf3ff..2f17c24 100644 --- a/pkg/jsonpath/filter.go +++ b/pkg/jsonpath/filter.go @@ -1,6 +1,10 @@ package jsonpath -import "gopkg.in/yaml.v3" +import ( + "gopkg.in/yaml.v3" + "strconv" + "strings" +) // filter-selector = "?" S logical-expr type filterSelector struct { @@ -8,28 +12,72 @@ type filterSelector struct { expression *logicalOrExpr } +func (s filterSelector) ToString() string { + return s.expression.ToString() +} + // logical-or-expr = logical-and-expr *(S "||" S logical-and-expr) type logicalOrExpr struct { expressions []*logicalAndExpr } +func (e logicalOrExpr) ToString() string { + builder := strings.Builder{} + for i, expr := range e.expressions { + if i > 0 { + builder.WriteString(" || ") + } + builder.WriteString(expr.ToString()) + } + return builder.String() +} + // logical-and-expr = basic-expr *(S "&&" S basic-expr) type logicalAndExpr struct { expressions []*basicExpr } +func (e logicalAndExpr) ToString() string { + builder := strings.Builder{} + for i, expr := range e.expressions { + if i > 0 { + builder.WriteString(" && ") + } + builder.WriteString(expr.ToString()) + } + return builder.String() +} + // relQuery rel-query = current-node-identifier segments // current-node-identifier = "@" type relQuery struct { segments []*segment } +func (q relQuery) ToString() string { + builder := strings.Builder{} + builder.WriteString("@") + for _, segment := range q.segments { + builder.WriteString(segment.ToString()) + } + return builder.String() +} + // filterQuery filter-query = rel-query / jsonpath-query type filterQuery struct { relQuery *relQuery jsonPathQuery *jsonPathAST } +func (q filterQuery) ToString() string { + if q.relQuery != nil { + return q.relQuery.ToString() + } else if q.jsonPathQuery != nil { + return q.jsonPathQuery.ToString() + } + return "" +} + // functionArgument function-argument = literal / // // filter-query / ; (includes singular-query) @@ -80,6 +128,20 @@ func (a functionArgument) Eval(node *yaml.Node, root *yaml.Node) resolvedArgumen return resolvedArgument{} } +func (a functionArgument) ToString() string { + builder := strings.Builder{} + if a.literal != nil { + builder.WriteString(a.literal.ToString()) + } else if a.filterQuery != nil { + builder.WriteString(a.filterQuery.ToString()) + } else if a.logicalExpr != nil { + builder.WriteString(a.logicalExpr.ToString()) + } else if a.functionExpr != nil { + builder.WriteString(a.functionExpr.ToString()) + } + return builder.String() +} + //function-name = function-name-first *function-name-char //function-name-first = LCALPHA //function-name-char = function-name-first / "_" / DIGIT @@ -120,6 +182,20 @@ type functionExpr struct { args []*functionArgument } +func (e functionExpr) ToString() string { + builder := strings.Builder{} + builder.WriteString(e.funcType.String()) + builder.WriteString("(") + for i, arg := range e.args { + if i > 0 { + builder.WriteString(", ") + } + builder.WriteString(arg.ToString()) + } + builder.WriteString(")") + return builder.String() +} + // testExpr test-expr = [logical-not-op S] // // (filter-query / ; existence/non-existence @@ -130,6 +206,19 @@ type testExpr struct { functionExpr *functionExpr } +func (e testExpr) ToString() string { + builder := strings.Builder{} + if e.not { + builder.WriteString("!") + } + if e.filterQuery != nil { + builder.WriteString(e.filterQuery.ToString()) + } else if e.functionExpr != nil { + builder.WriteString(e.functionExpr.ToString()) + } + return builder.String() +} + // basicExpr basic-expr = // // paren-expr / @@ -141,6 +230,17 @@ type basicExpr struct { testExpr *testExpr } +func (e basicExpr) ToString() string { + if e.parenExpr != nil { + return e.parenExpr.ToString() + } else if e.comparisonExpr != nil { + return e.comparisonExpr.ToString() + } else if e.testExpr != nil { + return e.testExpr.ToString() + } + return "" +} + // literal literal = number / // . string-literal / // . true / false / null @@ -154,14 +254,102 @@ type literal struct { node *yaml.Node } +func (l literal) ToString() string { + if l.integer != nil { + return strconv.Itoa(*l.integer) + } else if l.float64 != nil { + return strconv.FormatFloat(*l.float64, 'f', -1, 64) + } else if l.string != nil { + builder := strings.Builder{} + builder.WriteString("'") + builder.WriteString(escapeString(*l.string)) + builder.WriteString("'") + return builder.String() + } else if l.bool != nil { + if *l.bool { + return "true" + } else { + return "false" + } + } else if l.null != nil { + if *l.null { + return "null" + } else { + return "null" + } + } else if l.node != nil { + switch l.node.Kind { + case yaml.ScalarNode: + return l.node.Value + case yaml.SequenceNode: + builder := strings.Builder{} + builder.WriteString("[") + for i, child := range l.node.Content { + if i > 0 { + builder.WriteString(",") + } + builder.WriteString(literal{node: child}.ToString()) + } + builder.WriteString("]") + return builder.String() + case yaml.MappingNode: + builder := strings.Builder{} + builder.WriteString("{") + for i, child := range l.node.Content { + if i > 0 { + builder.WriteString(",") + } + builder.WriteString(literal{node: child}.ToString()) + } + builder.WriteString("}") + return builder.String() + } + } + return "" +} + +func escapeString(value string) string { + b := strings.Builder{} + for i := 0; i < len(value); i++ { + if value[i] == '\n' { + b.WriteString("\\\\n") + } else if value[i] == '\\' { + b.WriteString("\\\\") + } else if value[i] == '\'' { + b.WriteString("\\'") + } else { + b.WriteByte(value[i]) + } + } + return b.String() +} + type absQuery jsonPathAST +func (q absQuery) ToString() string { + builder := strings.Builder{} + builder.WriteString("$") + for _, segment := range q.segments { + builder.WriteString(segment.ToString()) + } + return builder.String() +} + // singularQuery singular-query = rel-singular-query / abs-singular-query type singularQuery struct { relQuery *relQuery absQuery *absQuery } +func (q singularQuery) ToString() string { + if q.relQuery != nil { + return q.relQuery.ToString() + } else if q.absQuery != nil { + return q.absQuery.ToString() + } + return "" +} + // comparable // // comparable = literal / @@ -173,6 +361,17 @@ type comparable struct { functionExpr *functionExpr } +func (c comparable) ToString() string { + if c.literal != nil { + return c.literal.ToString() + } else if c.singularQuery != nil { + return c.singularQuery.ToString() + } else if c.functionExpr != nil { + return c.functionExpr.ToString() + } + return "" +} + // comparisonExpr represents a comparison expression // // comparison-expr = comparable S comparison-op S comparable @@ -190,6 +389,16 @@ type comparisonExpr struct { right *comparable } +func (e comparisonExpr) ToString() string { + builder := strings.Builder{} + builder.WriteString(e.left.ToString()) + builder.WriteString(" ") + builder.WriteString(e.op.ToString()) + builder.WriteString(" ") + builder.WriteString(e.right.ToString()) + return builder.String() +} + // existExpr represents an existence expression type existExpr struct { query string @@ -205,6 +414,17 @@ type parenExpr struct { expr *logicalOrExpr } +func (e parenExpr) ToString() string { + builder := strings.Builder{} + if e.not { + builder.WriteString("!") + } + builder.WriteString("(") + builder.WriteString(e.expr.ToString()) + builder.WriteString(")") + return builder.String() +} + // comparisonOperator represents a comparison operator type comparisonOperator int @@ -216,3 +436,21 @@ const ( greaterThan greaterThanEqualTo ) + +func (o comparisonOperator) ToString() string { + switch o { + case equalTo: + return "==" + case notEqualTo: + return "!=" + case lessThan: + return "<" + case lessThanEqualTo: + return "<=" + case greaterThan: + return ">" + case greaterThanEqualTo: + return ">=" + } + return "" +} diff --git a/pkg/jsonpath/jsonpath.go b/pkg/jsonpath/jsonpath.go index 3922d1d..22d2192 100644 --- a/pkg/jsonpath/jsonpath.go +++ b/pkg/jsonpath/jsonpath.go @@ -25,3 +25,10 @@ func NewPath(input string) (*JSONPath, error) { func (p *JSONPath) Query(root *yaml.Node) []*yaml.Node { return p.ast.Query(root, root) } + +func (p *JSONPath) String() string { + if p == nil { + return "" + } + return p.ast.ToString() +} diff --git a/pkg/jsonpath/parser.go b/pkg/jsonpath/parser.go index 242fa04..af233cd 100644 --- a/pkg/jsonpath/parser.go +++ b/pkg/jsonpath/parser.go @@ -133,7 +133,7 @@ func (p *JSONPath) parseInnerSegment() (retValue *innerSegment, err error) { } else if firstToken.Token == token.BRACKET_LEFT { prior := p.current p.current += 1 - selectors := []*Selector{} + selectors := []*selector{} for p.current < len(p.tokens) { innerSelector, err := p.parseSelector() if err != nil { @@ -157,7 +157,7 @@ func (p *JSONPath) parseInnerSegment() (retValue *innerSegment, err error) { return nil, p.parseFailure(&firstToken, "unexpected token when parsing inner segment") } -func (p *JSONPath) parseSelector() (retSelector *Selector, err error) { +func (p *JSONPath) parseSelector() (retSelector *selector, err error) { //selector = name-selector / // wildcard-selector / // slice-selector / @@ -166,10 +166,10 @@ func (p *JSONPath) parseSelector() (retSelector *Selector, err error) { initial := p.current defer func() { if p.mode[len(p.mode)-1] == modeSingular && retSelector != nil { - if retSelector.Kind == SelectorSubKindWildcard { + if retSelector.kind == selectorSubKindWildcard { err = p.parseFailure(&p.tokens[initial], "unexpected wildcard in singular query") retSelector = nil - } else if retSelector.Kind == SelectorSubKindArraySlice { + } else if retSelector.kind == selectorSubKindArraySlice { err = p.parseFailure(&p.tokens[initial], "unexpected slice in singular query") retSelector = nil } @@ -180,11 +180,11 @@ func (p *JSONPath) parseSelector() (retSelector *Selector, err error) { if p.tokens[p.current].Token == token.STRING_LITERAL { name := p.tokens[p.current].Literal p.current++ - return &Selector{Kind: SelectorSubKindName, name: name}, nil + return &selector{kind: selectorSubKindName, name: name}, nil // wildcard-selector = "*" } else if p.tokens[p.current].Token == token.WILDCARD { p.current++ - return &Selector{Kind: SelectorSubKindWildcard}, nil + return &selector{kind: selectorSubKindWildcard}, nil } else if p.tokens[p.current].Token == token.INTEGER { // peek ahead to see if it's a slice if p.peek(token.ARRAY_SLICE) { @@ -192,7 +192,7 @@ func (p *JSONPath) parseSelector() (retSelector *Selector, err error) { if err != nil { return nil, err } - return &Selector{Kind: SelectorSubKindArraySlice, slice: slice}, nil + return &selector{kind: selectorSubKindArraySlice, slice: slice}, nil } // peek ahead to see if we close the array index properly if !p.peek(token.BRACKET_RIGHT) && !p.peek(token.COMMA) { @@ -216,13 +216,13 @@ func (p *JSONPath) parseSelector() (retSelector *Selector, err error) { p.current++ - return &Selector{Kind: SelectorSubKindArrayIndex, index: int(i)}, nil + return &selector{kind: selectorSubKindArrayIndex, index: int(i)}, nil } else if p.tokens[p.current].Token == token.ARRAY_SLICE { slice, err := p.parseSliceSelector() if err != nil { return nil, err } - return &Selector{Kind: SelectorSubKindArraySlice, slice: slice}, nil + return &selector{kind: selectorSubKindArraySlice, slice: slice}, nil } else if p.tokens[p.current].Token == token.FILTER { return p.parseFilterSelector() } @@ -230,7 +230,7 @@ func (p *JSONPath) parseSelector() (retSelector *Selector, err error) { return nil, p.parseFailure(&p.tokens[p.current], "unexpected token when parsing selector") } -func (p *JSONPath) parseSliceSelector() (*Slice, error) { +func (p *JSONPath) parseSliceSelector() (*slice, error) { // slice-selector = [start S] ":" S [end S] [":" [S step]] var start, end, step *int @@ -294,7 +294,7 @@ func (p *JSONPath) parseSliceSelector() (*Slice, error) { return nil, p.parseFailure(&p.tokens[p.current], "expected ']'") } - return &Slice{Start: start, End: end, Step: step}, nil + return &slice{start: start, end: end, step: step}, nil } func (p *JSONPath) checkSafeInteger(i int, literal string) error { @@ -307,7 +307,7 @@ func (p *JSONPath) checkSafeInteger(i int, literal string) error { return nil } -func (p *JSONPath) parseFilterSelector() (*Selector, error) { +func (p *JSONPath) parseFilterSelector() (*selector, error) { if p.tokens[p.current].Token != token.FILTER { return nil, p.parseFailure(&p.tokens[p.current], "expected '?'") @@ -319,7 +319,7 @@ func (p *JSONPath) parseFilterSelector() (*Selector, error) { return nil, err } - return &Selector{Kind: SelectorSubKindFilter, filter: &filterSelector{expr}}, nil + return &selector{kind: selectorSubKindFilter, filter: &filterSelector{expr}}, nil } func (p *JSONPath) parseLogicalOrExpr() (*logicalOrExpr, error) { @@ -372,6 +372,12 @@ func (p *JSONPath) parseBasicExpr() (*basicExpr, error) { if err != nil { return nil, err } + // Inspect if the expr is topped by a parenExpr -- if so we can simplify + if len(expr.expressions) == 1 && len(expr.expressions[0].expressions) == 1 && expr.expressions[0].expressions[0].parenExpr != nil { + child := expr.expressions[0].expressions[0].parenExpr + child.not = !child.not + return &basicExpr{parenExpr: child}, nil + } return &basicExpr{parenExpr: &parenExpr{not: true, expr: expr}}, nil case token.PAREN_LEFT: p.current++ diff --git a/pkg/jsonpath/segment.go b/pkg/jsonpath/segment.go index 5e3d22a..42876df 100644 --- a/pkg/jsonpath/segment.go +++ b/pkg/jsonpath/segment.go @@ -15,7 +15,7 @@ type segmentSubKind int const ( segmentDotWildcard segmentSubKind = iota // .* segmentDotMemberName // .property - segmentLongHand // [ Selector[] ] + segmentLongHand // [ selector[] ] ) func (s segment) ToString() string { @@ -35,7 +35,7 @@ func (s segment) ToString() string { type innerSegment struct { kind segmentSubKind dotName string - selectors []*Selector + selectors []*selector } func (s innerSegment) ToString() string { @@ -52,7 +52,7 @@ func (s innerSegment) ToString() string { for i, selector := range s.selectors { builder.WriteString(selector.ToString()) if i < len(s.selectors)-1 { - builder.WriteString(",") + builder.WriteString(", ") } } builder.WriteString("]") diff --git a/pkg/jsonpath/selector.go b/pkg/jsonpath/selector.go index 52cc354..401b976 100644 --- a/pkg/jsonpath/selector.go +++ b/pkg/jsonpath/selector.go @@ -3,41 +3,61 @@ package jsonpath import ( "fmt" "strconv" + "strings" ) -type SelectorSubKind int +type selectorSubKind int const ( - SelectorSubKindWildcard SelectorSubKind = iota - SelectorSubKindName - SelectorSubKindArraySlice - SelectorSubKindArrayIndex - SelectorSubKindFilter + selectorSubKindWildcard selectorSubKind = iota + selectorSubKindName + selectorSubKindArraySlice + selectorSubKindArrayIndex + selectorSubKindFilter ) -type Slice struct { - Start *int - End *int - Step *int +type slice struct { + start *int + end *int + step *int } -type Selector struct { - Kind SelectorSubKind +type selector struct { + kind selectorSubKind name string index int - slice *Slice + slice *slice filter *filterSelector } -func (s Selector) ToString() string { - switch s.Kind { - case SelectorSubKindName: - return "\"" + s.name + "\"" - case SelectorSubKindArrayIndex: +func (s selector) ToString() string { + switch s.kind { + case selectorSubKindName: + return "'" + escapeString(s.name) + "'" + case selectorSubKindArrayIndex: // int to string - return "[" + strconv.Itoa(s.index) + "]" + return strconv.Itoa(s.index) + case selectorSubKindFilter: + return "?" + s.filter.ToString() + case selectorSubKindWildcard: + return "*" + case selectorSubKindArraySlice: + builder := strings.Builder{} + if s.slice.start != nil { + builder.WriteString(strconv.Itoa(*s.slice.start)) + } + builder.WriteString(":") + if s.slice.end != nil { + builder.WriteString(strconv.Itoa(*s.slice.end)) + } + + if s.slice.step != nil { + builder.WriteString(":") + builder.WriteString(strconv.Itoa(*s.slice.step)) + } + return builder.String() default: - panic(fmt.Sprintf("unimplemented selector kind: %v", s.Kind)) + panic(fmt.Sprintf("unimplemented selector kind: %v", s.kind)) } return "" } diff --git a/pkg/jsonpath/token/token.go b/pkg/jsonpath/token/token.go index 22822d3..5b621df 100644 --- a/pkg/jsonpath/token/token.go +++ b/pkg/jsonpath/token/token.go @@ -639,7 +639,7 @@ func (t *Tokenizer) scanNumber() { tokenType = ILLEGAL } // conformance spec - if len(literal) > 1 && literal[0] == '0' { + if len(literal) > 1 && literal[0] == '0' && !dotSeen { // no leading zero tokenType = ILLEGAL } else if len(literal) > 2 && literal[0] == '-' && literal[1] == '0' && !dotSeen { diff --git a/pkg/jsonpath/yaml_query.go b/pkg/jsonpath/yaml_query.go index 2ea0b61..fa6684a 100644 --- a/pkg/jsonpath/yaml_query.go +++ b/pkg/jsonpath/yaml_query.go @@ -110,9 +110,9 @@ func (s innerSegment) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { } -func (s Selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { - switch s.Kind { - case SelectorSubKindName: +func (s selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { + switch s.kind { + case selectorSubKindName: if value.Kind != yaml.MappingNode { return nil } @@ -127,7 +127,7 @@ func (s Selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { return []*yaml.Node{child} } } - case SelectorSubKindArrayIndex: + case selectorSubKindArrayIndex: if value.Kind != yaml.SequenceNode { return nil } @@ -140,7 +140,7 @@ func (s Selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { return []*yaml.Node{value.Content[len(value.Content)+s.index]} } return []*yaml.Node{value.Content[s.index]} - case SelectorSubKindWildcard: + case selectorSubKindWildcard: if value.Kind == yaml.SequenceNode { return value.Content } else if value.Kind == yaml.MappingNode { @@ -153,7 +153,7 @@ func (s Selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { return result } return nil - case SelectorSubKindArraySlice: + case selectorSubKindArraySlice: if value.Kind != yaml.SequenceNode { return nil } @@ -161,14 +161,14 @@ func (s Selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { return nil } step := 1 - if s.slice.Step != nil { - step = *s.slice.Step + if s.slice.step != nil { + step = *s.slice.step } if step == 0 { return nil } - start, end := s.slice.Start, s.slice.End + start, end := s.slice.start, s.slice.end lower, upper := bounds(start, end, step, len(value.Content)) var result []*yaml.Node @@ -183,7 +183,7 @@ func (s Selector) Query(value *yaml.Node, root *yaml.Node) []*yaml.Node { } return result - case SelectorSubKindFilter: + case selectorSubKindFilter: var result []*yaml.Node switch value.Kind { case yaml.MappingNode: diff --git a/web/src/Playground.tsx b/web/src/Playground.tsx index 8edb93f..12e6c21 100644 --- a/web/src/Playground.tsx +++ b/web/src/Playground.tsx @@ -149,7 +149,7 @@ function Playground() { OpenAPI Overlay Specification {" "} - lets you update arbitrary values in an YAML document using{" "} + lets you update arbitrary values in a YAML document using{" "} jsonpath