From af1de757bc01c9c48b522b576b0a1c261236a7d2 Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 21 Jul 2026 07:51:20 -0400 Subject: [PATCH] feat: add Spectral JSONPath compatibility dialect with evaluator and conformance corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an opt-in Spectral compatibility mode (config.WithSpectralCompatibility) that evaluates JavaScript-style filter expressions used by Spectral rulesets — method calls like .match()/.indexOf(), regex literals, and standalone @property truthiness — while preserving RFC 9535 and JSONPath Plus behavior. - Add spectral_expr.go and spectral_eval.go implementing the compatibility grammar and runtime evaluator - Wire the dialect through config, parser, filter, selector, segment, and token handling - Add conformance corpus, official Spectral parity fixtures, fuzz tests, and benchmarks under testdata/spectral, with generator scripts for collecting public ruleset selectors - Pinned public-ruleset inventory covers 51 deduplicated selectors at 100% compile and round-trip compatibility - Update README with the new dialect documentation --- README.md | 111 ++ pkg/jsonpath/config/config.go | 36 + pkg/jsonpath/config/config_test.go | 53 + pkg/jsonpath/context_usage.go | 21 +- pkg/jsonpath/context_usage_test.go | 46 +- pkg/jsonpath/filter.go | 662 +++---- pkg/jsonpath/jsonpath.go | 25 +- pkg/jsonpath/parser.go | 1417 +++++++-------- pkg/jsonpath/parser_lazy_context_test.go | 4 +- pkg/jsonpath/segment.go | 15 +- pkg/jsonpath/selector.go | 4 +- pkg/jsonpath/spectral_bench_test.go | 95 + pkg/jsonpath/spectral_corpus_test.go | 271 +++ pkg/jsonpath/spectral_eval.go | 529 ++++++ pkg/jsonpath/spectral_eval_test.go | 198 +++ pkg/jsonpath/spectral_expr.go | 659 +++++++ pkg/jsonpath/spectral_fuzz_test.go | 38 + pkg/jsonpath/spectral_test.go | 413 +++++ pkg/jsonpath/testdata/spectral/corpus.json | 349 ++++ .../spectral/generate/collect-official.mjs | 76 + .../spectral/generate/collect_public.go | 277 +++ .../spectral/generate/collect_public_test.go | 57 + .../testdata/spectral/generate/generate.mjs | 80 + .../spectral/generate/package-lock.json | 114 ++ .../testdata/spectral/generate/package.json | 15 + .../testdata/spectral/official-input.json | 108 ++ .../testdata/spectral/official-parity.json | 1274 ++++++++++++++ .../testdata/spectral/official-selectors.json | 138 ++ .../testdata/spectral/public-selectors.json | 393 +++++ pkg/jsonpath/token/spectral_token_test.go | 79 + pkg/jsonpath/token/token.go | 1529 +++++++++-------- pkg/jsonpath/yaml_query.go | 45 +- 32 files changed, 7359 insertions(+), 1772 deletions(-) create mode 100644 pkg/jsonpath/spectral_bench_test.go create mode 100644 pkg/jsonpath/spectral_corpus_test.go create mode 100644 pkg/jsonpath/spectral_eval.go create mode 100644 pkg/jsonpath/spectral_eval_test.go create mode 100644 pkg/jsonpath/spectral_expr.go create mode 100644 pkg/jsonpath/spectral_fuzz_test.go create mode 100644 pkg/jsonpath/spectral_test.go create mode 100644 pkg/jsonpath/testdata/spectral/corpus.json create mode 100644 pkg/jsonpath/testdata/spectral/generate/collect-official.mjs create mode 100644 pkg/jsonpath/testdata/spectral/generate/collect_public.go create mode 100644 pkg/jsonpath/testdata/spectral/generate/collect_public_test.go create mode 100644 pkg/jsonpath/testdata/spectral/generate/generate.mjs create mode 100644 pkg/jsonpath/testdata/spectral/generate/package-lock.json create mode 100644 pkg/jsonpath/testdata/spectral/generate/package.json create mode 100644 pkg/jsonpath/testdata/spectral/official-input.json create mode 100644 pkg/jsonpath/testdata/spectral/official-parity.json create mode 100644 pkg/jsonpath/testdata/spectral/official-selectors.json create mode 100644 pkg/jsonpath/testdata/spectral/public-selectors.json create mode 100644 pkg/jsonpath/token/spectral_token_test.go diff --git a/README.md b/README.md index 374c319..63e1f2c 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,117 @@ $.person.*~ --- +## Spectral Compatibility + +Spectral rulesets use a safe JavaScript-flavored JSONPath subset that is not +part of RFC 9535. Enable it explicitly when executing Spectral `given` +selectors: + +```go +import ( + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" +) + +path, err := jsonpath.NewPath( + `$.paths[?(@property && !@property.match(/\/openapi\.json/))]~`, + config.WithSpectralCompatibility(), +) +``` + +`WithSpectralCompatibility()` implies the property-name extension and all +JSONPath Plus features, including context variables, `~`, and `^`. +`WithPropertyNameExtension()` may still be supplied and is redundant but +valid. `WithLazyContextTracking()` is independent and produces the same +results in eager and lazy modes. + +Spectral compatibility and `WithStrictRFC9535()` are mutually exclusive. +Supplying both returns a configuration error regardless of option order. The +default dialect remains unchanged. + +### Compatibility matrix + +| Status | Feature | Behavior | +|--------|---------|----------| +| Supported | Truthiness | Missing values, `null`, `false`, zero, and empty strings are false; arrays, objects, non-empty strings, and non-zero numbers are true | +| Supported | Regex literals | `/pattern/flags`, compiled once with Go's linear-time RE2 engine | +| Supported | `value.match(regex)` | Partial regex search on strings; usable only as a truthy test | +| Supported | `value.indexOf(string[, fromIndex])` | JavaScript UTF-16 code-unit indexing on strings | +| Supported | `value.includes(value[, fromIndex])` | Safe membership checks on strings and sequences | +| Supported | `value.length` | UTF-16 length for strings and element count for sequences | +| Supported | `value.constructor.name` | Read-only synthetic type metadata: `Array`, `Object`, `String`, `Number`, or `Boolean` | +| Supported | `void 0` | The missing/undefined value used by official Spectral selectors | +| Supported | `===` and `!==` | JavaScript strict equality, including reference identity for arrays and objects | +| Supported | `==` and `!=` | JavaScript scalar coercion and reference identity for arrays and objects | +| Partial by design | `value.match(regex)` result | Truthiness is exact; comparison and result chaining are rejected because the evaluator does not expose JavaScript match arrays | +| Partial by design | Regex `u` flag | Accepted for RE2-compatible patterns; JavaScript-only code-point escapes are rejected | +| Unsupported | Arbitrary JavaScript and methods | Assignments, statements, functions, unknown methods, constructors, prototype access, and reflection are rejected | +| Unsupported | JavaScript-only regex features | Lookaround, backreferences, `d`, `v`, and `y` flags are rejected | + +RFC functions remain available in Spectral mode. For example, +`match(@.name, 'a.*')` is RFC 9535 full-string matching, whereas +`@.name.match(/a/)` is a Spectral partial search. Spectral `match` returns an +internal boolean-compatible result; comparing it, reading its length, or +chaining from it is rejected rather than approximated. + +### Context value types + +Spectral mode follows JavaScript object and array property behavior: + +- `@property` and `@parentProperty` are strings for mapping entries. YAML keys + are stringified, so an unquoted response key such as `404` works with + `@property.match(/^(4|5)/)`. +- They are numbers for sequence-index comparisons. `@property === 3` can match + the fourth element, while `@property === '3'` cannot. Nimma nevertheless + stringifies a sequence property for its whitelisted `match`, `indexOf`, and + `includes` operations; this engine-specific behavior is pinned in the + differential corpus. +- A method or synthetic property used on the wrong value type evaluates as a + non-match and never panics. + +### Regex dialect and safety + +Regex flags `i`, `m`, and `s` map to RE2 modes. `g` is accepted because it does +not change boolean match results. `u` is accepted only when the pattern does +not require JavaScript-only Unicode syntax such as `\u{...}`. Flags `d`, `v`, +and `y`, duplicate or unknown flags, lookaround, and backreferences are +rejected during compilation with line and column information. + +Spectral mode does not execute JavaScript and does not use reflection. It +rejects arbitrary methods, assignments, statements, constructors, +`__proto__`, prototype traversal, computed constructor access, getters, and +setters before document traversal. Only the operations listed above are +available. + +### Compatibility baseline + +The checked corpus in +[`pkg/jsonpath/testdata/spectral`](pkg/jsonpath/testdata/spectral) records the +pinned Spectral version, source selector, exact normalized result paths, +multiplicity, and whether Spectral selected Nimma or its JSONPath Plus +fallback. It includes every selector collected from the pinned official +OpenAPI and AsyncAPI rulesets plus focused migration, public-ruleset, Unicode, +security, and Vacuum issue fixtures. A separate public inventory classifies 51 +deduplicated selectors from four pinned rulesets. Public Spectral aliases are +expanded before classification, and every source records its repository, +commit, path, and license identifier. + +Regenerate the differential results and official-selector inventory with: + +```bash +cd pkg/jsonpath/testdata/spectral/generate +npm ci +npm run collect-official +npm run collect-public +npm run generate +``` + +The currently supported boundary is documented above. Arbitrary JavaScript, +user-provided functions, prototype access, and unsupported regex constructs +are intentionally unsupported. + +--- + ## Overlay Support This library includes support for YAML overlays, which allow you to apply structured modifications to YAML documents using JSONPath expressions. diff --git a/pkg/jsonpath/config/config.go b/pkg/jsonpath/config/config.go index a064736..c0c2250 100644 --- a/pkg/jsonpath/config/config.go +++ b/pkg/jsonpath/config/config.go @@ -1,5 +1,8 @@ package config +import "errors" + +// Option configures JSONPath compilation and evaluation. type Option func(*config) // WithPropertyNameExtension enables the use of the "~" character to access a property key. @@ -28,6 +31,20 @@ func WithStrictRFC9535() Option { } } +// WithSpectralCompatibility enables the safe Spectral expression dialect. +// +// Spectral compatibility includes JSONPath Plus context variables, parent +// selection and the property-name extension. It cannot be combined with +// WithStrictRFC9535; callers should validate Config before use. NewPath does +// this automatically. +func WithSpectralCompatibility() Option { + return func(cfg *config) { + cfg.spectralCompatibility = true + cfg.propertyNameExtension = true + } +} + +// Config exposes the resolved JSONPath dialect and context-tracking settings. type Config interface { PropertyNameEnabled() bool JSONPathPlusEnabled() bool @@ -38,6 +55,7 @@ type config struct { propertyNameExtension bool strictRFC9535 bool lazyContextTracking bool + spectralCompatibility bool } func (c *config) PropertyNameEnabled() bool { @@ -57,6 +75,24 @@ func (c *config) LazyContextTrackingEnabled() bool { return c.lazyContextTracking } +// SpectralCompatibilityEnabled reports whether cfg enables the safe Spectral dialect. +// It is a function rather than a Config method so adding the dialect does not +// break external implementations of the existing Config interface. +func SpectralCompatibilityEnabled(cfg Config) bool { + resolved, ok := cfg.(*config) + return ok && resolved.spectralCompatibility +} + +// Validate rejects incompatible dialect options without making option order significant. +func Validate(cfg Config) error { + resolved, ok := cfg.(*config) + if ok && resolved.strictRFC9535 && resolved.spectralCompatibility { + return errors.New("config.WithStrictRFC9535 and config.WithSpectralCompatibility cannot be combined") + } + return nil +} + +// New resolves options into an immutable-by-interface configuration view. func New(opts ...Option) Config { cfg := &config{} for _, opt := range opts { diff --git a/pkg/jsonpath/config/config_test.go b/pkg/jsonpath/config/config_test.go index 42fc5bc..74c527e 100644 --- a/pkg/jsonpath/config/config_test.go +++ b/pkg/jsonpath/config/config_test.go @@ -2,6 +2,14 @@ package config import "testing" +type legacyConfigImplementation struct{} + +func (legacyConfigImplementation) PropertyNameEnabled() bool { return false } +func (legacyConfigImplementation) JSONPathPlusEnabled() bool { return true } +func (legacyConfigImplementation) LazyContextTrackingEnabled() bool { return false } + +var _ Config = legacyConfigImplementation{} + func TestLazyContextTrackingOption(t *testing.T) { cfg := New() if cfg.LazyContextTrackingEnabled() { @@ -13,3 +21,48 @@ func TestLazyContextTrackingOption(t *testing.T) { t.Fatalf("expected lazy context tracking enabled with option") } } + +func TestSpectralCompatibilityOption(t *testing.T) { + cfg := New(WithSpectralCompatibility()) + if !SpectralCompatibilityEnabled(cfg) { + t.Fatal("expected Spectral compatibility to be enabled") + } + if !cfg.JSONPathPlusEnabled() || !cfg.PropertyNameEnabled() { + t.Fatal("expected Spectral compatibility to imply JSONPath Plus and property-name extensions") + } + if err := Validate(cfg); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestSpectralAndExplicitPropertyNameExtensionAreCompatible(t *testing.T) { + cfg := New(WithSpectralCompatibility(), WithPropertyNameExtension()) + if err := Validate(cfg); err != nil { + t.Fatalf("redundant property-name option should be valid: %v", err) + } + if !cfg.PropertyNameEnabled() || !SpectralCompatibilityEnabled(cfg) { + t.Fatal("expected both implied settings to remain enabled") + } +} + +func TestSpectralAndStrictConflictIsOrderIndependent(t *testing.T) { + tests := []Config{ + New(WithSpectralCompatibility(), WithStrictRFC9535()), + New(WithStrictRFC9535(), WithSpectralCompatibility()), + } + for _, cfg := range tests { + if err := Validate(cfg); err == nil { + t.Fatal("expected incompatible dialects to be rejected") + } + } +} + +func TestLegacyConfigImplementationsRemainCompatible(t *testing.T) { + cfg := Config(legacyConfigImplementation{}) + if SpectralCompatibilityEnabled(cfg) { + t.Fatal("legacy Config implementation unexpectedly enabled Spectral compatibility") + } + if err := Validate(cfg); err != nil { + t.Fatalf("legacy Config implementation failed validation: %v", err) + } +} diff --git a/pkg/jsonpath/context_usage.go b/pkg/jsonpath/context_usage.go index 4ee137f..6d5e7d9 100644 --- a/pkg/jsonpath/context_usage.go +++ b/pkg/jsonpath/context_usage.go @@ -63,7 +63,7 @@ func (q *jsonPathAST) hasPropertyNameReferencesPtr() bool { // hasPropertyNameReferences reports whether the segment references the property name selector func (s *segment) hasPropertyNameReferences() bool { - if s.kind == segmentKindProperyName { + if s.kind == segmentKindProperyName || s.kind == segmentKindRecursivePropertyName { return true } if s.child != nil && s.child.hasPropertyNameReferences() { @@ -87,7 +87,7 @@ func (s *innerSegment) hasPropertyNameReferences() bool { // hasPropertyNameReferences reports whether the selector references the property name selector func (s *selector) hasPropertyNameReferences() bool { - if s.filter != nil && s.filter.hasPropertyNameReferences() { + if s.filter.present() && s.filter.hasPropertyNameReferences() { return true } return false @@ -95,6 +95,9 @@ func (s *selector) hasPropertyNameReferences() bool { // hasPropertyNameReferences reports whether the filter selector references the property name selector func (f *filterSelector) hasPropertyNameReferences() bool { + if f.spectralExpression != nil { + return f.spectralExpression.usesPropertyNameSelector + } if f.expression == nil { return false } @@ -256,13 +259,25 @@ func (s *innerSegment) collectContextVarUsage(usage *contextVarUsage) { // collectContextVarUsage records usage from a selector func (s *selector) collectContextVarUsage(usage *contextVarUsage) { - if s.filter != nil { + if s.filter.present() { s.filter.collectContextVarUsage(usage) } } // collectContextVarUsage records usage from a filter selector func (f *filterSelector) collectContextVarUsage(usage *contextVarUsage) { + if f.spectralExpression != nil { + usage.property = usage.property || f.spectralExpression.usage.property + // Parent-property typing needs the container of the parent so sequence + // indexes remain numeric under lazy context tracking. + usage.parent = usage.parent || f.spectralExpression.usage.parent || f.spectralExpression.usage.parentProperty + usage.parentProperty = usage.parentProperty || f.spectralExpression.usage.parentProperty + usage.path = usage.path || f.spectralExpression.usage.path + // Spectral @property is numeric for sequence elements, so lazy mode + // needs the index even when @index is not referenced explicitly. + usage.index = usage.index || f.spectralExpression.usage.index || f.spectralExpression.usage.property + return + } if f.expression != nil { f.expression.collectContextVarUsage(usage) } diff --git a/pkg/jsonpath/context_usage_test.go b/pkg/jsonpath/context_usage_test.go index daf5691..6953937 100644 --- a/pkg/jsonpath/context_usage_test.go +++ b/pkg/jsonpath/context_usage_test.go @@ -79,7 +79,7 @@ func TestContextUsageAndPropertyReferenceDetection(t *testing.T) { } // positive branches across each helper type - truthySelector := &selector{filter: &filterSelector{expression: &logicalOrExpr{ + truthySelector := &selector{filter: filterSelector{expression: &logicalOrExpr{ expressions: []*logicalAndExpr{ { expressions: []*basicExpr{ @@ -182,7 +182,7 @@ func TestCollectContextVarUsageCoversBranches(t *testing.T) { }, } childSelector := &selector{ - filter: &filterSelector{expression: &logicalOrExpr{ + filter: filterSelector{expression: &logicalOrExpr{ expressions: []*logicalAndExpr{{expressions: []*basicExpr{firstBasic}}}, }}, } @@ -221,7 +221,7 @@ func TestCollectContextVarUsageCoversBranches(t *testing.T) { }, } descSelector := &selector{ - filter: &filterSelector{expression: &logicalOrExpr{ + filter: filterSelector{expression: &logicalOrExpr{ expressions: []*logicalAndExpr{{expressions: []*basicExpr{secondBasic}}}, }}, } @@ -283,26 +283,26 @@ type bareFilterContext struct { _index } -func (b *bareFilterContext) PropertyName() string { return "" } -func (b *bareFilterContext) SetPropertyName(string) {} -func (b *bareFilterContext) Parent() *yaml.Node { return nil } -func (b *bareFilterContext) SetParent(*yaml.Node) {} -func (b *bareFilterContext) ParentPropertyName() string { return "" } -func (b *bareFilterContext) SetParentPropertyName(string) {} -func (b *bareFilterContext) Path() string { return "$" } -func (b *bareFilterContext) PushPathSegment(string) {} -func (b *bareFilterContext) PopPathSegment() {} -func (b *bareFilterContext) SetPendingPathSegment(*yaml.Node, string) {} -func (b *bareFilterContext) GetAndClearPendingPathSegment(*yaml.Node) string { return "" } -func (b *bareFilterContext) SetPendingPropertyName(*yaml.Node, string) {} -func (b *bareFilterContext) GetAndClearPendingPropertyName(*yaml.Node) string { return "" } -func (b *bareFilterContext) Root() *yaml.Node { return nil } -func (b *bareFilterContext) SetRoot(*yaml.Node) {} -func (b *bareFilterContext) Index() int { return -1 } -func (b *bareFilterContext) SetIndex(int) {} -func (b *bareFilterContext) EnableParentTracking() {} -func (b *bareFilterContext) ParentTrackingEnabled() bool { return false } -func (b *bareFilterContext) Clone() FilterContext { return b } +func (b *bareFilterContext) PropertyName() string { return "" } +func (b *bareFilterContext) SetPropertyName(string) {} +func (b *bareFilterContext) Parent() *yaml.Node { return nil } +func (b *bareFilterContext) SetParent(*yaml.Node) {} +func (b *bareFilterContext) ParentPropertyName() string { return "" } +func (b *bareFilterContext) SetParentPropertyName(string) {} +func (b *bareFilterContext) Path() string { return "$" } +func (b *bareFilterContext) PushPathSegment(string) {} +func (b *bareFilterContext) PopPathSegment() {} +func (b *bareFilterContext) SetPendingPathSegment(*yaml.Node, string) {} +func (b *bareFilterContext) GetAndClearPendingPathSegment(*yaml.Node) string { return "" } +func (b *bareFilterContext) SetPendingPropertyName(*yaml.Node, string) {} +func (b *bareFilterContext) GetAndClearPendingPropertyName(*yaml.Node) string { return "" } +func (b *bareFilterContext) Root() *yaml.Node { return nil } +func (b *bareFilterContext) SetRoot(*yaml.Node) {} +func (b *bareFilterContext) Index() int { return -1 } +func (b *bareFilterContext) SetIndex(int) {} +func (b *bareFilterContext) EnableParentTracking() {} +func (b *bareFilterContext) ParentTrackingEnabled() bool { return false } +func (b *bareFilterContext) Clone() FilterContext { return b } func TestEnableTrackingHelpersNoOpForMissingOptionalMethods(t *testing.T) { ctx := &bareFilterContext{_index: _index{ diff --git a/pkg/jsonpath/filter.go b/pkg/jsonpath/filter.go index 67afe7e..a481039 100644 --- a/pkg/jsonpath/filter.go +++ b/pkg/jsonpath/filter.go @@ -1,81 +1,89 @@ package jsonpath import ( - "go.yaml.in/yaml/v4" - "strconv" - "strings" + "go.yaml.in/yaml/v4" + "strconv" + "strings" ) // filter-selector = "?" S logical-expr type filterSelector struct { - // logical-expr = logical-or-expr - expression *logicalOrExpr + // logical-expr = logical-or-expr + expression *logicalOrExpr + spectralExpression *spectralBoolExpr +} + +func (s filterSelector) present() bool { + return s.expression != nil || s.spectralExpression != nil } func (s filterSelector) ToString() string { - return s.expression.ToString() + if s.spectralExpression != nil { + return s.spectralExpression.String() + } + return s.expression.ToString() } // logical-or-expr = logical-and-expr *(S "||" S logical-and-expr) type logicalOrExpr struct { - expressions []*logicalAndExpr + 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() + 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 + 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() + 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 + segments []*segment } func (q relQuery) ToString() string { - builder := strings.Builder{} - builder.WriteString("@") - for _, segment := range q.segments { - builder.WriteString(segment.ToString()) - } - return builder.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 + 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 "" + if q.relQuery != nil { + return q.relQuery.ToString() + } else if q.jsonPathQuery != nil { + return q.jsonPathQuery.ToString() + } + return "" } // functionArgument function-argument = literal / @@ -84,69 +92,69 @@ func (q filterQuery) ToString() string { // logical-expr / // function-expr type functionArgument struct { - literal *literal - filterQuery *filterQuery - logicalExpr *logicalOrExpr - functionExpr *functionExpr - contextVar *contextVariable // JSONPath Plus context variables + literal *literal + filterQuery *filterQuery + logicalExpr *logicalOrExpr + functionExpr *functionExpr + contextVar *contextVariable // JSONPath Plus context variables } type functionArgType int const ( - functionArgTypeLiteral functionArgType = iota - functionArgTypeNodes + functionArgTypeLiteral functionArgType = iota + functionArgTypeNodes ) type resolvedArgument struct { - kind functionArgType - literal *literal - nodes []*literal + kind functionArgType + literal *literal + nodes []*literal } func (a functionArgument) Eval(idx index, node *yaml.Node, root *yaml.Node) resolvedArgument { - if a.literal != nil { - return resolvedArgument{kind: functionArgTypeLiteral, literal: a.literal} - } else if a.filterQuery != nil { - result := a.filterQuery.Query(idx, node, root) - lits := make([]*literal, len(result)) - for i, node := range result { - lit := nodeToLiteral(node) - lits[i] = &lit - } - if len(result) != 1 { - return resolvedArgument{kind: functionArgTypeNodes, nodes: lits} - } else { - return resolvedArgument{kind: functionArgTypeLiteral, literal: lits[0]} - } - } else if a.logicalExpr != nil { - res := a.logicalExpr.Matches(idx, node, root) - return resolvedArgument{kind: functionArgTypeLiteral, literal: &literal{bool: &res}} - } else if a.functionExpr != nil { - res := a.functionExpr.Evaluate(idx, node, root) - return resolvedArgument{kind: functionArgTypeLiteral, literal: &res} - } else if a.contextVar != nil { - // Evaluate context variable and return as literal - res := a.contextVar.Evaluate(idx, node, root) - return resolvedArgument{kind: functionArgTypeLiteral, literal: &res} - } - return resolvedArgument{} + if a.literal != nil { + return resolvedArgument{kind: functionArgTypeLiteral, literal: a.literal} + } else if a.filterQuery != nil { + result := a.filterQuery.Query(idx, node, root) + lits := make([]*literal, len(result)) + for i, node := range result { + lit := nodeToLiteral(node) + lits[i] = &lit + } + if len(result) != 1 { + return resolvedArgument{kind: functionArgTypeNodes, nodes: lits} + } else { + return resolvedArgument{kind: functionArgTypeLiteral, literal: lits[0]} + } + } else if a.logicalExpr != nil { + res := a.logicalExpr.Matches(idx, node, root) + return resolvedArgument{kind: functionArgTypeLiteral, literal: &literal{bool: &res}} + } else if a.functionExpr != nil { + res := a.functionExpr.Evaluate(idx, node, root) + return resolvedArgument{kind: functionArgTypeLiteral, literal: &res} + } else if a.contextVar != nil { + // Evaluate context variable and return as literal + res := a.contextVar.Evaluate(idx, node, root) + return resolvedArgument{kind: functionArgTypeLiteral, literal: &res} + } + 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()) - } else if a.contextVar != nil { - builder.WriteString(a.contextVar.ToString()) - } - return builder.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()) + } else if a.contextVar != nil { + builder.WriteString(a.contextVar.ToString()) + } + return builder.String() } //function-name = function-name-first *function-name-char @@ -158,74 +166,74 @@ func (a functionArgument) ToString() string { type functionType int const ( - functionTypeLength functionType = iota - functionTypeCount - functionTypeMatch - functionTypeSearch - functionTypeValue - // JSONPath Plus type selector functions - functionTypeIsNull - functionTypeIsBoolean - functionTypeIsNumber - functionTypeIsString - functionTypeIsArray - functionTypeIsObject - functionTypeIsInteger + functionTypeLength functionType = iota + functionTypeCount + functionTypeMatch + functionTypeSearch + functionTypeValue + // JSONPath Plus type selector functions + functionTypeIsNull + functionTypeIsBoolean + functionTypeIsNumber + functionTypeIsString + functionTypeIsArray + functionTypeIsObject + functionTypeIsInteger ) var functionTypeMap = map[string]functionType{ - "length": functionTypeLength, - "count": functionTypeCount, - "match": functionTypeMatch, - "search": functionTypeSearch, - "value": functionTypeValue, + "length": functionTypeLength, + "count": functionTypeCount, + "match": functionTypeMatch, + "search": functionTypeSearch, + "value": functionTypeValue, } // typeSelectorFunctionMap maps JSONPath Plus type selector function names to their types. // These are extensions enabled when JSONPath Plus mode is active. var typeSelectorFunctionMap = map[string]functionType{ - "isNull": functionTypeIsNull, - "isBoolean": functionTypeIsBoolean, - "isNumber": functionTypeIsNumber, - "isString": functionTypeIsString, - "isArray": functionTypeIsArray, - "isObject": functionTypeIsObject, - "isInteger": functionTypeIsInteger, + "isNull": functionTypeIsNull, + "isBoolean": functionTypeIsBoolean, + "isNumber": functionTypeIsNumber, + "isString": functionTypeIsString, + "isArray": functionTypeIsArray, + "isObject": functionTypeIsObject, + "isInteger": functionTypeIsInteger, } func (f functionType) String() string { - for k, v := range functionTypeMap { - if v == f { - return k - } - } - for k, v := range typeSelectorFunctionMap { - if v == f { - return k - } - } - return "unknown" + for k, v := range functionTypeMap { + if v == f { + return k + } + } + for k, v := range typeSelectorFunctionMap { + if v == f { + return k + } + } + return "unknown" } // functionExpr function-expr = function-name "(" S [function-argument // *(S "," S function-argument)] S ")" type functionExpr struct { - funcType functionType - args []*functionArgument + funcType functionType + 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() + 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] @@ -233,22 +241,22 @@ func (e functionExpr) ToString() string { // (filter-query / ; existence/non-existence // function-expr) ; LogicalType or NodesType type testExpr struct { - not bool - filterQuery *filterQuery - functionExpr *functionExpr + not bool + filterQuery *filterQuery + 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() + 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 = @@ -257,166 +265,166 @@ func (e testExpr) ToString() string { // comparison-expr / // test-expr type basicExpr struct { - parenExpr *parenExpr - comparisonExpr *comparisonExpr - testExpr *testExpr + parenExpr *parenExpr + comparisonExpr *comparisonExpr + 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 "" + 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 type literal struct { - // we generally decompose these into their component parts for easier evaluation - integer *int - float64 *float64 - string *string - bool *bool - null *bool - node *yaml.Node + // we generally decompose these into their component parts for easier evaluation + integer *int + float64 *float64 + string *string + bool *bool + null *bool + 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 "" + 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() + 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() + 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 + 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 "" + if q.relQuery != nil { + return q.relQuery.ToString() + } else if q.absQuery != nil { + return q.absQuery.ToString() + } + return "" } // contextVarKind represents the type of context variable type contextVarKind int const ( - contextVarProperty contextVarKind = iota // @property - current property name - contextVarRoot // @root - root node access - contextVarParent // @parent - parent node - contextVarParentProperty // @parentProperty - parent's property name - contextVarPath // @path - absolute path to current node - contextVarIndex // @index - current array index + contextVarProperty contextVarKind = iota // @property - current property name + contextVarRoot // @root - root node access + contextVarParent // @parent - parent node + contextVarParentProperty // @parentProperty - parent's property name + contextVarPath // @path - absolute path to current node + contextVarIndex // @index - current array index ) // contextVariable represents a JSONPath Plus context variable in filter expressions. // These provide access to metadata about the current node being evaluated. type contextVariable struct { - kind contextVarKind + kind contextVarKind } func (cv contextVariable) ToString() string { - switch cv.kind { - case contextVarProperty: - return "@property" - case contextVarRoot: - return "@root" - case contextVarParent: - return "@parent" - case contextVarParentProperty: - return "@parentProperty" - case contextVarPath: - return "@path" - case contextVarIndex: - return "@index" - default: - return "@unknown" - } + switch cv.kind { + case contextVarProperty: + return "@property" + case contextVarRoot: + return "@root" + case contextVarParent: + return "@parent" + case contextVarParentProperty: + return "@parentProperty" + case contextVarPath: + return "@path" + case contextVarIndex: + return "@index" + default: + return "@unknown" + } } // comparable @@ -426,23 +434,23 @@ func (cv contextVariable) ToString() string { // function-expr ; ValueType // context-variable ; JSONPath Plus extension type comparable struct { - literal *literal - singularQuery *singularQuery - functionExpr *functionExpr - contextVar *contextVariable // JSONPath Plus extension + literal *literal + singularQuery *singularQuery + functionExpr *functionExpr + contextVar *contextVariable // JSONPath Plus extension } 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() - } else if c.contextVar != nil { - return c.contextVar.ToString() - } - return "" + 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() + } else if c.contextVar != nil { + return c.contextVar.ToString() + } + return "" } // comparisonExpr represents a comparison expression @@ -457,73 +465,73 @@ func (c comparable) ToString() string { // "<=" / ">=" / // "<" / ">" type comparisonExpr struct { - left *comparable - op comparisonOperator - right *comparable + left *comparable + op comparisonOperator + 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() + 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 + query string } // parenExpr represents a parenthesized expression // // paren-expr = [logical-not-op S] "(" S logical-expr S ")" type parenExpr struct { - // "!" - not bool - // "(" logicalOrExpr ")" - expr *logicalOrExpr + // "!" + not bool + // "(" logicalOrExpr ")" + 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() + 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 const ( - equalTo comparisonOperator = iota - notEqualTo - lessThan - lessThanEqualTo - greaterThan - greaterThanEqualTo + equalTo comparisonOperator = iota + notEqualTo + lessThan + lessThanEqualTo + 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 "" + 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 851256e..aaacda6 100644 --- a/pkg/jsonpath/jsonpath.go +++ b/pkg/jsonpath/jsonpath.go @@ -7,15 +7,26 @@ import ( "go.yaml.in/yaml/v4" ) +// NewPath compiles input into a reusable JSONPath using the supplied options. func NewPath(input string, opts ...config.Option) (*JSONPath, error) { - tokenizer := token.NewTokenizer(input, opts...) + cfg := config.New(opts...) + if err := config.Validate(cfg); err != nil { + return nil, err + } + tokenizer := token.NewTokenizerWithConfig(input, cfg) tokens := tokenizer.Tokenize() for i := 0; i < len(tokens); i++ { if tokens[i].Token == token.ILLEGAL { - return nil, fmt.Errorf("%s", tokenizer.ErrorString(&tokens[i], "unexpected token")) + message := "unexpected token" + if config.SpectralCompatibilityEnabled(cfg) && tokens[i].Literal != "" { + message = "Spectral compatibility mode: " + tokens[i].Literal + } else if !cfg.JSONPathPlusEnabled() && tokens[i].Literal != "" { + message = tokens[i].Literal + } + return nil, fmt.Errorf("%s", tokenizer.ErrorString(&tokens[i], message)) } } - parser := newParserPrivate(tokenizer, tokens, opts...) + parser := newParserPrivateWithConfig(tokenizer, tokens, cfg) err := parser.parse() if err != nil { return nil, err @@ -23,10 +34,12 @@ func NewPath(input string, opts ...config.Option) (*JSONPath, error) { return parser, nil } +// Query evaluates the compiled path against root. func (p *JSONPath) Query(root *yaml.Node) []*yaml.Node { return p.ast.Query(root, root) } +// String returns a stable normalized representation of the compiled path. func (p *JSONPath) String() string { if p == nil { return "" @@ -34,6 +47,7 @@ func (p *JSONPath) String() string { return p.ast.ToString() } +// IsSingular reports whether the path can select at most one node. func (p *JSONPath) IsSingular() bool { if p == nil { return false @@ -41,6 +55,7 @@ func (p *JSONPath) IsSingular() bool { return p.ast.isSingular() } +// SegmentInfo describes a member-name or array-index segment in a singular path. type SegmentInfo struct { Kind SegmentKind Key string @@ -48,13 +63,17 @@ type SegmentInfo struct { HasIndex bool } +// SegmentKind identifies the public kind of a singular path segment. type SegmentKind int const ( + // SegmentKindMemberName identifies a mapping member segment. SegmentKindMemberName SegmentKind = iota + // SegmentKindArrayIndex identifies a sequence index segment. SegmentKindArrayIndex ) +// GetSegmentInfo returns public segment metadata for a singular path. func (p *JSONPath) GetSegmentInfo() ([]SegmentInfo, error) { if p == nil { return nil, fmt.Errorf("nil path") diff --git a/pkg/jsonpath/parser.go b/pkg/jsonpath/parser.go index ec1109a..f321abd 100644 --- a/pkg/jsonpath/parser.go +++ b/pkg/jsonpath/parser.go @@ -1,12 +1,12 @@ package jsonpath import ( - "errors" - "fmt" - "github.com/pb33f/jsonpath/pkg/jsonpath/config" - "github.com/pb33f/jsonpath/pkg/jsonpath/token" - "strconv" - "strings" + "errors" + "fmt" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/jsonpath/pkg/jsonpath/token" + "strconv" + "strings" ) const MaxSafeFloat int64 = 9007199254740991 @@ -14,794 +14,811 @@ const MaxSafeFloat int64 = 9007199254740991 type mode int const ( - modeNormal mode = iota - modeSingular + modeNormal mode = iota + modeSingular ) // contextVarTokenMap maps context variable tokens to their kinds // CONTEXT_ROOT is handled separately as it requires path parsing var contextVarTokenMap = map[token.Token]contextVarKind{ - token.CONTEXT_PROPERTY: contextVarProperty, - token.CONTEXT_PARENT: contextVarParent, - token.CONTEXT_PARENT_PROPERTY: contextVarParentProperty, - token.CONTEXT_PATH: contextVarPath, - token.CONTEXT_INDEX: contextVarIndex, + token.CONTEXT_PROPERTY: contextVarProperty, + token.CONTEXT_PARENT: contextVarParent, + token.CONTEXT_PARENT_PROPERTY: contextVarParentProperty, + token.CONTEXT_PATH: contextVarPath, + token.CONTEXT_INDEX: contextVarIndex, } // JSONPath represents a JSONPath parser. type JSONPath struct { - tokenizer *token.Tokenizer - tokens []token.TokenInfo - ast jsonPathAST - current int - mode []mode - config config.Config - filterDepth int // tracks nesting depth inside filter expressions + tokenizer *token.Tokenizer + tokens []token.TokenInfo + ast jsonPathAST + current int + mode []mode + config config.Config + filterDepth int // tracks nesting depth inside filter expressions } // newParserPrivate creates a new JSONPath with the given tokens. func newParserPrivate(tokenizer *token.Tokenizer, tokens []token.TokenInfo, opts ...config.Option) *JSONPath { - cfg := config.New(opts...) - return &JSONPath{tokenizer, tokens, jsonPathAST{lazyContextTracking: cfg.LazyContextTrackingEnabled(), jsonPathPlus: cfg.JSONPathPlusEnabled()}, 0, []mode{modeNormal}, cfg, 0} + return newParserPrivateWithConfig(tokenizer, tokens, config.New(opts...)) +} + +func newParserPrivateWithConfig(tokenizer *token.Tokenizer, tokens []token.TokenInfo, cfg config.Config) *JSONPath { + return &JSONPath{tokenizer, tokens, jsonPathAST{lazyContextTracking: cfg.LazyContextTrackingEnabled(), jsonPathPlus: cfg.JSONPathPlusEnabled()}, 0, []mode{modeNormal}, cfg, 0} } // parse parses the JSONPath tokens and returns the root node of the AST. // // jsonpath-query = root-identifier segments func (p *JSONPath) parse() error { - if len(p.tokens) == 0 { - return fmt.Errorf("empty JSONPath expression") - } - - if p.tokens[p.current].Token != token.ROOT { - return p.parseFailure(&p.tokens[p.current], "expected '$'") - } - p.current++ - - for p.current < len(p.tokens) { - segment, err := p.parseSegment() - if err != nil { - return err - } - p.ast.segments = append(p.ast.segments, segment) - } - return nil + if len(p.tokens) == 0 { + return fmt.Errorf("empty JSONPath expression") + } + + if p.tokens[p.current].Token != token.ROOT { + return p.parseFailure(&p.tokens[p.current], "expected '$'") + } + p.current++ + + for p.current < len(p.tokens) { + segment, err := p.parseSegment() + if err != nil { + return err + } + p.ast.segments = append(p.ast.segments, segment) + } + return nil } func (p *JSONPath) parseFailure(target *token.TokenInfo, msg string) error { - return errors.New(p.tokenizer.ErrorString(target, msg)) + return errors.New(p.tokenizer.ErrorString(target, msg)) } // peek returns true if the upcoming token matches the given token type. func (p *JSONPath) peek(token token.Token) bool { - return p.current+1 < len(p.tokens) && p.tokens[p.current+1].Token == token + return p.current+1 < len(p.tokens) && p.tokens[p.current+1].Token == token } // peek returns true if the upcoming token matches the given token type. func (p *JSONPath) next(token token.Token) bool { - return p.current < len(p.tokens) && p.tokens[p.current].Token == token + return p.current < len(p.tokens) && p.tokens[p.current].Token == token } // expect consumes the current token if it matches the given token type. func (p *JSONPath) expect(token token.Token) bool { - if p.peek(token) { - p.current++ - return true - } - return false + if p.peek(token) { + p.current++ + return true + } + return false } // isComparisonOperator returns true if the given token is a comparison operator. func (p *JSONPath) isComparisonOperator(tok token.Token) bool { - return tok == token.EQ || tok == token.NE || tok == token.GT || tok == token.GE || tok == token.LT || tok == token.LE + return tok == token.EQ || tok == token.NE || tok == token.GT || tok == token.GE || tok == token.LT || tok == token.LE } func (p *JSONPath) parseSegment() (*segment, error) { - currentToken := p.tokens[p.current] - if currentToken.Token == token.RECURSIVE { - if p.mode[len(p.mode)-1] == modeSingular { - return nil, p.parseFailure(&p.tokens[p.current], "unexpected recursive descent in singular query") - } - p.current++ - child, err := p.parseInnerSegment() - if err != nil { - return nil, err - } - return &segment{kind: segmentKindDescendant, descendant: child}, nil - } else if currentToken.Token == token.CHILD || currentToken.Token == token.BRACKET_LEFT { - if currentToken.Token == token.CHILD { - p.current++ - } - child, err := p.parseInnerSegment() - if err != nil { - return nil, err - } - return &segment{kind: segmentKindChild, child: child}, nil - } else if p.config.PropertyNameEnabled() && currentToken.Token == token.PROPERTY_NAME { - p.current++ - return &segment{kind: segmentKindProperyName}, nil - } else if p.config.JSONPathPlusEnabled() && currentToken.Token == token.PARENT_SELECTOR { - // JSONPath Plus parent selector: ^ returns parent of current node - p.current++ - return &segment{kind: segmentKindParent}, nil - } - return nil, p.parseFailure(¤tToken, "unexpected token when parsing segment") + currentToken := p.tokens[p.current] + if currentToken.Token == token.RECURSIVE { + if p.mode[len(p.mode)-1] == modeSingular { + return nil, p.parseFailure(&p.tokens[p.current], "unexpected recursive descent in singular query") + } + p.current++ + child, err := p.parseInnerSegment() + if err != nil { + return nil, err + } + return &segment{kind: segmentKindDescendant, descendant: child}, nil + } else if currentToken.Token == token.CHILD || currentToken.Token == token.BRACKET_LEFT { + if currentToken.Token == token.CHILD { + if config.SpectralCompatibilityEnabled(p.config) && p.current+1 < len(p.tokens) && p.tokens[p.current+1].Token == token.PROPERTY_NAME { + p.current += 2 + return &segment{kind: segmentKindRecursivePropertyName}, nil + } + p.current++ + } + child, err := p.parseInnerSegment() + if err != nil { + return nil, err + } + return &segment{kind: segmentKindChild, child: child}, nil + } else if p.config.PropertyNameEnabled() && currentToken.Token == token.PROPERTY_NAME { + p.current++ + return &segment{kind: segmentKindProperyName}, nil + } else if p.config.JSONPathPlusEnabled() && currentToken.Token == token.PARENT_SELECTOR { + // JSONPath Plus parent selector: ^ returns parent of current node + p.current++ + return &segment{kind: segmentKindParent}, nil + } + return nil, p.parseFailure(¤tToken, "unexpected token when parsing segment") } func (p *JSONPath) parseInnerSegment() (retValue *innerSegment, err error) { - defer func() { - if p.mode[len(p.mode)-1] == modeSingular && retValue != nil { - if len(retValue.selectors) > 1 { - retValue = nil - err = p.parseFailure(&p.tokens[p.current], "unexpected multiple selectors in singular query") - return - } else if retValue.kind == segmentDotWildcard { - retValue = nil - err = p.parseFailure(&p.tokens[p.current], "unexpected wildcard in singular query") - return - } - } - }() - // .* - // .STRING - // [] - if p.current >= len(p.tokens) { - return nil, p.parseFailure(nil, "unexpected end of input") - } - firstToken := p.tokens[p.current] - if firstToken.Token == token.WILDCARD { - p.current += 1 - return &innerSegment{segmentDotWildcard, "", nil}, nil - } else if firstToken.Token == token.STRING { - dotName := p.tokens[p.current].Literal - p.current += 1 - return &innerSegment{segmentDotMemberName, dotName, nil}, nil - } else if firstToken.Token == token.INTEGER && p.config.JSONPathPlusEnabled() && p.current >= 3 { - // JSONPath Plus: treat .201 as a member name (common for HTTP status codes in OpenAPI). - // Only when we're past the root (p.current >= 3 means at least $, ., and something before this). - dotName := p.tokens[p.current].Literal - p.current += 1 - return &innerSegment{segmentDotMemberName, dotName, nil}, nil - } else if firstToken.Token == token.BRACKET_LEFT { - prior := p.current - p.current += 1 - selectors := []*selector{} - for p.current < len(p.tokens) { - innerSelector, err := p.parseSelector() - if err != nil { - p.current = prior - return nil, err - } - selectors = append(selectors, innerSelector) - if len(p.tokens) <= p.current { - return nil, p.parseFailure(&p.tokens[p.current-1], "unexpected end of input") - } - if p.tokens[p.current].Token == token.BRACKET_RIGHT { - break - } else if p.tokens[p.current].Token == token.COMMA { - p.current++ - } - } - if p.tokens[p.current].Token != token.BRACKET_RIGHT { - prior = p.current - return nil, p.parseFailure(&p.tokens[p.current], "expected ']'") - } - p.current += 1 - return &innerSegment{kind: segmentLongHand, dotName: "", selectors: selectors}, nil - } - return nil, p.parseFailure(&firstToken, "unexpected token when parsing inner segment") + defer func() { + if p.mode[len(p.mode)-1] == modeSingular && retValue != nil { + if len(retValue.selectors) > 1 { + retValue = nil + err = p.parseFailure(&p.tokens[p.current], "unexpected multiple selectors in singular query") + return + } else if retValue.kind == segmentDotWildcard { + retValue = nil + err = p.parseFailure(&p.tokens[p.current], "unexpected wildcard in singular query") + return + } + } + }() + // .* + // .STRING + // [] + if p.current >= len(p.tokens) { + return nil, p.parseFailure(nil, "unexpected end of input") + } + firstToken := p.tokens[p.current] + if firstToken.Token == token.WILDCARD { + p.current += 1 + return &innerSegment{segmentDotWildcard, "", nil}, nil + } else if firstToken.Token == token.STRING { + dotName := p.tokens[p.current].Literal + p.current += 1 + return &innerSegment{segmentDotMemberName, dotName, nil}, nil + } else if firstToken.Token == token.INTEGER && p.config.JSONPathPlusEnabled() && p.current >= 3 { + // JSONPath Plus: treat .201 as a member name (common for HTTP status codes in OpenAPI). + // Only when we're past the root (p.current >= 3 means at least $, ., and something before this). + dotName := p.tokens[p.current].Literal + p.current += 1 + return &innerSegment{segmentDotMemberName, dotName, nil}, nil + } else if firstToken.Token == token.BRACKET_LEFT { + prior := p.current + p.current += 1 + selectors := []*selector{} + for p.current < len(p.tokens) { + innerSelector, err := p.parseSelector() + if err != nil { + p.current = prior + return nil, err + } + selectors = append(selectors, innerSelector) + if len(p.tokens) <= p.current { + return nil, p.parseFailure(&p.tokens[p.current-1], "unexpected end of input") + } + if p.tokens[p.current].Token == token.BRACKET_RIGHT { + break + } else if p.tokens[p.current].Token == token.COMMA { + p.current++ + } + } + if p.tokens[p.current].Token != token.BRACKET_RIGHT { + prior = p.current + return nil, p.parseFailure(&p.tokens[p.current], "expected ']'") + } + p.current += 1 + return &innerSegment{kind: segmentLongHand, dotName: "", selectors: selectors}, nil + } + return nil, p.parseFailure(&firstToken, "unexpected token when parsing inner segment") } func (p *JSONPath) parseSelector() (retSelector *selector, err error) { - //selector = name-selector / - // wildcard-selector / - // slice-selector / - // index-selector / - // filter-selector - initial := p.current - defer func() { - if p.mode[len(p.mode)-1] == modeSingular && retSelector != nil { - if retSelector.kind == selectorSubKindWildcard { - err = p.parseFailure(&p.tokens[initial], "unexpected wildcard in singular query") - retSelector = nil - } else if retSelector.kind == selectorSubKindArraySlice { - err = p.parseFailure(&p.tokens[initial], "unexpected slice in singular query") - retSelector = nil - } - } - }() - - // name-selector = string-literal - if p.tokens[p.current].Token == token.STRING_LITERAL { - name := p.tokens[p.current].Literal - p.current++ - 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 - } else if p.tokens[p.current].Token == token.INTEGER { - // peek ahead to see if it's a slice - if p.peek(token.ARRAY_SLICE) { - slice, err := p.parseSliceSelector() - if err != nil { - return nil, err - } - 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) { - return nil, p.parseFailure(&p.tokens[p.current], "expected ']' or ','") - } - // else it's an index - lit := p.tokens[p.current].Literal - // make sure it's not -0 - if lit == "-0" { - return nil, p.parseFailure(&p.tokens[p.current], "-0 unexpected") - } - // make sure lit is an integer - i, err := strconv.ParseInt(lit, 10, 64) - if err != nil { - return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") - } - err = p.checkSafeInteger(i, lit) - if err != nil { - return nil, err - } - - p.current++ - - return &selector{kind: selectorSubKindArrayIndex, index: i, jsonPathPlus: p.config.JSONPathPlusEnabled() && p.filterDepth == 0}, 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 - } else if p.tokens[p.current].Token == token.FILTER { - return p.parseFilterSelector() - } - - return nil, p.parseFailure(&p.tokens[p.current], "unexpected token when parsing selector") + //selector = name-selector / + // wildcard-selector / + // slice-selector / + // index-selector / + // filter-selector + initial := p.current + defer func() { + if p.mode[len(p.mode)-1] == modeSingular && retSelector != nil { + if retSelector.kind == selectorSubKindWildcard { + err = p.parseFailure(&p.tokens[initial], "unexpected wildcard in singular query") + retSelector = nil + } else if retSelector.kind == selectorSubKindArraySlice { + err = p.parseFailure(&p.tokens[initial], "unexpected slice in singular query") + retSelector = nil + } + } + }() + + // name-selector = string-literal + if p.tokens[p.current].Token == token.STRING_LITERAL { + name := p.tokens[p.current].Literal + p.current++ + 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 + } else if p.tokens[p.current].Token == token.INTEGER { + // peek ahead to see if it's a slice + if p.peek(token.ARRAY_SLICE) { + slice, err := p.parseSliceSelector() + if err != nil { + return nil, err + } + 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) { + return nil, p.parseFailure(&p.tokens[p.current], "expected ']' or ','") + } + // else it's an index + lit := p.tokens[p.current].Literal + // make sure it's not -0 + if lit == "-0" { + return nil, p.parseFailure(&p.tokens[p.current], "-0 unexpected") + } + // make sure lit is an integer + i, err := strconv.ParseInt(lit, 10, 64) + if err != nil { + return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") + } + err = p.checkSafeInteger(i, lit) + if err != nil { + return nil, err + } + + p.current++ + + return &selector{ + kind: selectorSubKindArrayIndex, + index: i, + jsonPathPlus: p.config.JSONPathPlusEnabled() && p.filterDepth == 0, + spectral: config.SpectralCompatibilityEnabled(p.config), + }, 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 + } else if p.tokens[p.current].Token == token.FILTER { + return p.parseFilterSelector() + } + + return nil, p.parseFailure(&p.tokens[p.current], "unexpected token when parsing selector") } func (p *JSONPath) parseSliceSelector() (*slice, error) { - // slice-selector = [start S] ":" S [end S] [":" [S step]] - var start, end, step *int64 - - // parse the start index - if p.tokens[p.current].Token == token.INTEGER { - literal := p.tokens[p.current].Literal - i, err := strconv.ParseInt(literal, 10, 64) - if err != nil { - return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") - } - err = p.checkSafeInteger(i, literal) - if err != nil { - return nil, err - } - - start = &i - p.current += 1 - } - - // Expect a colon - if p.tokens[p.current].Token != token.ARRAY_SLICE { - return nil, p.parseFailure(&p.tokens[p.current], "expected ':'") - } - p.current++ - - // parse the end index - if p.tokens[p.current].Token == token.INTEGER { - literal := p.tokens[p.current].Literal - i, err := strconv.ParseInt(literal, 10, 64) - if err != nil { - return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") - } - err = p.checkSafeInteger(i, literal) - if err != nil { - return nil, err - } - - end = &i - p.current++ - } - - // Check for an optional second colon and step value - if p.tokens[p.current].Token == token.ARRAY_SLICE { - p.current++ - if p.tokens[p.current].Token == token.INTEGER { - literal := p.tokens[p.current].Literal - i, err := strconv.ParseInt(literal, 10, 64) - if err != nil { - return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") - } - err = p.checkSafeInteger(i, literal) - if err != nil { - return nil, err - } - - step = &i - p.current++ - } - } - if p.tokens[p.current].Token != token.BRACKET_RIGHT { - return nil, p.parseFailure(&p.tokens[p.current], "expected ']'") - } - - return &slice{start: start, end: end, step: step}, nil + // slice-selector = [start S] ":" S [end S] [":" [S step]] + var start, end, step *int64 + + // parse the start index + if p.tokens[p.current].Token == token.INTEGER { + literal := p.tokens[p.current].Literal + i, err := strconv.ParseInt(literal, 10, 64) + if err != nil { + return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") + } + err = p.checkSafeInteger(i, literal) + if err != nil { + return nil, err + } + + start = &i + p.current += 1 + } + + // Expect a colon + if p.tokens[p.current].Token != token.ARRAY_SLICE { + return nil, p.parseFailure(&p.tokens[p.current], "expected ':'") + } + p.current++ + + // parse the end index + if p.tokens[p.current].Token == token.INTEGER { + literal := p.tokens[p.current].Literal + i, err := strconv.ParseInt(literal, 10, 64) + if err != nil { + return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") + } + err = p.checkSafeInteger(i, literal) + if err != nil { + return nil, err + } + + end = &i + p.current++ + } + + // Check for an optional second colon and step value + if p.tokens[p.current].Token == token.ARRAY_SLICE { + p.current++ + if p.tokens[p.current].Token == token.INTEGER { + literal := p.tokens[p.current].Literal + i, err := strconv.ParseInt(literal, 10, 64) + if err != nil { + return nil, p.parseFailure(&p.tokens[p.current], "expected an integer") + } + err = p.checkSafeInteger(i, literal) + if err != nil { + return nil, err + } + + step = &i + p.current++ + } + } + if p.tokens[p.current].Token != token.BRACKET_RIGHT { + return nil, p.parseFailure(&p.tokens[p.current], "expected ']'") + } + + return &slice{start: start, end: end, step: step}, nil } func (p *JSONPath) checkSafeInteger(i int64, literal string) error { - if i > MaxSafeFloat || i < -MaxSafeFloat { - return p.parseFailure(&p.tokens[p.current], "outside bounds for safe integers") - } - if literal == "-0" { - return p.parseFailure(&p.tokens[p.current], "-0 unexpected") - } - return nil + if i > MaxSafeFloat || i < -MaxSafeFloat { + return p.parseFailure(&p.tokens[p.current], "outside bounds for safe integers") + } + if literal == "-0" { + return p.parseFailure(&p.tokens[p.current], "-0 unexpected") + } + return nil } func (p *JSONPath) parseFilterSelector() (*selector, error) { - if p.tokens[p.current].Token != token.FILTER { - return nil, p.parseFailure(&p.tokens[p.current], "expected '?'") - } - p.current++ - p.filterDepth++ - defer func() { p.filterDepth-- }() - - expr, err := p.parseLogicalOrExpr() - if err != nil { - return nil, err - } - - return &selector{kind: selectorSubKindFilter, filter: &filterSelector{expr}}, nil + if p.tokens[p.current].Token != token.FILTER { + return nil, p.parseFailure(&p.tokens[p.current], "expected '?'") + } + p.current++ + p.filterDepth++ + defer func() { p.filterDepth-- }() + if config.SpectralCompatibilityEnabled(p.config) { + expr, err := p.parseSpectralExpression() + if err != nil { + return nil, err + } + return &selector{kind: selectorSubKindFilter, filter: filterSelector{spectralExpression: expr}}, nil + } + + expr, err := p.parseLogicalOrExpr() + if err != nil { + return nil, err + } + + return &selector{kind: selectorSubKindFilter, filter: filterSelector{expression: expr}}, nil } func (p *JSONPath) parseLogicalOrExpr() (*logicalOrExpr, error) { - var expr logicalOrExpr - - for { - andExpr, err := p.parseLogicalAndExpr() - if err != nil { - return nil, err - } - expr.expressions = append(expr.expressions, andExpr) - - if !p.next(token.OR) { - break - } - p.current++ - } - - return &expr, nil + var expr logicalOrExpr + + for { + andExpr, err := p.parseLogicalAndExpr() + if err != nil { + return nil, err + } + expr.expressions = append(expr.expressions, andExpr) + + if !p.next(token.OR) { + break + } + p.current++ + } + + return &expr, nil } func (p *JSONPath) parseLogicalAndExpr() (*logicalAndExpr, error) { - var expr logicalAndExpr - - for { - basicExpr, err := p.parseBasicExpr() - if err != nil { - return nil, err - } - expr.expressions = append(expr.expressions, basicExpr) - - if !p.next(token.AND) { - break - } - p.current++ - } - - return &expr, nil + var expr logicalAndExpr + + for { + basicExpr, err := p.parseBasicExpr() + if err != nil { + return nil, err + } + expr.expressions = append(expr.expressions, basicExpr) + + if !p.next(token.AND) { + break + } + p.current++ + } + + return &expr, nil } func (p *JSONPath) parseBasicExpr() (*basicExpr, error) { - //basic-expr = paren-expr / - // comparison-expr / - // test-expr - - switch p.tokens[p.current].Token { - case token.NOT: - p.current++ - expr, err := p.parseLogicalOrExpr() - 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++ - expr, err := p.parseLogicalOrExpr() - if err != nil { - return nil, err - } - if p.tokens[p.current].Token != token.PAREN_RIGHT { - return nil, p.parseFailure(&p.tokens[p.current], "expected ')'") - } - p.current++ - return &basicExpr{parenExpr: &parenExpr{not: false, expr: expr}}, nil - } - prevCurrent := p.current - comparisonExpr, comparisonErr := p.parseComparisonExpr() - if comparisonErr == nil { - return &basicExpr{comparisonExpr: comparisonExpr}, nil - } - p.current = prevCurrent - testExpr, testErr := p.parseTestExpr() - if testErr == nil { - return &basicExpr{testExpr: testExpr}, nil - } - p.current = prevCurrent - return nil, p.parseFailure(&p.tokens[p.current], fmt.Sprintf("could not parse query: expected either testExpr [err: %s] or comparisonExpr: [err: %s]", testErr.Error(), comparisonErr.Error())) + //basic-expr = paren-expr / + // comparison-expr / + // test-expr + + switch p.tokens[p.current].Token { + case token.NOT: + p.current++ + expr, err := p.parseLogicalOrExpr() + 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++ + expr, err := p.parseLogicalOrExpr() + if err != nil { + return nil, err + } + if p.tokens[p.current].Token != token.PAREN_RIGHT { + return nil, p.parseFailure(&p.tokens[p.current], "expected ')'") + } + p.current++ + return &basicExpr{parenExpr: &parenExpr{not: false, expr: expr}}, nil + } + prevCurrent := p.current + comparisonExpr, comparisonErr := p.parseComparisonExpr() + if comparisonErr == nil { + return &basicExpr{comparisonExpr: comparisonExpr}, nil + } + p.current = prevCurrent + testExpr, testErr := p.parseTestExpr() + if testErr == nil { + return &basicExpr{testExpr: testExpr}, nil + } + p.current = prevCurrent + return nil, p.parseFailure(&p.tokens[p.current], fmt.Sprintf("could not parse query: expected either testExpr [err: %s] or comparisonExpr: [err: %s]", testErr.Error(), comparisonErr.Error())) } func (p *JSONPath) parseComparisonExpr() (*comparisonExpr, error) { - left, err := p.parseComparable() - if err != nil { - return nil, err - } - - if !p.isComparisonOperator(p.tokens[p.current].Token) { - return nil, p.parseFailure(&p.tokens[p.current], "expected comparison operator") - } - operator := p.tokens[p.current].Token - var op comparisonOperator - switch operator { - case token.EQ: - op = equalTo - case token.NE: - op = notEqualTo - case token.LT: - op = lessThan - case token.LE: - op = lessThanEqualTo - case token.GT: - op = greaterThan - case token.GE: - op = greaterThanEqualTo - default: - return nil, p.parseFailure(&p.tokens[p.current], "expected comparison operator") - } - p.current++ - - right, err := p.parseComparable() - if err != nil { - return nil, err - } - - return &comparisonExpr{left: left, op: op, right: right}, nil + left, err := p.parseComparable() + if err != nil { + return nil, err + } + + if !p.isComparisonOperator(p.tokens[p.current].Token) { + return nil, p.parseFailure(&p.tokens[p.current], "expected comparison operator") + } + operator := p.tokens[p.current].Token + var op comparisonOperator + switch operator { + case token.EQ: + op = equalTo + case token.NE: + op = notEqualTo + case token.LT: + op = lessThan + case token.LE: + op = lessThanEqualTo + case token.GT: + op = greaterThan + case token.GE: + op = greaterThanEqualTo + default: + return nil, p.parseFailure(&p.tokens[p.current], "expected comparison operator") + } + p.current++ + + right, err := p.parseComparable() + if err != nil { + return nil, err + } + + return &comparisonExpr{left: left, op: op, right: right}, nil } func (p *JSONPath) parseComparable() (*comparable, error) { - // comparable = literal / - // singular-query / ; singular query value - // function-expr ; ValueType - // context-variable ; JSONPath Plus extension - if literal, err := p.parseLiteral(); err == nil { - return &comparable{literal: literal}, nil - } - if funcExpr, err := p.parseFunctionExpr(); err == nil { - if funcExpr.funcType == functionTypeMatch { - return nil, p.parseFailure(&p.tokens[p.current], "match result cannot be compared") - } else if funcExpr.funcType == functionTypeSearch { - return nil, p.parseFailure(&p.tokens[p.current], "search result cannot be compared") - } - return &comparable{functionExpr: funcExpr}, nil - } - switch p.tokens[p.current].Token { - case token.ROOT: - p.current++ - query, err := p.parseSingleQuery() - if err != nil { - return nil, err - } - return &comparable{singularQuery: &singularQuery{absQuery: &absQuery{segments: query.segments}}}, nil - case token.CURRENT: - p.current++ - query, err := p.parseSingleQuery() - if err != nil { - return nil, err - } - return &comparable{singularQuery: &singularQuery{relQuery: &relQuery{segments: query.segments}}}, nil - - case token.CONTEXT_ROOT: - // @root followed by a path - parse as a query starting from root - p.current++ - query, err := p.parseSingleQuery() - if err != nil { - return nil, err - } - return &comparable{singularQuery: &singularQuery{absQuery: &absQuery{segments: query.segments}}}, nil - - default: - // Check for JSONPath Plus context variables - if varKind, ok := contextVarTokenMap[p.tokens[p.current].Token]; ok { - p.current++ - return &comparable{contextVar: &contextVariable{kind: varKind}}, nil - } - return nil, p.parseFailure(&p.tokens[p.current], "expected literal or query") - } + // comparable = literal / + // singular-query / ; singular query value + // function-expr ; ValueType + // context-variable ; JSONPath Plus extension + if literal, err := p.parseLiteral(); err == nil { + return &comparable{literal: literal}, nil + } + if funcExpr, err := p.parseFunctionExpr(); err == nil { + if funcExpr.funcType == functionTypeMatch { + return nil, p.parseFailure(&p.tokens[p.current], "match result cannot be compared") + } else if funcExpr.funcType == functionTypeSearch { + return nil, p.parseFailure(&p.tokens[p.current], "search result cannot be compared") + } + return &comparable{functionExpr: funcExpr}, nil + } + switch p.tokens[p.current].Token { + case token.ROOT: + p.current++ + query, err := p.parseSingleQuery() + if err != nil { + return nil, err + } + return &comparable{singularQuery: &singularQuery{absQuery: &absQuery{segments: query.segments}}}, nil + case token.CURRENT: + p.current++ + query, err := p.parseSingleQuery() + if err != nil { + return nil, err + } + return &comparable{singularQuery: &singularQuery{relQuery: &relQuery{segments: query.segments}}}, nil + + case token.CONTEXT_ROOT: + // @root followed by a path - parse as a query starting from root + p.current++ + query, err := p.parseSingleQuery() + if err != nil { + return nil, err + } + return &comparable{singularQuery: &singularQuery{absQuery: &absQuery{segments: query.segments}}}, nil + + default: + // Check for JSONPath Plus context variables + if varKind, ok := contextVarTokenMap[p.tokens[p.current].Token]; ok { + p.current++ + return &comparable{contextVar: &contextVariable{kind: varKind}}, nil + } + return nil, p.parseFailure(&p.tokens[p.current], "expected literal or query") + } } func (p *JSONPath) parseQuery() (*jsonPathAST, error) { - var query jsonPathAST - p.mode = append(p.mode, modeNormal) - - for p.current < len(p.tokens) { - prior := p.current - segment, err := p.parseSegment() - if err != nil { - p.current = prior - break - } - query.segments = append(query.segments, segment) - } - p.mode = p.mode[:len(p.mode)-1] - return &query, nil + var query jsonPathAST + p.mode = append(p.mode, modeNormal) + + for p.current < len(p.tokens) { + prior := p.current + segment, err := p.parseSegment() + if err != nil { + p.current = prior + break + } + query.segments = append(query.segments, segment) + } + p.mode = p.mode[:len(p.mode)-1] + return &query, nil } func (p *JSONPath) parseTestExpr() (*testExpr, error) { - //test-expr = [logical-not-op S] - // (filter-query / ; existence/non-existence - // function-expr) ; LogicalType or NodesType - //filter-query = rel-query / jsonpath-query - //rel-query = current-node-identifier segments - //current-node-identifier = "@" - not := false - if p.tokens[p.current].Token == token.NOT { - not = true - p.current++ - } - switch p.tokens[p.current].Token { - case token.CURRENT: - p.current++ - query, err := p.parseQuery() - if err != nil { - return nil, err - } - return &testExpr{filterQuery: &filterQuery{relQuery: &relQuery{segments: query.segments}}, not: not}, nil - case token.ROOT: - p.current++ - query, err := p.parseQuery() - if err != nil { - return nil, err - } - return &testExpr{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{ - segments: query.segments, - lazyContextTracking: p.config.LazyContextTrackingEnabled(), - }}, not: not}, nil - default: - funcExpr, err := p.parseFunctionExpr() - if err != nil { - return nil, err - } - if funcExpr.funcType == functionTypeCount { - return nil, p.parseFailure(&p.tokens[p.current], "count function must be compared") - } - if funcExpr.funcType == functionTypeLength { - return nil, p.parseFailure(&p.tokens[p.current], "length function must be compared") - } - if funcExpr.funcType == functionTypeValue { - return nil, p.parseFailure(&p.tokens[p.current], "length function must be compared") - } - return &testExpr{functionExpr: funcExpr, not: not}, nil - } - - return nil, p.parseFailure(&p.tokens[p.current], "unexpected token when parsing test expression") + //test-expr = [logical-not-op S] + // (filter-query / ; existence/non-existence + // function-expr) ; LogicalType or NodesType + //filter-query = rel-query / jsonpath-query + //rel-query = current-node-identifier segments + //current-node-identifier = "@" + not := false + if p.tokens[p.current].Token == token.NOT { + not = true + p.current++ + } + switch p.tokens[p.current].Token { + case token.CURRENT: + p.current++ + query, err := p.parseQuery() + if err != nil { + return nil, err + } + return &testExpr{filterQuery: &filterQuery{relQuery: &relQuery{segments: query.segments}}, not: not}, nil + case token.ROOT: + p.current++ + query, err := p.parseQuery() + if err != nil { + return nil, err + } + return &testExpr{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{ + segments: query.segments, + lazyContextTracking: p.config.LazyContextTrackingEnabled(), + }}, not: not}, nil + default: + funcExpr, err := p.parseFunctionExpr() + if err != nil { + return nil, err + } + if funcExpr.funcType == functionTypeCount { + return nil, p.parseFailure(&p.tokens[p.current], "count function must be compared") + } + if funcExpr.funcType == functionTypeLength { + return nil, p.parseFailure(&p.tokens[p.current], "length function must be compared") + } + if funcExpr.funcType == functionTypeValue { + return nil, p.parseFailure(&p.tokens[p.current], "length function must be compared") + } + return &testExpr{functionExpr: funcExpr, not: not}, nil + } } func (p *JSONPath) parseFunctionExpr() (*functionExpr, error) { - // RFC 9535: function name must be immediately followed by '(' (no whitespace) - // The tokenizer only emits FUNCTION token when function name is directly followed by '(' - if p.tokens[p.current].Token != token.FUNCTION { - return nil, p.parseFailure(&p.tokens[p.current], "expected function") - } - functionName := p.tokens[p.current].Literal - if p.current+1 >= len(p.tokens) || p.tokens[p.current+1].Token != token.PAREN_LEFT { - return nil, p.parseFailure(&p.tokens[p.current], "expected '(' after function") - } - p.current += 2 - args := []*functionArgument{} - - // Check type selector functions first (JSONPath Plus) - // These take a single argument and return boolean - if funcType, ok := typeSelectorFunctionMap[functionName]; ok { - arg, err := p.parseFunctionArgument(false) - if err != nil { - return nil, err - } - args = append(args, arg) - if p.tokens[p.current].Token != token.PAREN_RIGHT { - return nil, p.parseFailure(&p.tokens[p.current], "expected ')'") - } - p.current++ - return &functionExpr{funcType: funcType, args: args}, nil - } - - switch functionTypeMap[functionName] { - case functionTypeLength: - arg, err := p.parseFunctionArgument(true) - if err != nil { - return nil, err - } - args = append(args, arg) - case functionTypeCount: - arg, err := p.parseFunctionArgument(false) - if err != nil { - return nil, err - } - if arg.literal != nil && arg.literal.node == nil { - return nil, p.parseFailure(&p.tokens[p.current], "count function only supports containers") - } - args = append(args, arg) - case functionTypeValue: - arg, err := p.parseFunctionArgument(false) - if err != nil { - return nil, err - } - args = append(args, arg) - case functionTypeMatch: - fallthrough - case functionTypeSearch: - arg, err := p.parseFunctionArgument(false) - if err != nil { - return nil, err - } - args = append(args, arg) - if p.tokens[p.current].Token != token.COMMA { - return nil, p.parseFailure(&p.tokens[p.current], "expected ','") - } - p.current++ - arg, err = p.parseFunctionArgument(false) - if err != nil { - return nil, err - } - args = append(args, arg) - default: - return nil, p.parseFailure(&p.tokens[p.current], "unknown function: "+functionName) - } - if p.tokens[p.current].Token != token.PAREN_RIGHT { - return nil, p.parseFailure(&p.tokens[p.current], "expected ')'") - } - p.current++ - return &functionExpr{funcType: functionTypeMap[functionName], args: args}, nil + // RFC 9535: function name must be immediately followed by '(' (no whitespace) + // The tokenizer only emits FUNCTION token when function name is directly followed by '(' + if p.tokens[p.current].Token != token.FUNCTION { + return nil, p.parseFailure(&p.tokens[p.current], "expected function") + } + functionName := p.tokens[p.current].Literal + if p.current+1 >= len(p.tokens) || p.tokens[p.current+1].Token != token.PAREN_LEFT { + return nil, p.parseFailure(&p.tokens[p.current], "expected '(' after function") + } + p.current += 2 + args := []*functionArgument{} + + // Check type selector functions first (JSONPath Plus) + // These take a single argument and return boolean + if funcType, ok := typeSelectorFunctionMap[functionName]; ok { + arg, err := p.parseFunctionArgument(false) + if err != nil { + return nil, err + } + args = append(args, arg) + if p.tokens[p.current].Token != token.PAREN_RIGHT { + return nil, p.parseFailure(&p.tokens[p.current], "expected ')'") + } + p.current++ + return &functionExpr{funcType: funcType, args: args}, nil + } + + switch functionTypeMap[functionName] { + case functionTypeLength: + arg, err := p.parseFunctionArgument(true) + if err != nil { + return nil, err + } + args = append(args, arg) + case functionTypeCount: + arg, err := p.parseFunctionArgument(false) + if err != nil { + return nil, err + } + if arg.literal != nil && arg.literal.node == nil { + return nil, p.parseFailure(&p.tokens[p.current], "count function only supports containers") + } + args = append(args, arg) + case functionTypeValue: + arg, err := p.parseFunctionArgument(false) + if err != nil { + return nil, err + } + args = append(args, arg) + case functionTypeMatch: + fallthrough + case functionTypeSearch: + arg, err := p.parseFunctionArgument(false) + if err != nil { + return nil, err + } + args = append(args, arg) + if p.tokens[p.current].Token != token.COMMA { + return nil, p.parseFailure(&p.tokens[p.current], "expected ','") + } + p.current++ + arg, err = p.parseFunctionArgument(false) + if err != nil { + return nil, err + } + args = append(args, arg) + default: + return nil, p.parseFailure(&p.tokens[p.current], "unknown function: "+functionName) + } + if p.tokens[p.current].Token != token.PAREN_RIGHT { + return nil, p.parseFailure(&p.tokens[p.current], "expected ')'") + } + p.current++ + return &functionExpr{funcType: functionTypeMap[functionName], args: args}, nil } func (p *JSONPath) parseSingleQuery() (*jsonPathAST, error) { - var query jsonPathAST - for p.current < len(p.tokens) { - try := p.current - p.mode = append(p.mode, modeSingular) - segment, err := p.parseSegment() - if err != nil { - // rollback - p.mode = p.mode[:len(p.mode)-1] - p.current = try - break - } - p.mode = p.mode[:len(p.mode)-1] - query.segments = append(query.segments, segment) - } - //if len(query.segments) == 0 { - // return nil, p.parseFailure(p.tokens[p.current], "expected at least one segment") - //} - return &query, nil + var query jsonPathAST + for p.current < len(p.tokens) { + try := p.current + p.mode = append(p.mode, modeSingular) + segment, err := p.parseSegment() + if err != nil { + // rollback + p.mode = p.mode[:len(p.mode)-1] + p.current = try + break + } + p.mode = p.mode[:len(p.mode)-1] + query.segments = append(query.segments, segment) + } + //if len(query.segments) == 0 { + // return nil, p.parseFailure(p.tokens[p.current], "expected at least one segment") + //} + return &query, nil } func (p *JSONPath) parseFunctionArgument(single bool) (*functionArgument, error) { - //function-argument = literal / - // filter-query / ; (includes singular-query) - // logical-expr / - // function-expr - - if lit, err := p.parseLiteral(); err == nil { - return &functionArgument{literal: lit}, nil - } - switch p.tokens[p.current].Token { - case token.CURRENT: - p.current++ - var query *jsonPathAST - var err error - if single { - query, err = p.parseSingleQuery() - } else { - query, err = p.parseQuery() - } - if err != nil { - return nil, err - } - return &functionArgument{filterQuery: &filterQuery{relQuery: &relQuery{segments: query.segments}}}, nil - case token.ROOT: - p.current++ - var query *jsonPathAST - var err error - if single { - query, err = p.parseSingleQuery() - } else { - query, err = p.parseQuery() - } - if err != nil { - return nil, err - } - return &functionArgument{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{ - segments: query.segments, - lazyContextTracking: p.config.LazyContextTrackingEnabled(), - }}}, nil - } - - // Check for JSONPath Plus context variables as function arguments - if varKind, ok := contextVarTokenMap[p.tokens[p.current].Token]; ok { - p.current++ - return &functionArgument{contextVar: &contextVariable{kind: varKind}}, nil - } - - if expr, err := p.parseLogicalOrExpr(); err == nil { - return &functionArgument{logicalExpr: expr}, nil - } - if funcExpr, err := p.parseFunctionExpr(); err == nil { - return &functionArgument{functionExpr: funcExpr}, nil - } - - return nil, p.parseFailure(&p.tokens[p.current], "unexpected token for function argument") + //function-argument = literal / + // filter-query / ; (includes singular-query) + // logical-expr / + // function-expr + + if lit, err := p.parseLiteral(); err == nil { + return &functionArgument{literal: lit}, nil + } + switch p.tokens[p.current].Token { + case token.CURRENT: + p.current++ + var query *jsonPathAST + var err error + if single { + query, err = p.parseSingleQuery() + } else { + query, err = p.parseQuery() + } + if err != nil { + return nil, err + } + return &functionArgument{filterQuery: &filterQuery{relQuery: &relQuery{segments: query.segments}}}, nil + case token.ROOT: + p.current++ + var query *jsonPathAST + var err error + if single { + query, err = p.parseSingleQuery() + } else { + query, err = p.parseQuery() + } + if err != nil { + return nil, err + } + return &functionArgument{filterQuery: &filterQuery{jsonPathQuery: &jsonPathAST{ + segments: query.segments, + lazyContextTracking: p.config.LazyContextTrackingEnabled(), + }}}, nil + } + + // Check for JSONPath Plus context variables as function arguments + if varKind, ok := contextVarTokenMap[p.tokens[p.current].Token]; ok { + p.current++ + return &functionArgument{contextVar: &contextVariable{kind: varKind}}, nil + } + + if expr, err := p.parseLogicalOrExpr(); err == nil { + return &functionArgument{logicalExpr: expr}, nil + } + if funcExpr, err := p.parseFunctionExpr(); err == nil { + return &functionArgument{functionExpr: funcExpr}, nil + } + + return nil, p.parseFailure(&p.tokens[p.current], "unexpected token for function argument") } func (p *JSONPath) parseLiteral() (*literal, error) { - switch p.tokens[p.current].Token { - case token.STRING_LITERAL: - lit := p.tokens[p.current].Literal - p.current++ - return &literal{string: &lit}, nil - case token.INTEGER: - lit := p.tokens[p.current].Literal - p.current++ - i, err := strconv.Atoi(lit) - if err != nil { - return nil, p.parseFailure(&p.tokens[p.current], "expected integer") - } - return &literal{integer: &i}, nil - case token.FLOAT: - lit := p.tokens[p.current].Literal - p.current++ - f, err := strconv.ParseFloat(lit, 64) - if err != nil { - return nil, p.parseFailure(&p.tokens[p.current], "expected float") - } - return &literal{float64: &f}, nil - case token.TRUE: - p.current++ - res := true - return &literal{bool: &res}, nil - case token.FALSE: - p.current++ - res := false - return &literal{bool: &res}, nil - case token.NULL: - p.current++ - res := true - return &literal{null: &res}, nil - } - return nil, p.parseFailure(&p.tokens[p.current], "expected literal") + switch p.tokens[p.current].Token { + case token.STRING_LITERAL: + lit := p.tokens[p.current].Literal + p.current++ + return &literal{string: &lit}, nil + case token.INTEGER: + lit := p.tokens[p.current].Literal + p.current++ + i, err := strconv.Atoi(lit) + if err != nil { + return nil, p.parseFailure(&p.tokens[p.current], "expected integer") + } + return &literal{integer: &i}, nil + case token.FLOAT: + lit := p.tokens[p.current].Literal + p.current++ + f, err := strconv.ParseFloat(lit, 64) + if err != nil { + return nil, p.parseFailure(&p.tokens[p.current], "expected float") + } + return &literal{float64: &f}, nil + case token.TRUE: + p.current++ + res := true + return &literal{bool: &res}, nil + case token.FALSE: + p.current++ + res := false + return &literal{bool: &res}, nil + case token.NULL: + p.current++ + res := true + return &literal{null: &res}, nil + } + return nil, p.parseFailure(&p.tokens[p.current], "expected literal") } type jsonPathAST struct { - // "$" - segments []*segment - lazyContextTracking bool - jsonPathPlus bool // JSONPath Plus extensions enabled (unquoted brackets, mapping index fallback) + // "$" + segments []*segment + lazyContextTracking bool + jsonPathPlus bool // JSONPath Plus extensions enabled (unquoted brackets, mapping index fallback) } func (q jsonPathAST) ToString() string { - b := strings.Builder{} - b.WriteString("$") - for _, seg := range q.segments { - b.WriteString(seg.ToString()) - } - return b.String() + b := strings.Builder{} + b.WriteString("$") + for _, seg := range q.segments { + b.WriteString(seg.ToString()) + } + return b.String() } diff --git a/pkg/jsonpath/parser_lazy_context_test.go b/pkg/jsonpath/parser_lazy_context_test.go index 87d2d74..ecfaa76 100644 --- a/pkg/jsonpath/parser_lazy_context_test.go +++ b/pkg/jsonpath/parser_lazy_context_test.go @@ -96,7 +96,7 @@ func nestedJSONPathFromTestExpr(ast jsonPathAST) *jsonPathAST { return nil } sel := seg.child.selectors[0] - if sel == nil || sel.filter == nil || sel.filter.expression == nil || len(sel.filter.expression.expressions) == 0 { + if sel == nil || sel.filter.expression == nil || len(sel.filter.expression.expressions) == 0 { return nil } andExpr := sel.filter.expression.expressions[0] @@ -119,7 +119,7 @@ func nestedJSONPathFromFunctionArg(ast jsonPathAST) *jsonPathAST { return nil } sel := seg.child.selectors[0] - if sel == nil || sel.filter == nil || sel.filter.expression == nil || len(sel.filter.expression.expressions) == 0 { + if sel == nil || sel.filter.expression == nil || len(sel.filter.expression.expressions) == 0 { return nil } andExpr := sel.filter.expression.expressions[0] diff --git a/pkg/jsonpath/segment.go b/pkg/jsonpath/segment.go index b80bb76..e4e9ec3 100644 --- a/pkg/jsonpath/segment.go +++ b/pkg/jsonpath/segment.go @@ -9,10 +9,11 @@ import ( type segmentKind int const ( - segmentKindChild segmentKind = iota // . - segmentKindDescendant // .. - segmentKindProperyName // ~ (extension only) - segmentKindParent // ^ (JSONPath Plus parent selector) + segmentKindChild segmentKind = iota // . + segmentKindDescendant // .. + segmentKindProperyName // ~ (extension only) + segmentKindParent // ^ (JSONPath Plus parent selector) + segmentKindRecursivePropertyName // .~ (Spectral/JSONPath Plus recursive property names) ) type segment struct { @@ -43,6 +44,8 @@ func (s segment) ToString() string { return "~" case segmentKindParent: return "^" + case segmentKindRecursivePropertyName: + return ".~" } panic("unknown segment kind") } @@ -90,7 +93,7 @@ func descendApply(value *yaml.Node, apply func(*yaml.Node)) { func (s segment) IsSingular() bool { switch s.kind { - case segmentKindDescendant: + case segmentKindDescendant, segmentKindRecursivePropertyName: return false case segmentKindParent: return true @@ -148,7 +151,7 @@ func (s segment) getSegmentInfo() ([]SegmentInfo, error) { return s.child.getSegmentInfo() case segmentKindDescendant: return nil, fmt.Errorf("recursive descent not supported for upsert") - case segmentKindParent, segmentKindProperyName: + case segmentKindParent, segmentKindProperyName, segmentKindRecursivePropertyName: return nil, fmt.Errorf("parent/property selectors not supported for upsert") default: return nil, fmt.Errorf("unknown segment kind") diff --git a/pkg/jsonpath/selector.go b/pkg/jsonpath/selector.go index 9f5ea06..b06cb2e 100644 --- a/pkg/jsonpath/selector.go +++ b/pkg/jsonpath/selector.go @@ -27,8 +27,9 @@ type selector struct { name string index int64 slice *slice - filter *filterSelector + filter filterSelector jsonPathPlus bool // when true, enables MappingNode fallback for array index selectors + spectral bool // when true, enables Spectral-only context typing for array index selectors } func (s selector) ToString() string { @@ -60,5 +61,4 @@ func (s selector) ToString() string { default: panic(fmt.Sprintf("unimplemented selector kind: %v", s.kind)) } - return "" } diff --git a/pkg/jsonpath/spectral_bench_test.go b/pkg/jsonpath/spectral_bench_test.go new file mode 100644 index 0000000..a97f1fd --- /dev/null +++ b/pkg/jsonpath/spectral_bench_test.go @@ -0,0 +1,95 @@ +package jsonpath + +import ( + "fmt" + "strings" + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "go.yaml.in/yaml/v4" +) + +func BenchmarkSpectralRegexFilter(b *testing.B) { + var source strings.Builder + source.WriteString("paths:\n") + for i := 0; i < 1000; i++ { + fmt.Fprintf(&source, " /resource/%d:\n responses:\n %d: {}\n", i, 200+i%400) + } + var document yaml.Node + if err := yaml.Unmarshal([]byte(source.String()), &document); err != nil { + b.Fatal(err) + } + path, err := NewPath(`$.paths[*].responses[?(@property.match(/^(4|5)/))]`, config.WithSpectralCompatibility()) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = path.Query(&document) + } +} + +func BenchmarkSpectralRegexCompilation(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := NewPath(`$.paths[?(@property.match(/^\/api\/v[0-9]+/i))]`, config.WithSpectralCompatibility()); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkDefaultRFCFilter(b *testing.B) { + var document yaml.Node + if err := yaml.Unmarshal([]byte("values: [{count: 1}, {count: 2}, {count: 3}]\n"), &document); err != nil { + b.Fatal(err) + } + path, err := NewPath(`$.values[?(@.count > 1)]`) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = path.Query(&document) + } +} + +func BenchmarkDefaultRFCCompilation(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := NewPath(`$.values[?(@.count > 1)]`); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSpectralContextTracking(b *testing.B) { + var document yaml.Node + if err := yaml.Unmarshal([]byte("values: [{name: one}, {name: two}, {name: three}]\n"), &document); err != nil { + b.Fatal(err) + } + tests := []struct { + name string + expression string + options []config.Option + }{ + {name: "DefaultEager", expression: `$.values[?(@.name == 'three')]`}, + {name: "DefaultLazy", expression: `$.values[?(@.name == 'three')]`, options: []config.Option{config.WithLazyContextTracking()}}, + {name: "SpectralEager", expression: `$.values[?(@property === 2 && @.name.length === 5)]`, options: []config.Option{config.WithSpectralCompatibility()}}, + {name: "SpectralLazy", expression: `$.values[?(@property === 2 && @.name.length === 5)]`, options: []config.Option{config.WithSpectralCompatibility(), config.WithLazyContextTracking()}}, + } + for _, test := range tests { + b.Run(test.name, func(b *testing.B) { + path, err := NewPath(test.expression, test.options...) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = path.Query(&document) + } + }) + } +} diff --git a/pkg/jsonpath/spectral_corpus_test.go b/pkg/jsonpath/spectral_corpus_test.go new file mode 100644 index 0000000..77df4f1 --- /dev/null +++ b/pkg/jsonpath/spectral_corpus_test.go @@ -0,0 +1,271 @@ +package jsonpath + +import ( + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "go.yaml.in/yaml/v4" +) + +type spectralCorpus struct { + Baseline struct { + SpectralCommit string `json:"spectralCommit"` + SpectralCoreVersion string `json:"spectralCoreVersion"` + NimmaVersion string `json:"nimmaVersion"` + JSONPathPlusVersion string `json:"jsonPathPlusVersion"` + GeneratedBy string `json:"generatedBy"` + Unsafe bool `json:"unsafe"` + } `json:"baseline"` + Cases []struct { + Name string `json:"name"` + Source string `json:"source"` + Expression string `json:"expression"` + Input map[string]any `json:"input"` + Engine string `json:"engine"` + ExpectedPaths []string `json:"expectedPaths"` + Extension bool `json:"extension"` + } `json:"cases"` +} + +func TestSpectralDifferentialCorpus(t *testing.T) { + data, err := os.ReadFile("testdata/spectral/corpus.json") + if err != nil { + t.Fatalf("read corpus: %v", err) + } + var corpus spectralCorpus + if err := json.Unmarshal(data, &corpus); err != nil { + t.Fatalf("decode corpus: %v", err) + } + if corpus.Baseline.SpectralCommit == "" || corpus.Baseline.SpectralCoreVersion == "" || + corpus.Baseline.NimmaVersion == "" || corpus.Baseline.JSONPathPlusVersion == "" || + corpus.Baseline.GeneratedBy == "" || corpus.Baseline.Unsafe { + t.Fatalf("incomplete or unsafe Spectral baseline: %+v", corpus.Baseline) + } + if len(corpus.Cases) == 0 { + t.Fatal("Spectral corpus is empty") + } + + for _, testCase := range corpus.Cases { + t.Run(testCase.Name, func(t *testing.T) { + if testCase.Source == "" || (testCase.Engine != "nimma" && testCase.Engine != "jsonpath-plus") { + t.Fatalf("case is missing source or selected engine: %+v", testCase) + } + input, err := json.Marshal(testCase.Input) + if err != nil { + t.Fatalf("encode input: %v", err) + } + var document yaml.Node + if err := yaml.Unmarshal(input, &document); err != nil { + t.Fatalf("decode YAML node: %v", err) + } + compiled, err := NewPath(testCase.Expression, config.WithSpectralCompatibility()) + if err != nil { + t.Fatalf("compile: %v", err) + } + got := normalizedResultPaths(&document, compiled.Query(&document)) + want := append([]string(nil), testCase.ExpectedPaths...) + sort.Strings(want) + if !equalStrings(got, want) { + t.Fatalf("engine=%s paths=%v, want %v", testCase.Engine, got, want) + } + + roundTrip, err := NewPath(compiled.String(), config.WithSpectralCompatibility()) + if err != nil { + t.Fatalf("compile round trip %q: %v", compiled.String(), err) + } + if roundTripPaths := normalizedResultPaths(&document, roundTrip.Query(&document)); !equalStrings(roundTripPaths, want) { + t.Fatalf("round-trip paths=%v, want %v", roundTripPaths, want) + } + + if testCase.Extension { + if _, err := NewPath(testCase.Expression, config.WithStrictRFC9535()); err == nil { + t.Fatalf("strict RFC mode accepted Spectral-only expression") + } + } + }) + } +} + +func TestSpectralPinnedOfficialSelectorsCompile(t *testing.T) { + data, err := os.ReadFile("testdata/spectral/official-selectors.json") + if err != nil { + t.Fatalf("read official selectors: %v", err) + } + var inventory struct { + Repository string `json:"repository"` + License string `json:"license"` + Commit string `json:"commit"` + Files []string `json:"files"` + Selectors []string `json:"selectors"` + } + if err := json.Unmarshal(data, &inventory); err != nil { + t.Fatalf("decode official selectors: %v", err) + } + if inventory.Repository == "" || inventory.License == "" || inventory.Commit == "" || len(inventory.Files) != 2 || len(inventory.Selectors) < 100 { + t.Fatalf("official selector inventory is incomplete: commit=%q files=%d selectors=%d", inventory.Commit, len(inventory.Files), len(inventory.Selectors)) + } + for _, expression := range inventory.Selectors { + compiled, err := NewPath(expression, config.WithSpectralCompatibility()) + if err != nil { + t.Errorf("compile %s: %v", expression, err) + continue + } + if _, err := NewPath(compiled.String(), config.WithSpectralCompatibility()); err != nil { + t.Errorf("compile round trip %s as %s: %v", expression, compiled.String(), err) + } + } +} + +func TestSpectralPinnedPublicSelectorsCompile(t *testing.T) { + data, err := os.ReadFile("testdata/spectral/public-selectors.json") + if err != nil { + t.Fatalf("read public selectors: %v", err) + } + var inventory struct { + GeneratedBy string `json:"generatedBy"` + Sources []struct { + Name string `json:"name"` + Repository string `json:"repository"` + License string `json:"license"` + Commit string `json:"commit"` + Path string `json:"path"` + } `json:"sources"` + Selectors []struct { + Expression string `json:"expression"` + Sources []string `json:"sources"` + Status string `json:"status"` + Reason string `json:"reason"` + } `json:"selectors"` + } + if err := json.Unmarshal(data, &inventory); err != nil { + t.Fatalf("decode public selectors: %v", err) + } + if inventory.GeneratedBy == "" || len(inventory.Sources) != 4 || len(inventory.Selectors) < 50 { + t.Fatalf("public selector inventory is incomplete: generatedBy=%q sources=%d selectors=%d", inventory.GeneratedBy, len(inventory.Sources), len(inventory.Selectors)) + } + sourceNames := make(map[string]struct{}, len(inventory.Sources)) + for _, source := range inventory.Sources { + if source.Name == "" || source.Repository == "" || source.License == "" || source.Commit == "" || source.Path == "" { + t.Fatalf("public source metadata is incomplete: %+v", source) + } + sourceNames[source.Name] = struct{}{} + } + for _, entry := range inventory.Selectors { + if entry.Status != "supported" || entry.Reason != "" { + t.Errorf("public selector %q classified as %s: %s", entry.Expression, entry.Status, entry.Reason) + continue + } + if entry.Expression == "" || strings.HasPrefix(entry.Expression, "#") || len(entry.Sources) == 0 { + t.Errorf("invalid or unresolved public selector entry: %+v", entry) + continue + } + for _, source := range entry.Sources { + if _, ok := sourceNames[source]; !ok { + t.Errorf("selector %q references unknown source %q", entry.Expression, source) + } + } + compiled, err := NewPath(entry.Expression, config.WithSpectralCompatibility()) + if err != nil { + t.Errorf("compile public selector %s: %v", entry.Expression, err) + continue + } + if _, err := NewPath(compiled.String(), config.WithSpectralCompatibility()); err != nil { + t.Errorf("compile public selector round trip %s as %s: %v", entry.Expression, compiled.String(), err) + } + } +} + +func TestSpectralPinnedOfficialSelectorParity(t *testing.T) { + data, err := os.ReadFile("testdata/spectral/official-parity.json") + if err != nil { + t.Fatalf("read official parity corpus: %v", err) + } + var parity struct { + Baseline struct { + SpectralCommit string `json:"spectralCommit"` + GeneratedBy string `json:"generatedBy"` + SourceRepository string `json:"sourceRepository"` + SourceLicense string `json:"sourceLicense"` + Unsafe bool `json:"unsafe"` + } `json:"baseline"` + Input map[string]any `json:"input"` + Cases []struct { + Expression string `json:"expression"` + Engine string `json:"engine"` + ExpectedPaths []string `json:"expectedPaths"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &parity); err != nil { + t.Fatalf("decode official parity corpus: %v", err) + } + if parity.Baseline.SpectralCommit == "" || parity.Baseline.GeneratedBy == "" || parity.Baseline.SourceRepository == "" || parity.Baseline.SourceLicense == "" || parity.Baseline.Unsafe || len(parity.Cases) < 100 { + t.Fatalf("official parity baseline is incomplete: commit=%q generatedBy=%q unsafe=%v cases=%d", parity.Baseline.SpectralCommit, parity.Baseline.GeneratedBy, parity.Baseline.Unsafe, len(parity.Cases)) + } + input, err := json.Marshal(parity.Input) + if err != nil { + t.Fatalf("encode official input: %v", err) + } + var document yaml.Node + if err := yaml.Unmarshal(input, &document); err != nil { + t.Fatalf("decode official input node: %v", err) + } + for _, testCase := range parity.Cases { + t.Run(testCase.Expression, func(t *testing.T) { + compiled, err := NewPath(testCase.Expression, config.WithSpectralCompatibility()) + if err != nil { + t.Fatalf("compile: %v", err) + } + got := normalizedResultPaths(&document, compiled.Query(&document)) + want := append([]string(nil), testCase.ExpectedPaths...) + sort.Strings(want) + if !equalStrings(got, want) { + t.Fatalf("engine=%s paths=%v, want %v", testCase.Engine, got, want) + } + }) + } +} + +func normalizedResultPaths(document *yaml.Node, results []*yaml.Node) []string { + paths := make(map[*yaml.Node]string) + collectNormalizedPaths(unwrapDocument(document), "$", paths) + normalized := make([]string, 0, len(results)) + for _, result := range results { + if path, ok := paths[result]; ok { + normalized = append(normalized, path) + } else { + normalized = append(normalized, "") + } + } + sort.Strings(normalized) + return normalized +} + +func collectNormalizedPaths(node *yaml.Node, path string, paths map[*yaml.Node]string) { + if node == nil { + return + } + paths[node] = path + switch node.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + value := node.Content[i+1] + childPath := path + "['" + escapeNormalizedPathKey(key.Value) + "']" + paths[key] = childPath + collectNormalizedPaths(value, childPath, paths) + } + case yaml.SequenceNode: + for i, child := range node.Content { + collectNormalizedPaths(child, path+"["+strconv.Itoa(i)+"]", paths) + } + } +} + +func escapeNormalizedPathKey(value string) string { + return strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `'`, `\'`) +} diff --git a/pkg/jsonpath/spectral_eval.go b/pkg/jsonpath/spectral_eval.go new file mode 100644 index 0000000..0abaadd --- /dev/null +++ b/pkg/jsonpath/spectral_eval.go @@ -0,0 +1,529 @@ +package jsonpath + +import ( + "math" + "strconv" + "unicode/utf16" + + "github.com/pb33f/jsonpath/pkg/jsonpath/token" + "go.yaml.in/yaml/v4" +) + +type spectralRuntimeKind uint8 + +const ( + spectralMissing spectralRuntimeKind = iota + spectralInvalid + spectralNull + spectralBoolean + spectralNumber + spectralString + spectralNode + spectralRegexValue +) + +type spectralRuntimeValue struct { + kind spectralRuntimeKind + boolean bool + number float64 + string string + node *yaml.Node + regex *spectralRegex + propertyMethodCoercion bool +} + +type spectralEvalContext struct { + index index + current *yaml.Node + root *yaml.Node +} + +func (e *spectralBoolExpr) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { + result, valid := e.evaluate(spectralEvalContext{index: idx, current: node, root: unwrapDocument(root)}) + return valid && result +} + +func (e *spectralBoolExpr) evaluate(ctx spectralEvalContext) (bool, bool) { + if e == nil { + return false, false + } + switch e.kind { + case spectralBoolValue: + value := e.value.evaluate(ctx) + return value.truthy(), value.kind != spectralInvalid + case spectralBoolNot: + result, valid := e.left.evaluate(ctx) + return !result, valid + case spectralBoolAnd: + left, valid := e.left.evaluate(ctx) + if !valid || !left { + return left, valid + } + return e.right.evaluate(ctx) + case spectralBoolOr: + left, valid := e.left.evaluate(ctx) + if !valid || left { + return left, valid + } + return e.right.evaluate(ctx) + case spectralBoolCompare: + left := e.left.value.evaluate(ctx) + right := e.right.value.evaluate(ctx) + if left.kind == spectralInvalid || right.kind == spectralInvalid { + return false, false + } + return spectralCompare(left, right, e.op), true + case spectralBoolParen: + return e.left.evaluate(ctx) + default: + return false, false + } +} + +func (e *spectralValueExpr) evaluate(ctx spectralEvalContext) spectralRuntimeValue { + if e == nil { + return spectralRuntimeValue{} + } + var value spectralRuntimeValue + switch e.kind { + case spectralValueLiteral: + value = spectralRuntimeFromLiteral(e.literal) + case spectralValueCurrent: + value = spectralRuntimeValue{kind: spectralNode, node: ctx.current} + case spectralValueRoot: + value = spectralRuntimeValue{kind: spectralNode, node: ctx.root} + case spectralValueContext: + value = spectralContextValue(e.context, ctx) + case spectralValueRegex: + value = spectralRuntimeValue{kind: spectralRegexValue, regex: e.regex} + case spectralValueUndefined: + value = spectralRuntimeValue{} + case spectralValueFunction: + value = spectralRuntimeFromLiteral(e.function.Evaluate(ctx.index, ctx.current, ctx.root)) + } + + if len(e.segments) > 0 { + if value.kind != spectralNode || value.node == nil { + return spectralRuntimeValue{} + } + resolved := value.node + for _, segment := range e.segments { + resolved = spectralResolveSegment(resolved, segment) + if resolved == nil { + return spectralRuntimeValue{} + } + } + value = spectralRuntimeFromNode(resolved) + } else if value.kind == spectralNode { + value = spectralRuntimeFromNode(value.node) + } + + for _, postfix := range e.postfix { + value = spectralApplyPostfix(value, postfix) + if value.kind == spectralMissing { + break + } + } + return value +} + +func spectralRuntimeFromLiteral(value literal) spectralRuntimeValue { + switch { + case value.integer != nil: + return spectralRuntimeValue{kind: spectralNumber, number: float64(*value.integer)} + case value.float64 != nil: + return spectralRuntimeValue{kind: spectralNumber, number: *value.float64} + case value.string != nil: + return spectralRuntimeValue{kind: spectralString, string: *value.string} + case value.bool != nil: + return spectralRuntimeValue{kind: spectralBoolean, boolean: *value.bool} + case value.null != nil: + return spectralRuntimeValue{kind: spectralNull} + case value.node != nil: + return spectralRuntimeFromNode(value.node) + default: + return spectralRuntimeValue{} + } +} + +func spectralRuntimeFromNode(node *yaml.Node) spectralRuntimeValue { + node = unwrapDocument(node) + if node == nil { + return spectralRuntimeValue{} + } + if node.Kind == yaml.SequenceNode || node.Kind == yaml.MappingNode { + return spectralRuntimeValue{kind: spectralNode, node: node} + } + switch node.Tag { + case "!!null": + return spectralRuntimeValue{kind: spectralNull} + case "!!bool": + value, err := strconv.ParseBool(node.Value) + if err != nil { + return spectralRuntimeValue{} + } + return spectralRuntimeValue{kind: spectralBoolean, boolean: value} + case "!!int": + value, err := strconv.ParseFloat(node.Value, 64) + if err != nil { + return spectralRuntimeValue{} + } + return spectralRuntimeValue{kind: spectralNumber, number: value} + case "!!float": + value, err := strconv.ParseFloat(node.Value, 64) + if err != nil { + return spectralRuntimeValue{} + } + return spectralRuntimeValue{kind: spectralNumber, number: value} + case "!!str": + return spectralRuntimeValue{kind: spectralString, string: node.Value} + default: + if node.Kind == yaml.ScalarNode { + return spectralRuntimeValue{kind: spectralString, string: node.Value} + } + return spectralRuntimeValue{kind: spectralNode, node: node} + } +} + +func spectralContextValue(kind contextVarKind, ctx spectralEvalContext) spectralRuntimeValue { + filterCtx, ok := ctx.index.(FilterContext) + if !ok { + return spectralRuntimeValue{} + } + switch kind { + case contextVarProperty: + if filterCtx.Index() >= 0 { + return spectralRuntimeValue{kind: spectralNumber, number: float64(filterCtx.Index()), propertyMethodCoercion: true} + } + return spectralRuntimeValue{kind: spectralString, string: filterCtx.PropertyName()} + case contextVarRoot: + return spectralRuntimeFromNode(ctx.root) + case contextVarParent: + return spectralRuntimeFromNode(filterCtx.Parent()) + case contextVarParentProperty: + parent := filterCtx.Parent() + if parent != nil { + if parentContainer := ctx.index.getParentNode(parent); parentContainer != nil && parentContainer.Kind == yaml.SequenceNode { + if parentIndex, err := strconv.Atoi(filterCtx.ParentPropertyName()); err == nil { + return spectralRuntimeValue{kind: spectralNumber, number: float64(parentIndex), propertyMethodCoercion: true} + } + } + } + return spectralRuntimeValue{kind: spectralString, string: filterCtx.ParentPropertyName()} + case contextVarPath: + return spectralRuntimeValue{kind: spectralString, string: filterCtx.Path()} + case contextVarIndex: + return spectralRuntimeValue{kind: spectralNumber, number: float64(filterCtx.Index())} + default: + return spectralRuntimeValue{} + } +} + +func spectralResolveSegment(node *yaml.Node, segment spectralPathSegment) *yaml.Node { + node = unwrapDocument(node) + if node == nil { + return nil + } + if segment.index != nil { + if node.Kind != yaml.SequenceNode { + return nil + } + index := *segment.index + if index < 0 { + index += int64(len(node.Content)) + } + if index < 0 || index >= int64(len(node.Content)) { + return nil + } + return node.Content[index] + } + if node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == segment.name { + return node.Content[i+1] + } + } + return nil +} + +func spectralApplyPostfix(value spectralRuntimeValue, postfix spectralPostfix) spectralRuntimeValue { + if value.kind == spectralNumber && value.propertyMethodCoercion && + (postfix.kind == spectralPostfixMatch || postfix.kind == spectralPostfixIndexOf || postfix.kind == spectralPostfixIncludes) { + value = spectralRuntimeValue{kind: spectralString, string: strconv.FormatFloat(value.number, 'f', -1, 64)} + } + switch postfix.kind { + case spectralPostfixMatch: + if value.kind != spectralString { + return spectralRuntimeValue{kind: spectralInvalid} + } + return spectralRuntimeValue{kind: spectralBoolean, boolean: postfix.regex.compiled.MatchString(value.string)} + case spectralPostfixIndexOf: + if value.kind != spectralString { + return spectralRuntimeValue{kind: spectralInvalid} + } + from := 0 + if postfix.fromIndex != nil { + from = int(math.Trunc(*postfix.fromIndex)) + } + return spectralRuntimeValue{kind: spectralNumber, number: float64(spectralUTF16IndexOf(value.string, postfix.search, from))} + case spectralPostfixLength: + switch value.kind { + case spectralString: + return spectralRuntimeValue{kind: spectralNumber, number: float64(len(utf16.Encode([]rune(value.string))))} + case spectralNode: + if value.node != nil && value.node.Kind == yaml.SequenceNode { + return spectralRuntimeValue{kind: spectralNumber, number: float64(len(value.node.Content))} + } + } + return spectralRuntimeValue{kind: spectralInvalid} + case spectralPostfixConstructorName: + name := "" + switch value.kind { + case spectralString: + name = "String" + case spectralNumber: + name = "Number" + case spectralBoolean: + name = "Boolean" + case spectralNode: + if value.node != nil && value.node.Kind == yaml.SequenceNode { + name = "Array" + } else if value.node != nil && value.node.Kind == yaml.MappingNode { + name = "Object" + } + } + if name == "" { + return spectralRuntimeValue{kind: spectralInvalid} + } + return spectralRuntimeValue{kind: spectralString, string: name} + case spectralPostfixIncludes: + argument := spectralRuntimeFromLiteral(postfix.argument) + from := 0 + if postfix.fromIndex != nil { + from = int(math.Trunc(*postfix.fromIndex)) + } + switch value.kind { + case spectralString: + if argument.kind != spectralString { + return spectralRuntimeValue{kind: spectralInvalid} + } + if from < 0 { + from = 0 + } + return spectralRuntimeValue{kind: spectralBoolean, boolean: spectralUTF16IndexOf(value.string, argument.string, from) >= 0} + case spectralNode: + if value.node == nil || value.node.Kind != yaml.SequenceNode { + return spectralRuntimeValue{kind: spectralInvalid} + } + if from < 0 { + from += len(value.node.Content) + if from < 0 { + from = 0 + } + } + if from > len(value.node.Content) { + return spectralRuntimeValue{kind: spectralBoolean, boolean: false} + } + for _, item := range value.node.Content[from:] { + if spectralStrictEqual(spectralRuntimeFromNode(item), argument) { + return spectralRuntimeValue{kind: spectralBoolean, boolean: true} + } + } + return spectralRuntimeValue{kind: spectralBoolean, boolean: false} + default: + return spectralRuntimeValue{kind: spectralInvalid} + } + default: + return spectralRuntimeValue{kind: spectralInvalid} + } +} + +func spectralUTF16IndexOf(value, search string, from int) int { + valueUnits := utf16.Encode([]rune(value)) + searchUnits := utf16.Encode([]rune(search)) + if from < 0 { + from = 0 + } + if from > len(valueUnits) { + from = len(valueUnits) + } + if len(searchUnits) == 0 { + return from + } + for i := from; i+len(searchUnits) <= len(valueUnits); i++ { + matched := true + for j := range searchUnits { + if valueUnits[i+j] != searchUnits[j] { + matched = false + break + } + } + if matched { + return i + } + } + return -1 +} + +func (value spectralRuntimeValue) truthy() bool { + switch value.kind { + case spectralMissing, spectralInvalid, spectralNull: + return false + case spectralBoolean: + return value.boolean + case spectralNumber: + return value.number != 0 && !math.IsNaN(value.number) + case spectralString: + return value.string != "" + case spectralNode, spectralRegexValue: + return true + default: + return false + } +} + +func spectralCompare(left, right spectralRuntimeValue, operator token.Token) bool { + switch operator { + case token.STRICT_EQ: + return spectralStrictEqual(left, right) + case token.STRICT_NE: + return !spectralStrictEqual(left, right) + case token.EQ: + return spectralLooseEqual(left, right) + case token.NE: + return !spectralLooseEqual(left, right) + case token.LT, token.LE, token.GT, token.GE: + comparison, ok := spectralRelationalCompare(left, right) + if !ok { + return false + } + switch operator { + case token.LT: + return comparison < 0 + case token.LE: + return comparison <= 0 + case token.GT: + return comparison > 0 + case token.GE: + return comparison >= 0 + } + } + return false +} + +func spectralStrictEqual(left, right spectralRuntimeValue) bool { + if left.kind == spectralInvalid || right.kind == spectralInvalid { + return false + } + if left.kind != right.kind { + return false + } + switch left.kind { + case spectralMissing, spectralNull: + return true + case spectralBoolean: + return left.boolean == right.boolean + case spectralNumber: + return left.number == right.number + case spectralString: + return left.string == right.string + case spectralNode: + return left.node == right.node + case spectralRegexValue: + return left.regex == right.regex + default: + return false + } +} + +func spectralLooseEqual(left, right spectralRuntimeValue) bool { + if spectralStrictEqual(left, right) { + return true + } + if (left.kind == spectralNull && right.kind == spectralMissing) || (left.kind == spectralMissing && right.kind == spectralNull) { + return true + } + if left.kind == spectralBoolean { + left = spectralRuntimeValue{kind: spectralNumber, number: spectralBooleanNumber(left.boolean)} + } + if right.kind == spectralBoolean { + right = spectralRuntimeValue{kind: spectralNumber, number: spectralBooleanNumber(right.boolean)} + } + if spectralStrictEqual(left, right) { + return true + } + if left.kind == spectralString && right.kind == spectralNumber { + number, ok := spectralStringNumber(left.string) + return ok && number == right.number + } + if left.kind == spectralNumber && right.kind == spectralString { + number, ok := spectralStringNumber(right.string) + return ok && left.number == number + } + return false +} + +func spectralBooleanNumber(value bool) float64 { + if value { + return 1 + } + return 0 +} + +func spectralStringNumber(value string) (float64, bool) { + if value == "" { + return 0, true + } + number, err := strconv.ParseFloat(value, 64) + return number, err == nil +} + +func spectralRelationalCompare(left, right spectralRuntimeValue) (int, bool) { + if left.kind == spectralString && right.kind == spectralString { + switch { + case left.string < right.string: + return -1, true + case left.string > right.string: + return 1, true + default: + return 0, true + } + } + leftNumber, leftOK := spectralComparableNumber(left) + rightNumber, rightOK := spectralComparableNumber(right) + if !leftOK || !rightOK || math.IsNaN(leftNumber) || math.IsNaN(rightNumber) { + return 0, false + } + switch { + case leftNumber < rightNumber: + return -1, true + case leftNumber > rightNumber: + return 1, true + default: + return 0, true + } +} + +func spectralComparableNumber(value spectralRuntimeValue) (float64, bool) { + switch value.kind { + case spectralNumber: + return value.number, true + case spectralString: + return spectralStringNumber(value.string) + case spectralBoolean: + return spectralBooleanNumber(value.boolean), true + case spectralNull: + return 0, true + default: + return 0, false + } +} + +func unwrapDocument(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) == 1 { + return node.Content[0] + } + return node +} diff --git a/pkg/jsonpath/spectral_eval_test.go b/pkg/jsonpath/spectral_eval_test.go new file mode 100644 index 0000000..500962c --- /dev/null +++ b/pkg/jsonpath/spectral_eval_test.go @@ -0,0 +1,198 @@ +package jsonpath + +import ( + "testing" + + "go.yaml.in/yaml/v4" +) + +func TestSpectralTruthinessAllValueKinds(t *testing.T) { + source := ` +values: + - null + - false + - true + - 0 + - 1 + - "" + - x + - [] + - {} +` + nodes := querySpectral(t, `$.values[?(@)]`, source) + if len(nodes) != 5 { + t.Fatalf("truthiness selected %d nodes, want true, one, string, array and object", len(nodes)) + } +} + +func TestSpectralSyntheticConstructorNames(t *testing.T) { + source := ` +values: + string: value + number: 42 + float: 4.2 + boolean: true + array: [] + object: {} + null: null +` + tests := []struct { + name string + want int + }{ + {"String", 1}, + {"Number", 2}, + {"Boolean", 1}, + {"Array", 1}, + {"Object", 1}, + } + for _, test := range tests { + expression := `$.values[?(@.constructor.name === '` + test.name + `')]` + if got := querySpectral(t, expression, source); len(got) != test.want { + t.Errorf("%s selected %d nodes, want %d", expression, len(got), test.want) + } + } + if got := querySpectral(t, `$.values[?(!@.constructor.name)]`, source); len(got) != 0 { + t.Fatalf("negated constructor type error selected %d nodes", len(got)) + } +} + +func TestSpectralStringOperationsMatchJavaScriptIndexes(t *testing.T) { + source := ` +values: + - a😀b + - absent +` + tests := []string{ + `$.values[?(@.indexOf('b', -3) === 3)]`, + `$.values[?(@.indexOf('', 99) === 4)]`, + `$.values[?(@.indexOf('missing') === -1)]`, + `$.values[?(@.includes('😀', -1))]`, + `$.values[?(@.length === 4)]`, + } + wants := []int{1, 1, 2, 1, 1} + for i, expression := range tests { + if got := querySpectral(t, expression, source); len(got) != wants[i] { + t.Errorf("%s selected %d nodes, want %d", expression, len(got), wants[i]) + } + } +} + +func TestSpectralSequenceIncludesAndOffsets(t *testing.T) { + source := ` +values: + - list: [one, two, three] + - list: [three] +` + if got := querySpectral(t, `$.values[?(@.list.includes('three', 2))]`, source); len(got) != 1 { + t.Fatalf("offset includes selected %d nodes, want one", len(got)) + } + if got := querySpectral(t, `$.values[?(@.list.includes('three', 99))]`, source); len(got) != 0 { + t.Fatalf("oversized includes selected %d nodes, want none", len(got)) + } + if got := querySpectral(t, `$.values[?(@.list.includes('one', -1))]`, source); len(got) != 0 { + t.Fatalf("negative includes offset selected %d nodes before the normalized start", len(got)) + } + if got := querySpectral(t, `$.values[?(@.list.includes('two', -2))]`, source); len(got) != 1 { + t.Fatalf("negative includes offset selected %d nodes, want one", len(got)) + } +} + +func TestSpectralLooseEqualityAndRelationalCoercion(t *testing.T) { + source := ` +values: + - {a: "200", b: 200} + - {a: false, b: 0} + - {a: true, b: 1} + - {a: abc, b: 0} +` + if got := querySpectral(t, `$.values[?(@.a == @.b)]`, source); len(got) != 3 { + t.Fatalf("loose equality selected %d nodes, want three", len(got)) + } + if got := querySpectral(t, `$.values[?(@.a != @.b)]`, source); len(got) != 1 { + t.Fatalf("loose inequality selected %d nodes, want one", len(got)) + } + if got := querySpectral(t, `$.values[?(@.a > 100)]`, source); len(got) != 1 { + t.Fatalf("numeric coercion selected %d nodes, want one", len(got)) + } + if got := querySpectral(t, `$.values[?(@.missing == null)]`, source); len(got) != 4 { + t.Fatalf("undefined/null equality selected %d nodes, want four", len(got)) + } +} + +func TestSpectralRegexModes(t *testing.T) { + source := "values: [\"foo\\nbar\", FoO, x]\n" + tests := []struct { + expression string + want int + }{ + {`$.values[?(@.match(/^bar$/m))]`, 1}, + {`$.values[?(@.match(/foo.bar/s))]`, 1}, + {`$.values[?(@.match(/foo/ig))]`, 2}, + {`$.values[?(@.match(/foo/iu))]`, 2}, + } + for _, test := range tests { + if got := querySpectral(t, test.expression, source); len(got) != test.want { + t.Errorf("%s selected %d nodes, want %d", test.expression, len(got), test.want) + } + } +} + +func TestSpectralRootContextPathAndBracketQueries(t *testing.T) { + source := ` +expected: final +values: + - {items: [first, final]} + - {items: [other]} +` + if got := querySpectral(t, `$.values[?(@.items[-1] === @root.expected)]`, source); len(got) != 1 { + t.Fatalf("root and bracket query selected %d nodes, want one", len(got)) + } + if got := querySpectral(t, `$.values[?(@path.match(/\[0\]$/))]`, source); len(got) != 1 { + t.Fatalf("path method selected %d nodes, want one", len(got)) + } +} + +func TestSpectralWrongTypePostfixNeverMatches(t *testing.T) { + source := ` +values: + - {value: 1} + - {value: {nested: true}} + - {value: null} +` + expressions := []string{ + `$.values[?(@.value.indexOf('x') === void 0)]`, + `$.values[?(!@.value.length)]`, + `$.values[?(@.value.includes('x'))]`, + } + for _, expression := range expressions { + if got := querySpectral(t, expression, source); len(got) != 0 { + t.Errorf("%s selected %d nodes after a type error", expression, len(got)) + } + } +} + +func TestSpectralScalarStrictEquality(t *testing.T) { + source := ` +values: + - {a: 1, b: 1.0} + - {a: true, b: true} + - {a: null, b: null} + - {a: one, b: one} + - {a: one, b: two} +` + if got := querySpectral(t, `$.values[?(@.a === @.b)]`, source); len(got) != 4 { + t.Fatalf("strict equality selected %d nodes, want four", len(got)) + } + if got := querySpectral(t, `$.values[?(@.a !== @.b)]`, source); len(got) != 1 { + t.Fatalf("strict inequality selected %d nodes, want one", len(got)) + } +} + +func TestSpectralLengthOnMappingIsInvalid(t *testing.T) { + source := "values: [{a: 1}, [one], text]\n" + nodes := querySpectral(t, `$.values[?(@.length)]`, source) + if len(nodes) != 2 || nodes[0].Kind != yaml.SequenceNode || nodes[1].Value != "text" { + t.Fatalf("length truthiness selected unexpected nodes: %+v", nodeValues(nodes)) + } +} diff --git a/pkg/jsonpath/spectral_expr.go b/pkg/jsonpath/spectral_expr.go new file mode 100644 index 0000000..50a89b9 --- /dev/null +++ b/pkg/jsonpath/spectral_expr.go @@ -0,0 +1,659 @@ +package jsonpath + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/pb33f/jsonpath/pkg/jsonpath/token" +) + +type spectralBoolKind uint8 + +const ( + spectralBoolValue spectralBoolKind = iota + spectralBoolNot + spectralBoolAnd + spectralBoolOr + spectralBoolCompare + spectralBoolParen +) + +type spectralBoolExpr struct { + kind spectralBoolKind + value *spectralValueExpr + left *spectralBoolExpr + right *spectralBoolExpr + op token.Token + usage contextVarUsage + usesPropertyNameSelector bool +} + +func (e *spectralBoolExpr) String() string { + if e == nil { + return "" + } + switch e.kind { + case spectralBoolValue: + return e.value.String() + case spectralBoolNot: + return "!" + e.left.String() + case spectralBoolAnd: + return e.left.String() + " && " + e.right.String() + case spectralBoolOr: + return e.left.String() + " || " + e.right.String() + case spectralBoolCompare: + return e.left.String() + " " + e.op.String() + " " + e.right.String() + case spectralBoolParen: + return "(" + e.left.String() + ")" + default: + return "" + } +} + +type spectralValueKind uint8 + +const ( + spectralValueLiteral spectralValueKind = iota + spectralValueCurrent + spectralValueRoot + spectralValueContext + spectralValueRegex + spectralValueUndefined + spectralValueFunction +) + +type spectralPathSegment struct { + name string + index *int64 +} + +type spectralPostfixKind uint8 + +const ( + spectralPostfixMatch spectralPostfixKind = iota + spectralPostfixIndexOf + spectralPostfixLength + spectralPostfixConstructorName + spectralPostfixIncludes +) + +type spectralPostfix struct { + kind spectralPostfixKind + regex *spectralRegex + search string + fromIndex *float64 + argument literal +} + +type spectralRegex struct { + source string + pattern string + flags string + compiled *regexp.Regexp +} + +type spectralValueExpr struct { + kind spectralValueKind + literal literal + context contextVarKind + regex *spectralRegex + function *functionExpr + segments []spectralPathSegment + postfix []spectralPostfix +} + +func (e *spectralValueExpr) String() string { + if e == nil { + return "" + } + var b strings.Builder + switch e.kind { + case spectralValueLiteral: + b.WriteString(e.literal.ToString()) + case spectralValueCurrent: + b.WriteByte('@') + case spectralValueRoot: + b.WriteByte('$') + case spectralValueContext: + b.WriteString(contextVariable{kind: e.context}.ToString()) + case spectralValueRegex: + b.WriteString(e.regex.source) + case spectralValueUndefined: + b.WriteString("void 0") + case spectralValueFunction: + b.WriteString(e.function.ToString()) + } + for _, segment := range e.segments { + if segment.index != nil { + b.WriteByte('[') + b.WriteString(strconv.FormatInt(*segment.index, 10)) + b.WriteByte(']') + } else if spectralDotMemberName(segment.name) { + b.WriteByte('.') + b.WriteString(segment.name) + } else { + b.WriteString("['") + b.WriteString(escapeString(segment.name)) + b.WriteString("']") + } + } + for _, postfix := range e.postfix { + switch postfix.kind { + case spectralPostfixMatch: + b.WriteString(".match(") + b.WriteString(postfix.regex.source) + b.WriteByte(')') + case spectralPostfixIndexOf: + b.WriteString(".indexOf('") + b.WriteString(escapeString(postfix.search)) + b.WriteByte('\'') + if postfix.fromIndex != nil { + b.WriteString(", ") + b.WriteString(strconv.FormatFloat(*postfix.fromIndex, 'f', -1, 64)) + } + b.WriteByte(')') + case spectralPostfixLength: + b.WriteString(".length") + case spectralPostfixConstructorName: + b.WriteString(".constructor.name") + case spectralPostfixIncludes: + b.WriteString(".includes(") + b.WriteString(postfix.argument.ToString()) + if postfix.fromIndex != nil { + b.WriteString(", ") + b.WriteString(strconv.FormatFloat(*postfix.fromIndex, 'f', -1, 64)) + } + b.WriteByte(')') + } + } + return b.String() +} + +func spectralDotMemberName(name string) bool { + if name == "$ref" { + return true + } + if name == "" { + return false + } + for i := 0; i < len(name); i++ { + ch := name[i] + if 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch >= 0x80 { + continue + } + if i > 0 && '0' <= ch && ch <= '9' { + continue + } + return false + } + return true +} + +func (p *JSONPath) parseSpectralExpression() (*spectralBoolExpr, error) { + expr, err := p.parseSpectralOr() + if err != nil { + return nil, err + } + if p.current >= len(p.tokens) || p.tokens[p.current].Token != token.BRACKET_RIGHT { + return nil, p.spectralFailure(p.current, "expected the end of the Spectral filter expression") + } + return expr, nil +} + +func (p *JSONPath) parseSpectralOr() (*spectralBoolExpr, error) { + left, err := p.parseSpectralAnd() + if err != nil { + return nil, err + } + for p.spectralNext(token.OR) { + p.current++ + right, err := p.parseSpectralAnd() + if err != nil { + return nil, err + } + left = p.spectralBool(spectralBoolOr, left, right, 0) + } + return left, nil +} + +func (p *JSONPath) parseSpectralAnd() (*spectralBoolExpr, error) { + left, err := p.parseSpectralUnary() + if err != nil { + return nil, err + } + for p.spectralNext(token.AND) { + p.current++ + right, err := p.parseSpectralUnary() + if err != nil { + return nil, err + } + left = p.spectralBool(spectralBoolAnd, left, right, 0) + } + return left, nil +} + +func (p *JSONPath) parseSpectralUnary() (*spectralBoolExpr, error) { + if p.spectralNext(token.NOT) { + p.current++ + child, err := p.parseSpectralUnary() + if err != nil { + return nil, err + } + return p.spectralBool(spectralBoolNot, child, nil, 0), nil + } + if p.spectralNext(token.PAREN_LEFT) { + p.current++ + child, err := p.parseSpectralOr() + if err != nil { + return nil, err + } + if !p.spectralNext(token.PAREN_RIGHT) { + return nil, p.spectralFailure(p.current, "expected ')' in Spectral filter expression") + } + p.current++ + return p.spectralBool(spectralBoolParen, child, nil, 0), nil + } + return p.parseSpectralComparison() +} + +func (p *JSONPath) parseSpectralComparison() (*spectralBoolExpr, error) { + left, err := p.parseSpectralValue() + if err != nil { + return nil, err + } + leftBool := p.spectralValueBool(left) + if p.current >= len(p.tokens) || !isSpectralComparison(p.tokens[p.current].Token) { + if len(left.postfix) > 0 && left.postfix[len(left.postfix)-1].kind == spectralPostfixMatch { + return leftBool, nil + } + return leftBool, nil + } + op := p.tokens[p.current].Token + if len(left.postfix) > 0 && left.postfix[len(left.postfix)-1].kind == spectralPostfixMatch { + return nil, p.spectralFailure(p.current, "match(...) results are only supported as truthy tests and cannot be compared or chained") + } + p.current++ + right, err := p.parseSpectralValue() + if err != nil { + return nil, err + } + if len(right.postfix) > 0 && right.postfix[len(right.postfix)-1].kind == spectralPostfixMatch { + return nil, p.spectralFailure(p.current-1, "match(...) results are only supported as truthy tests and cannot be compared or chained") + } + return p.spectralBool(spectralBoolCompare, leftBool, p.spectralValueBool(right), op), nil +} + +func (p *JSONPath) parseSpectralValue() (*spectralValueExpr, error) { + if p.current >= len(p.tokens) { + return nil, p.spectralFailure(p.current, "expected a Spectral value expression") + } + tok := p.tokens[p.current] + value := &spectralValueExpr{} + switch tok.Token { + case token.STRING_LITERAL, token.INTEGER, token.FLOAT, token.TRUE, token.FALSE, token.NULL: + lit, err := p.parseLiteral() + if err != nil { + return nil, err + } + value.kind = spectralValueLiteral + value.literal = *lit + case token.CURRENT: + value.kind = spectralValueCurrent + p.current++ + case token.ROOT, token.CONTEXT_ROOT: + value.kind = spectralValueRoot + p.current++ + case token.CONTEXT_PROPERTY, token.CONTEXT_PARENT, token.CONTEXT_PARENT_PROPERTY, token.CONTEXT_PATH, token.CONTEXT_INDEX: + value.kind = spectralValueContext + value.context = contextVarTokenMap[tok.Token] + p.current++ + case token.REGEX: + rx, err := compileSpectralRegex(tok.Literal) + if err != nil { + return nil, p.spectralFailure(p.current, err.Error()) + } + value.kind = spectralValueRegex + value.regex = rx + p.current++ + case token.FUNCTION: + function, err := p.parseFunctionExpr() + if err != nil { + return nil, err + } + value.kind = spectralValueFunction + value.function = function + case token.STRING: + if tok.Literal != "void" || p.current+1 >= len(p.tokens) || p.tokens[p.current+1].Token != token.INTEGER || p.tokens[p.current+1].Literal != "0" { + return nil, p.spectralFailure(p.current, "unsupported identifier; only the safe undefined expression 'void 0' is supported") + } + value.kind = spectralValueUndefined + p.current += 2 + default: + return nil, p.spectralFailure(p.current, "expected a literal, query, context value or regex literal") + } + + for p.current < len(p.tokens) { + if len(value.postfix) > 0 && (p.tokens[p.current].Token == token.CHILD || p.tokens[p.current].Token == token.BRACKET_LEFT) { + return nil, p.spectralFailure(p.current, "postfix results cannot be chained in Spectral compatibility mode") + } + if p.tokens[p.current].Token == token.BRACKET_LEFT { + if value.kind != spectralValueCurrent && value.kind != spectralValueRoot { + return nil, p.spectralFailure(p.current, "computed member access is not supported in Spectral compatibility mode") + } + if err := p.parseSpectralBracketSegment(value); err != nil { + return nil, err + } + continue + } + if p.tokens[p.current].Token != token.CHILD { + break + } + childAt := p.current + p.current++ + if p.current >= len(p.tokens) { + return nil, p.spectralFailure(childAt, "expected a member or method name after '.'") + } + nameToken := p.tokens[p.current] + nameTokenWidth := 1 + if nameToken.Token == token.ROOT { + if p.current+1 >= len(p.tokens) || p.tokens[p.current+1].Token != token.STRING || p.tokens[p.current+1].Literal != "ref" { + return nil, p.spectralFailure(p.current, "only $ref is supported as a '$'-prefixed member name") + } + nameToken.Literal = "$ref" + nameTokenWidth = 2 + } else if nameToken.Token != token.STRING && nameToken.Token != token.FUNCTION { + return nil, p.spectralFailure(p.current, "expected a member or method name after '.'") + } + name := nameToken.Literal + p.current += nameTokenWidth + if p.spectralNext(token.PAREN_LEFT) { + if err := p.parseSpectralMethod(value, name, childAt); err != nil { + return nil, err + } + continue + } + switch name { + case "length": + value.postfix = append(value.postfix, spectralPostfix{kind: spectralPostfixLength}) + case "constructor": + if !p.spectralNext(token.CHILD) || p.current+1 >= len(p.tokens) || p.tokens[p.current+1].Literal != "name" { + return nil, p.spectralFailure(childAt, "only the complete read-only constructor.name shape is supported") + } + p.current += 2 + value.postfix = append(value.postfix, spectralPostfix{kind: spectralPostfixConstructorName}) + case "__proto__", "prototype", "__defineGetter__", "__defineSetter__": + return nil, p.spectralFailure(childAt, "prototype and executable-object access is not supported") + default: + if len(value.postfix) > 0 { + return nil, p.spectralFailure(childAt, "postfix results cannot be chained in Spectral compatibility mode") + } + if value.kind != spectralValueCurrent && value.kind != spectralValueRoot { + return nil, p.spectralFailure(childAt, "member access is only supported on singular queries") + } + value.segments = append(value.segments, spectralPathSegment{name: name}) + } + } + return value, nil +} + +func (p *JSONPath) parseSpectralBracketSegment(value *spectralValueExpr) error { + start := p.current + p.current++ + if p.current >= len(p.tokens) { + return p.spectralFailure(start, "unterminated computed member access") + } + tok := p.tokens[p.current] + segment := spectralPathSegment{} + switch tok.Token { + case token.STRING_LITERAL: + if tok.Literal == "constructor" || tok.Literal == "__proto__" || tok.Literal == "prototype" { + return p.spectralFailure(p.current, "computed constructor and prototype access is not supported") + } + segment.name = tok.Literal + case token.INTEGER: + index, err := strconv.ParseInt(tok.Literal, 10, 64) + if err != nil { + return p.spectralFailure(p.current, "invalid singular query index") + } + segment.index = &index + default: + return p.spectralFailure(p.current, "only literal member names and indexes are supported in singular queries") + } + p.current++ + if !p.spectralNext(token.BRACKET_RIGHT) { + return p.spectralFailure(p.current, "expected ']' after singular query member") + } + p.current++ + value.segments = append(value.segments, segment) + return nil +} + +func (p *JSONPath) parseSpectralMethod(value *spectralValueExpr, name string, at int) error { + if len(value.postfix) > 0 { + return p.spectralFailure(at, "method results cannot be chained in Spectral compatibility mode") + } + p.current++ + switch name { + case "match": + if !p.spectralNext(token.REGEX) { + return p.spectralFailure(p.current, "match requires one regex literal argument") + } + rx, err := compileSpectralRegex(p.tokens[p.current].Literal) + if err != nil { + return p.spectralFailure(p.current, err.Error()) + } + p.current++ + if !p.spectralNext(token.PAREN_RIGHT) { + return p.spectralFailure(p.current, "match requires exactly one regex literal argument") + } + p.current++ + value.postfix = append(value.postfix, spectralPostfix{kind: spectralPostfixMatch, regex: rx}) + case "indexOf": + if !p.spectralNext(token.STRING_LITERAL) { + return p.spectralFailure(p.current, "indexOf requires a string argument") + } + postfix := spectralPostfix{kind: spectralPostfixIndexOf, search: p.tokens[p.current].Literal} + p.current++ + if p.spectralNext(token.COMMA) { + p.current++ + if p.current >= len(p.tokens) || (p.tokens[p.current].Token != token.INTEGER && p.tokens[p.current].Token != token.FLOAT) { + return p.spectralFailure(p.current, "indexOf fromIndex must be numeric") + } + from, err := strconv.ParseFloat(p.tokens[p.current].Literal, 64) + if err != nil { + return p.spectralFailure(p.current, "invalid indexOf fromIndex") + } + postfix.fromIndex = &from + p.current++ + } + if !p.spectralNext(token.PAREN_RIGHT) { + return p.spectralFailure(p.current, "indexOf accepts one string and an optional numeric fromIndex") + } + p.current++ + value.postfix = append(value.postfix, postfix) + case "includes": + argument, err := p.parseLiteral() + if err != nil { + return p.spectralFailure(p.current, "includes requires a scalar literal argument") + } + postfix := spectralPostfix{kind: spectralPostfixIncludes, argument: *argument} + if p.spectralNext(token.COMMA) { + p.current++ + if p.current >= len(p.tokens) || (p.tokens[p.current].Token != token.INTEGER && p.tokens[p.current].Token != token.FLOAT) { + return p.spectralFailure(p.current, "includes fromIndex must be numeric") + } + from, err := strconv.ParseFloat(p.tokens[p.current].Literal, 64) + if err != nil { + return p.spectralFailure(p.current, "invalid includes fromIndex") + } + postfix.fromIndex = &from + p.current++ + } + if !p.spectralNext(token.PAREN_RIGHT) { + return p.spectralFailure(p.current, "includes accepts one scalar and an optional numeric fromIndex") + } + p.current++ + value.postfix = append(value.postfix, postfix) + default: + return p.spectralFailure(at, fmt.Sprintf("unsupported method %q; supported methods are match, indexOf and includes", name)) + } + return nil +} + +func (p *JSONPath) spectralBool(kind spectralBoolKind, left, right *spectralBoolExpr, op token.Token) *spectralBoolExpr { + expr := &spectralBoolExpr{kind: kind, left: left, right: right, op: op} + if left != nil { + expr.usage = left.usage + expr.usesPropertyNameSelector = left.usesPropertyNameSelector + } + if right != nil { + expr.usage.property = expr.usage.property || right.usage.property + expr.usage.parent = expr.usage.parent || right.usage.parent + expr.usage.parentProperty = expr.usage.parentProperty || right.usage.parentProperty + expr.usage.path = expr.usage.path || right.usage.path + expr.usage.index = expr.usage.index || right.usage.index + expr.usesPropertyNameSelector = expr.usesPropertyNameSelector || right.usesPropertyNameSelector + } + return expr +} + +func (p *JSONPath) spectralValueBool(value *spectralValueExpr) *spectralBoolExpr { + expr := &spectralBoolExpr{kind: spectralBoolValue, value: value} + switch value.kind { + case spectralValueContext: + expr.usage.mark(value.context) + case spectralValueFunction: + value.function.collectContextVarUsage(&expr.usage) + expr.usesPropertyNameSelector = value.function.hasPropertyNameReferences() + } + return expr +} + +func (p *JSONPath) spectralNext(tok token.Token) bool { + return p.current < len(p.tokens) && p.tokens[p.current].Token == tok +} + +func (p *JSONPath) spectralFailure(at int, message string) error { + if len(p.tokens) == 0 { + return fmt.Errorf("spectral compatibility mode: %s", message) + } + if at >= len(p.tokens) { + at = len(p.tokens) - 1 + } + return p.parseFailure(&p.tokens[at], "Spectral compatibility mode: "+message) +} + +func isSpectralComparison(tok token.Token) bool { + switch tok { + case token.EQ, token.NE, token.STRICT_EQ, token.STRICT_NE, token.GT, token.GE, token.LT, token.LE: + return true + default: + return false + } +} + +func compileSpectralRegex(source string) (*spectralRegex, error) { + if len(source) < 2 || source[0] != '/' { + return nil, fmt.Errorf("invalid regex literal") + } + closing := -1 + escaped := false + inClass := false + for i := 1; i < len(source); i++ { + ch := source[i] + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == '[' { + inClass = true + } else if ch == ']' { + inClass = false + } else if ch == '/' && !inClass { + closing = i + break + } + } + if closing < 0 { + return nil, fmt.Errorf("unterminated regex literal") + } + pattern := source[1:closing] + flags := source[closing+1:] + seen := [256]bool{} + for i := 0; i < len(flags); i++ { + flag := flags[i] + if seen[flag] { + return nil, fmt.Errorf("duplicate regex flag %q", flag) + } + seen[flag] = true + } + var modes strings.Builder + for i := 0; i < len(flags); i++ { + flag := flags[i] + switch flag { + case 'i', 'm', 's': + modes.WriteByte(flag) + case 'g': + case 'u': + if strings.Contains(pattern, `\u{`) { + return nil, fmt.Errorf("regex u flag code-point escapes are not supported by the safe RE2 evaluator") + } + case 'd', 'v', 'y': + return nil, fmt.Errorf("regex flag %q is not supported by the safe RE2 evaluator", flag) + default: + return nil, fmt.Errorf("unknown regex flag %q", flag) + } + } + if err := validateSpectralRegexPattern(pattern); err != nil { + return nil, err + } + pattern = strings.ReplaceAll(pattern, `\/`, `/`) + compiledPattern := pattern + if modes.Len() > 0 { + compiledPattern = "(?" + modes.String() + ")" + pattern + } + compiled, err := regexp.Compile(compiledPattern) + if err != nil { + return nil, fmt.Errorf("invalid or unsupported regex: %v", err) + } + return &spectralRegex{source: source, pattern: pattern, flags: flags, compiled: compiled}, nil +} + +func validateSpectralRegexPattern(pattern string) error { + inClass := false + for i := 0; i < len(pattern); i++ { + switch pattern[i] { + case '\\': + if i+1 >= len(pattern) { + return fmt.Errorf("malformed regex escape") + } + if pattern[i+1] >= '1' && pattern[i+1] <= '9' { + return fmt.Errorf("regex backreferences are not supported by the safe RE2 evaluator") + } + i++ + case '[': + if !inClass { + inClass = true + } + case ']': + if inClass { + inClass = false + } + case '(': + if inClass || i+2 >= len(pattern) || pattern[i+1] != '?' { + continue + } + if pattern[i+2] == '=' || pattern[i+2] == '!' || + (pattern[i+2] == '<' && i+3 < len(pattern) && (pattern[i+3] == '=' || pattern[i+3] == '!')) { + return fmt.Errorf("regex lookaround is not supported by the safe RE2 evaluator") + } + } + } + return nil +} diff --git a/pkg/jsonpath/spectral_fuzz_test.go b/pkg/jsonpath/spectral_fuzz_test.go new file mode 100644 index 0000000..57b21ff --- /dev/null +++ b/pkg/jsonpath/spectral_fuzz_test.go @@ -0,0 +1,38 @@ +package jsonpath + +import ( + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/jsonpath/pkg/jsonpath/token" +) + +func FuzzSpectralRegexScanner(f *testing.F) { + for _, seed := range []string{`x`, `\/`, `[a-z/]`, `\d+`, `(?=x)`, `(a)\1`, `😀`} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, pattern string) { + if len(pattern) > 4096 { + t.Skip() + } + expression := `$[?(@property.match(/` + pattern + `/))]` + _ = token.NewTokenizer(expression, config.WithSpectralCompatibility()).Tokenize() + }) +} + +func FuzzSpectralFilterCompilation(f *testing.F) { + for _, seed := range []string{ + `@property === 'get'`, + `@ && @.enum && @.type`, + `!@property.match(/x/i)`, + `@.enum.constructor.name === 'Array'`, + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, filter string) { + if len(filter) > 4096 { + t.Skip() + } + _, _ = NewPath(`$[?(`+filter+`)]`, config.WithSpectralCompatibility()) + }) +} diff --git a/pkg/jsonpath/spectral_test.go b/pkg/jsonpath/spectral_test.go new file mode 100644 index 0000000..8fa8af7 --- /dev/null +++ b/pkg/jsonpath/spectral_test.go @@ -0,0 +1,413 @@ +package jsonpath + +import ( + "regexp" + "strings" + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "go.yaml.in/yaml/v4" +) + +func parseSpectralTestYAML(t *testing.T, source string) *yaml.Node { + t.Helper() + var document yaml.Node + if err := yaml.Unmarshal([]byte(source), &document); err != nil { + t.Fatalf("parse YAML: %v", err) + } + return &document +} + +func querySpectral(t *testing.T, expression, source string, extra ...config.Option) []*yaml.Node { + t.Helper() + options := append([]config.Option{config.WithSpectralCompatibility()}, extra...) + path, err := NewPath(expression, options...) + if err != nil { + t.Fatalf("compile %s: %v", expression, err) + } + return path.Query(parseSpectralTestYAML(t, source)) +} + +func nodeValues(nodes []*yaml.Node) []string { + values := make([]string, len(nodes)) + for i, node := range nodes { + values[i] = node.Value + } + return values +} + +func TestSpectralIssue936Selector(t *testing.T) { + source := ` +paths: + /pets: + get: {} + /openapi.json: + get: {} + /nested/openapi.json: + get: {} + /openapi.json/pets: + get: {} +` + nodes := querySpectral(t, `$.paths[?(@property && !@property.match(/\/openapi\.json/))]~`, source) + if got, want := nodeValues(nodes), []string{"/pets"}; !equalStrings(got, want) { + t.Fatalf("selected paths = %v, want %v", got, want) + } +} + +func TestSpectralResponseCodeMatchStringifiesMappingKeys(t *testing.T) { + source := ` +paths: + /pets: + responses: + 200: {description: ok} + 404: {description: missing} + default: {description: fallback} +` + nodes := querySpectral(t, `$.paths[*].responses[?(@property.match(/^(4|5)/))]`, source) + if len(nodes) != 1 || nodes[0].Kind != yaml.MappingNode { + t.Fatalf("selected %d nodes, want one response object", len(nodes)) + } +} + +func TestSpectralIndexOfAndParentSelector(t *testing.T) { + source := ` +content: + application/json: {schema: {type: object}} + application/xml: {schema: {type: object}} + text/json-seq: {schema: {type: object}} +` + nodes := querySpectral(t, `$.content[?(@property.indexOf('json') === -1)]^`, source) + if len(nodes) != 1 || nodes[0].Kind != yaml.MappingNode { + t.Fatalf("selected %d nodes, want the content mapping once", len(nodes)) + } +} + +func TestSpectralConstructorNameAndTruthiness(t *testing.T) { + source := ` +schemas: + one: {enum: [a, b]} + two: {enum: value} + three: {type: string} +` + nodes := querySpectral(t, `$..[?(@property !== 'properties' && @.enum && @.enum.constructor.name === 'Array')]`, source) + if len(nodes) != 1 || nodes[0].Kind != yaml.MappingNode { + t.Fatalf("selected %d nodes, want the mapping with an array enum", len(nodes)) + } +} + +func TestSpectralOfficialArrayIncludesAndUndefined(t *testing.T) { + source := ` +schemas: + list: {type: [string, array], example: []} + scalar: {type: string} + implicit: {type: string, default: null} +` + nodes := querySpectral(t, `$..[?(@ && @.type && @.type.constructor.name === 'Array' && @.type.includes('array'))]`, source) + if len(nodes) != 1 { + t.Fatalf("array includes selected %d nodes, want one", len(nodes)) + } + nodes = querySpectral(t, `$..[?(@ && (@.example !== void 0 || @.default !== void 0))]`, source) + if len(nodes) != 2 { + t.Fatalf("undefined checks selected %d nodes, want two", len(nodes)) + } +} + +func TestSpectralLengthAndUTF16Indexing(t *testing.T) { + source := ` +values: + - {text: "a😀b", list: [1, 2]} + - {text: "ab", list: []} +` + nodes := querySpectral(t, `$.values[?(@.text.length === 4 && @.text.indexOf('b') === 3 && @.list.length > 0)]`, source) + if len(nodes) != 1 { + t.Fatalf("selected %d nodes, want one UTF-16-compatible value", len(nodes)) + } +} + +func TestSpectralPropertyTypingForSequenceIndexes(t *testing.T) { + source := "values: [zero, one, two, three]\n" + if got := nodeValues(querySpectral(t, `$.values[?(@property === 3)]`, source)); !equalStrings(got, []string{"three"}) { + t.Fatalf("numeric property result = %v", got) + } + if got := querySpectral(t, `$.values[?(@property === '3')]`, source); len(got) != 0 { + t.Fatalf("string property selected %d nodes, want none", len(got)) + } + if got := querySpectral(t, `$.values[?(@property.match(/3/))]`, source); len(got) != 1 || got[0].Value != "three" { + t.Fatalf("Nimma-compatible string method selected %v, want three", nodeValues(got)) + } + if got := querySpectral(t, `$.values[?(!@property.match(/3/))]`, source); len(got) != 3 { + t.Fatalf("negated Nimma-compatible string method selected %d nodes, want three", len(got)) + } +} + +func TestSpectralParentPropertyTypingForSequenceIndexes(t *testing.T) { + source := ` +groups: + - items: [a] + - items: [b] +` + nodes := querySpectral(t, `$.groups[0][?(@parentProperty === 0)]`, source) + if len(nodes) != 1 || nodes[0].Kind != yaml.SequenceNode { + t.Fatalf("numeric parent property selected %d nodes, want the items sequence", len(nodes)) + } + if got := querySpectral(t, `$.groups[0][?(@parentProperty === '0')]`, source); len(got) != 0 { + t.Fatalf("string parent property selected %d nodes, want none", len(got)) + } + if got := querySpectral(t, `$.groups[0][?(@parentProperty.match(/^0$/))]`, source); len(got) != 1 { + t.Fatalf("Nimma-compatible parent-property method selected %d nodes, want one", len(got)) + } +} + +func TestSpectralPreservesRFCFunctionExpressions(t *testing.T) { + source := ` +values: + - {name: alpha} + - {name: xy} +` + nodes := querySpectral(t, `$.values[?(length(@.name) > 3 && match(@.name, 'a.*'))]`, source) + if len(nodes) != 1 { + t.Fatalf("RFC functions selected %d nodes, want one", len(nodes)) + } +} + +func TestSpectralRegexFlagsAndEscapes(t *testing.T) { + source := "values: [FoO, bar/baz, abc123]\n" + tests := []struct { + expression string + want string + }{ + {`$.values[?(@.match(/foo/i))]`, "FoO"}, + {`$.values[?(@.match(/bar\/baz/))]`, "bar/baz"}, + {`$.values[?(@.match(/[a-z]+\d+/))]`, "abc123"}, + } + for _, test := range tests { + if got := nodeValues(querySpectral(t, test.expression, source)); !equalStrings(got, []string{test.want}) { + t.Errorf("%s selected %v, want %q", test.expression, got, test.want) + } + } +} + +func TestSpectralStrictStructuralEqualityUsesIdentity(t *testing.T) { + source := ` +values: + - {a: [1, 2], b: [1, 2]} + - &same {a: &items [1, 2], b: *items} +` + nodes := querySpectral(t, `$.values[?(@.a === @.b)]`, source) + if len(nodes) > 1 { + t.Fatalf("strict equality selected %d nodes, want at most the YAML alias identity case", len(nodes)) + } + if got := querySpectral(t, `$.values[?(@.a == @.b)]`, source); len(got) != 0 { + t.Fatalf("JavaScript loose reference equality selected %d distinct arrays", len(got)) + } +} + +func TestSpectralDefaultAndStrictModesRemainIsolated(t *testing.T) { + expression := `$.paths[?(@property.match(/x/))]~` + if _, err := NewPath(expression); err == nil { + t.Fatal("default mode unexpectedly accepted Spectral method syntax") + } + if _, err := NewPath(expression, config.WithStrictRFC9535()); err == nil { + t.Fatal("strict mode unexpectedly accepted Spectral method syntax") + } + orders := [][]config.Option{ + {config.WithStrictRFC9535(), config.WithSpectralCompatibility()}, + {config.WithSpectralCompatibility(), config.WithStrictRFC9535()}, + } + for _, options := range orders { + if _, err := NewPath(`$`, options...); err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("conflicting options error = %v", err) + } + } + + document := parseSpectralTestYAML(t, "- [one]\n") + defaultPath, err := NewPath(`$[0][?(@parentProperty == '0')]`) + if err != nil { + t.Fatalf("compile default context query: %v", err) + } + if got := defaultPath.Query(document); len(got) != 0 { + t.Fatalf("default context behavior changed: selected %d nodes", len(got)) + } + if got := querySpectral(t, `$[0][?(@parentProperty === 0)]`, "- [one]\n"); len(got) != 1 { + t.Fatalf("Spectral parent property typing selected %d nodes, want one", len(got)) + } +} + +func TestSpectralRejectsUnsafeAndUnsupportedSyntax(t *testing.T) { + tests := []struct { + expression string + contains string + }{ + {`$[?(@.constructor.constructor('return 1')())]`, "constructor.name"}, + {`$[?(@.__proto__)]`, "prototype"}, + {`$[?(@.__defineGetter__)]`, "prototype"}, + {`$[?(@.__defineSetter__)]`, "prototype"}, + {`$[?(@['constructor'])]`, "computed constructor"}, + {`$[?(@property.startsWith('x'))]`, "unsupported method"}, + {`$[?(@.value = 1)]`, "assignments are not supported"}, + {`$[?(@.value; true)]`, "statement separators are not supported"}, + {`$[?(function() {})]`, "function declarations"}, + {`$[?(@property.match(/(a)\1/))]`, "backreferences"}, + {`$[?(@property.match(/a(?=b)/))]`, "lookaround"}, + {`$[?(@property.match(/x/dd))]`, "duplicate regex flag"}, + {`$[?(@property.match(/x/y))]`, "regex flag"}, + } + for _, test := range tests { + _, err := NewPath(test.expression, config.WithSpectralCompatibility()) + if err == nil || !strings.Contains(err.Error(), test.contains) || !strings.Contains(err.Error(), "line 1, column") { + t.Errorf("%s error = %v, want substring %q", test.expression, err, test.contains) + } + } +} + +func TestSpectralRejectsMalformedPostfixAndRegexForms(t *testing.T) { + expressions := []string{ + `$[?(@property.match('x'))]`, + `$[?(@property.match(/x/, /y/))]`, + `$[?(@property.match(/x/) === true)]`, + `$[?(@property.match(/x/).length)]`, + `$[?(@property.indexOf())]`, + `$[?(@property.indexOf('x', '1'))]`, + `$[?(@property.includes())]`, + `$[?(@property.includes('x', '1'))]`, + `$[?(@property['length'])]`, + `$[?(@.constructor)]`, + `$[?(@.constructor.name.value)]`, + `$[?(@.unknown())]`, + `$[?(@property.match(/x/u).length)]`, + `$[?(@property.match(/\u{1F600}/u))]`, + `$[?(@property.match(/x/v))]`, + `$[?(@property.match(/x/z))]`, + `$[?(@property.match(/[a-/))]`, + } + for _, expression := range expressions { + if _, err := NewPath(expression, config.WithSpectralCompatibility()); err == nil { + t.Errorf("expected compilation failure for %s", expression) + } + } +} + +func TestSpectralRegexIsCompiledIntoReusableAST(t *testing.T) { + path, err := NewPath(`$.values[?(@property.match(/^x/i))]`, config.WithSpectralCompatibility()) + if err != nil { + t.Fatal(err) + } + compiled := spectralCompiledRegexes(path) + if len(compiled) != 1 || compiled[0] == nil { + t.Fatalf("compiled regexes = %v", compiled) + } + document := parseSpectralTestYAML(t, "values: {x: true, y: false}\n") + _ = path.Query(document) + _ = path.Query(document) + after := spectralCompiledRegexes(path) + if len(after) != 1 || after[0] != compiled[0] { + t.Fatal("query replaced or recompiled the cached regex") + } +} + +func TestSpectralLazyContextTrackingMatchesEager(t *testing.T) { + expression := `$.values[?(@property === 1 || @.name.length > 3)]` + source := "values: [{name: one}, {name: two}, {name: three}]\n" + eager := querySpectral(t, expression, source) + lazy := querySpectral(t, expression, source, config.WithLazyContextTracking()) + if got, want := nodeValues(lazy), nodeValues(eager); !equalStrings(got, want) { + t.Fatalf("lazy results = %v, eager = %v", got, want) + } +} + +func TestSpectralPinnedOfficialFilterSelectorsCompile(t *testing.T) { + expressions := []string{ + `$..[?(@ && @.type=="array")]`, + `$..[?(@ && @.type && @.type.constructor.name === "Array" && @.type.includes("array"))]`, + `$..[?(@property !== 'properties' && @.enum && @.enum.constructor.name === 'Array')]`, + `$..[?(@property === '$ref')]`, + `$..[?(@ && @.enum && @.type)]`, + `$.definitions[?(@.discriminator)]`, + `$..parameters[?(@ && @.in)]`, + `$..definitions..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]`, + `$..responses..[?(@ && @.schema && @.examples)]`, + `$.components.parameters[?(@ && @.in)]`, + `$..content..[?(@ && @.schema && (@.example !== void 0 || @.examples))]`, + `$.components.schemas..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]`, + `$.components.messageTraits[?(@.schemaFormat === void 0)].payload.default^`, + `$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload`, + } + for _, expression := range expressions { + path, err := NewPath(expression, config.WithSpectralCompatibility()) + if err != nil { + t.Errorf("compile %s: %v", expression, err) + continue + } + if path.String() == "" { + t.Errorf("empty round trip for %s", expression) + } + } +} + +func TestSpectralRoundTripPreservesBracketMemberSyntax(t *testing.T) { + expression := `$[?(@['x-example'] && @['content-type'] && @.$ref)]` + path, err := NewPath(expression, config.WithSpectralCompatibility()) + if err != nil { + t.Fatalf("compile %s: %v", expression, err) + } + + rendered := path.String() + if rendered != expression { + t.Fatalf("round trip = %q, want %q", rendered, expression) + } + _, err = NewPath(rendered, config.WithSpectralCompatibility()) + if err != nil { + t.Fatalf("compile round trip %s: %v", rendered, err) + } +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func spectralCompiledRegexes(path *JSONPath) []*regexp.Regexp { + var result []*regexp.Regexp + var walkExpr func(*spectralBoolExpr) + walkExpr = func(expression *spectralBoolExpr) { + if expression == nil { + return + } + if expression.value != nil { + if expression.value.regex != nil { + result = append(result, expression.value.regex.compiled) + } + for _, postfix := range expression.value.postfix { + if postfix.regex != nil { + result = append(result, postfix.regex.compiled) + } + } + } + walkExpr(expression.left) + walkExpr(expression.right) + } + for _, segment := range path.ast.segments { + var inner *innerSegment + if segment.child != nil { + inner = segment.child + } else { + inner = segment.descendant + } + if inner == nil { + continue + } + for _, selector := range inner.selectors { + if selector.filter.present() { + walkExpr(selector.filter.spectralExpression) + } + } + } + return result +} diff --git a/pkg/jsonpath/testdata/spectral/corpus.json b/pkg/jsonpath/testdata/spectral/corpus.json new file mode 100644 index 0000000..c64dfb8 --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/corpus.json @@ -0,0 +1,349 @@ +{ + "baseline": { + "spectralCommit": "f08df7bb4c62015391cc2b0d51cdaecf30ac109c", + "spectralCoreVersion": "1.23.1", + "nimmaVersion": "0.2.3", + "jsonPathPlusVersion": "10.3.0", + "unsafe": false, + "generatedBy": "npm ci && npm run generate" + }, + "cases": [ + { + "name": "vacuum-issue-936", + "source": "https://github.com/daveshanley/vacuum/issues/936", + "expression": "$.paths[?(@property && !@property.match(/\\/openapi\\.json/))]~", + "input": { + "paths": { + "/pets": {}, + "/openapi.json": {}, + "/nested/openapi.json": {}, + "/health": {} + } + }, + "engine": "jsonpath-plus", + "expectedPaths": [ + "$['paths']['/health']", + "$['paths']['/pets']" + ], + "extension": true + }, + { + "name": "numeric-response-keys", + "source": "https://github.com/stoplightio/spectral/blob/f08df7bb4c62015391cc2b0d51cdaecf30ac109c/packages/ruleset-migrator/src/__tests__/__fixtures__/apisyouwonthate/ruleset.yaml#L126", + "expression": "$.paths.[*].responses[?(@property.match(/^(4|5)/))].content.*~", + "input": { + "paths": { + "/pets": { + "responses": { + "200": { + "description": "ok" + }, + "404": { + "description": "missing", + "content": { + "application/problem+json": {}, + "application/json": {} + } + }, + "500": { + "description": "error", + "content": { + "application/problem+xml": {} + } + }, + "default": { + "description": "fallback" + } + } + } + } + }, + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['responses']['404']['content']['application/json']", + "$['paths']['/pets']['responses']['404']['content']['application/problem+json']", + "$['paths']['/pets']['responses']['500']['content']['application/problem+xml']" + ], + "extension": true + }, + { + "name": "index-of-parent", + "source": "https://github.com/stoplightio/spectral/blob/f08df7bb4c62015391cc2b0d51cdaecf30ac109c/packages/ruleset-migrator/src/__tests__/__fixtures__/apisyouwonthate/ruleset.yaml#L114", + "expression": "$.paths.[*].requestBody.content[?(@property.indexOf('json') === -1)]^", + "input": { + "paths": { + "/pets": { + "requestBody": { + "content": { + "application/json": {}, + "application/xml": {} + } + } + }, + "/health": { + "requestBody": { + "content": { + "application/yaml": {} + } + } + } + } + }, + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/health']['requestBody']['content']", + "$['paths']['/pets']['requestBody']['content']" + ], + "extension": true + }, + { + "name": "constructor-name-array", + "source": "Spectral OAS ruleset 1.22.6", + "expression": "$..[?(@property !== 'properties' && @.enum && @.enum.constructor.name === 'Array')]", + "input": { + "schemas": { + "array": { + "enum": [ + "a", + "b" + ] + }, + "scalar": { + "enum": "a" + }, + "properties": { + "enum": [ + "ignored" + ] + } + } + }, + "engine": "nimma", + "expectedPaths": [ + "$['schemas']['array']" + ], + "extension": true + }, + { + "name": "official-array-includes", + "source": "Spectral OAS ruleset 1.22.6", + "expression": "$..[?(@ && @.type && @.type.constructor.name === 'Array' && @.type.includes('array'))]", + "input": { + "schemas": { + "list": { + "type": [ + "string", + "array" + ] + }, + "scalar": { + "type": "array" + } + } + }, + "engine": "nimma", + "expectedPaths": [ + "$['schemas']['list']" + ], + "extension": true + }, + { + "name": "official-void-zero", + "source": "Spectral OAS ruleset 1.22.6", + "expression": "$..[?(@ && (@.example !== void 0 || @.default !== void 0))]", + "input": { + "schemas": { + "example": { + "example": 0 + }, + "default": { + "default": null + }, + "absent": { + "type": "string" + } + } + }, + "engine": "nimma", + "expectedPaths": [ + "$['schemas']", + "$['schemas']['default']", + "$['schemas']['example']" + ], + "extension": true + }, + { + "name": "sequence-property-number", + "source": "JSONPath Plus property semantics", + "expression": "$.values[?(@property === 3)]", + "input": { + "values": [ + "zero", + "one", + "two", + "three" + ] + }, + "engine": "nimma", + "expectedPaths": [ + "$['values'][3]" + ], + "extension": true + }, + { + "name": "utf16-length-and-index", + "source": "ECMAScript string semantics", + "expression": "$.values[?(@.text.length === 4 && @.text.indexOf('b') === 3)]", + "input": { + "values": [ + { + "text": "a😀b" + }, + { + "text": "abc" + } + ] + }, + "engine": "nimma", + "expectedPaths": [ + "$['values'][0]" + ], + "extension": true + }, + { + "name": "strict-structural-identity", + "source": "ECMAScript strict equality semantics", + "expression": "$.values[?(@.a === @.b)]", + "input": { + "values": [ + { + "a": [ + 1, + 2 + ], + "b": [ + 1, + 2 + ] + } + ] + }, + "engine": "nimma", + "expectedPaths": [], + "extension": true + }, + { + "name": "regex-flags-and-class", + "source": "Spectral public ruleset pattern", + "expression": "$.values[?(@property.match(/^[a-z/]+\\d+$/i))]", + "input": { + "values": { + "12": true, + "ABC/123": true, + "nope": true + } + }, + "engine": "jsonpath-plus", + "expectedPaths": [ + "$['values']['ABC/123']" + ], + "extension": true + }, + { + "name": "loose-scalar-equality", + "source": "ECMAScript loose equality semantics", + "expression": "$.values[?(@.a == @.b)]", + "input": { + "values": [ + { + "a": "200", + "b": 200 + }, + { + "a": false, + "b": 0 + }, + { + "a": true, + "b": 1 + }, + { + "a": "abc", + "b": 0 + } + ] + }, + "engine": "nimma", + "expectedPaths": [ + "$['values'][0]", + "$['values'][1]", + "$['values'][2]" + ], + "extension": false + }, + { + "name": "sequence-property-regex-coercion", + "source": "Pinned Nimma sequence property method semantics", + "expression": "$.values[?(!@property.match(/3/))]", + "input": { + "values": [ + "zero", + "one", + "two", + "three" + ] + }, + "engine": "nimma", + "expectedPaths": [ + "$['values'][0]", + "$['values'][1]", + "$['values'][2]" + ], + "extension": true + }, + { + "name": "sequence-property-index-of", + "source": "Pinned Nimma sequence property method semantics", + "expression": "$.values[?(@property.indexOf('3') === -1)]", + "input": { + "values": [ + "zero", + "one", + "two", + "three" + ] + }, + "engine": "nimma", + "expectedPaths": [ + "$['values'][0]", + "$['values'][1]", + "$['values'][2]" + ], + "extension": true + }, + { + "name": "public-dot-property-name-selector", + "source": "https://github.com/Cimpress/cimpress-api-style-guide/blob/873a6aa7d37933a3c7a0f8bfd7a6018b63c5b3a7/spectral/cimpress.spectral.yaml#L149", + "expression": "$.definitions.~", + "input": { + "definitions": { + "PetPayload": { + "nested": { + "deep": {} + } + }, + "User": {} + } + }, + "engine": "jsonpath-plus", + "expectedPaths": [ + "$['definitions']", + "$['definitions']['PetPayload']", + "$['definitions']['PetPayload']['nested']", + "$['definitions']['PetPayload']['nested']['deep']", + "$['definitions']['User']" + ], + "extension": true + } + ] +} diff --git a/pkg/jsonpath/testdata/spectral/generate/collect-official.mjs b/pkg/jsonpath/testdata/spectral/generate/collect-official.mjs new file mode 100644 index 0000000..fffcd2e --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/generate/collect-official.mjs @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const commit = 'f08df7bb4c62015391cc2b0d51cdaecf30ac109c'; +const repository = 'https://github.com/stoplightio/spectral'; +const license = 'Apache-2.0'; +const files = [ + 'packages/rulesets/src/oas/index.ts', + 'packages/rulesets/src/asyncapi/index.ts', +]; +const base = `https://raw.githubusercontent.com/stoplightio/spectral/${commit}/`; +const selectors = new Set(); + +function decodeString(quote, body) { + if (quote === '"') { + return JSON.parse(`"${body}"`); + } + return body + .replaceAll(`\\'`, `'`) + .replaceAll('\\"', '"') + .replaceAll('\\\\', '\\'); +} + +function collectStrings(source) { + const values = []; + for (let index = 0; index < source.length; ) { + if (source.startsWith('//', index)) { + index = source.indexOf('\n', index + 2); + if (index === -1) break; + continue; + } + if (source.startsWith('/*', index)) { + const end = source.indexOf('*/', index + 2); + index = end === -1 ? source.length : end + 2; + continue; + } + const quote = source[index]; + if (quote !== '"' && quote !== "'" && quote !== '`') { + index++; + continue; + } + const start = ++index; + let body = ''; + while (index < source.length && source[index] !== quote) { + if (source[index] === '\\' && index + 1 < source.length) { + body += source[index] + source[index + 1]; + index += 2; + } else { + body += source[index++]; + } + } + if (quote !== '`') { + values.push(decodeString(quote, body)); + } + index = index < source.length ? index + 1 : source.length; + } + return values; +} + +for (const file of files) { + const response = await fetch(base + file); + if (!response.ok) { + throw new Error(`failed to fetch ${file}: ${response.status} ${response.statusText}`); + } + const source = await response.text(); + for (const value of collectStrings(source)) { + if (value.startsWith('$') && !value.startsWith('${')) { + selectors.add(value); + } + } +} + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const outputPath = path.resolve(directory, '..', 'official-selectors.json'); +await fs.writeFile(outputPath, `${JSON.stringify({ repository, license, commit, files, selectors: [...selectors].sort() }, null, 2)}\n`); diff --git a/pkg/jsonpath/testdata/spectral/generate/collect_public.go b/pkg/jsonpath/testdata/spectral/generate/collect_public.go new file mode 100644 index 0000000..1205510 --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/generate/collect_public.go @@ -0,0 +1,277 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "go.yaml.in/yaml/v4" +) + +type publicSource struct { + Name string `json:"name"` + Repository string `json:"repository"` + License string `json:"license"` + Commit string `json:"commit"` + Path string `json:"path"` +} + +type publicSelector struct { + Expression string `json:"expression"` + Sources []string `json:"sources"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +type publicInventory struct { + GeneratedBy string `json:"generatedBy"` + Sources []publicSource `json:"sources"` + Selectors []publicSelector `json:"selectors"` +} + +var publicSources = []publicSource{ + { + Name: "Harbor", + Repository: "https://github.com/goharbor/harbor", + License: "Apache-2.0", + Commit: "691eb2fe36f4043f040c5d18ef365511042a917b", + Path: ".spectral.yaml", + }, + { + Name: "Kibana", + Repository: "https://github.com/elastic/kibana", + License: "NOASSERTION", + Commit: "aa6146fd7a49bf4291921c51b35bce3a7417dc01", + Path: "oas_docs/linters/.spectral.yaml", + }, + { + Name: "Postman Knowledge Base", + Repository: "https://github.com/postman-open-technologies/knowledge-base", + License: "Apache-2.0", + Commit: "2dd6098ab1d63dd9dc4ea52f9c6f6dc9c64fbab2", + Path: "spectral/postman/http-status-codes.spectral.yaml", + }, + { + Name: "Cimpress API Style Guide", + Repository: "https://github.com/Cimpress/cimpress-api-style-guide", + License: "NOASSERTION", + Commit: "873a6aa7d37933a3c7a0f8bfd7a6018b63c5b3a7", + Path: "spectral/cimpress.spectral.yaml", + }, +} + +func main() { + client := &http.Client{Timeout: 30 * time.Second} + selectors := make(map[string]map[string]struct{}) + for _, source := range publicSources { + document, err := fetchPublicRuleset(client, source) + if err != nil { + fatal(err) + } + var expressions []string + collectGivenSelectors(&document, &expressions) + aliases := collectAliasDefinitions(&document) + if len(expressions) == 0 { + fatal(fmt.Errorf("%s contains no given selectors", source.Name)) + } + for _, expression := range expressions { + for _, expanded := range expandAliasExpression(expression, aliases, nil) { + if selectors[expanded] == nil { + selectors[expanded] = make(map[string]struct{}) + } + selectors[expanded][source.Name] = struct{}{} + } + } + } + + inventory := publicInventory{ + GeneratedBy: "npm run collect-public", + Sources: publicSources, + Selectors: make([]publicSelector, 0, len(selectors)), + } + for expression, sourceNames := range selectors { + entry := publicSelector{Expression: expression, Status: "supported"} + for sourceName := range sourceNames { + entry.Sources = append(entry.Sources, sourceName) + } + sort.Strings(entry.Sources) + if _, err := jsonpath.NewPath(expression, config.WithSpectralCompatibility()); err != nil { + entry.Status = "unsupported" + entry.Reason = err.Error() + } + inventory.Selectors = append(inventory.Selectors, entry) + } + sort.Slice(inventory.Selectors, func(i, j int) bool { + return inventory.Selectors[i].Expression < inventory.Selectors[j].Expression + }) + + encoded, err := json.MarshalIndent(inventory, "", " ") + if err != nil { + fatal(fmt.Errorf("encode inventory: %w", err)) + } + _, filename, _, ok := runtime.Caller(0) + if !ok { + fatal(fmt.Errorf("resolve collector path")) + } + output := filepath.Join(filepath.Dir(filename), "..", "public-selectors.json") + if err := os.WriteFile(output, append(encoded, '\n'), 0o644); err != nil { + fatal(fmt.Errorf("write inventory: %w", err)) + } +} + +func fetchPublicRuleset(client *http.Client, source publicSource) (yaml.Node, error) { + repository := strings.TrimPrefix(source.Repository, "https://github.com/") + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repository, source.Commit, source.Path) + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return yaml.Node{}, fmt.Errorf("build %s request: %w", source.Name, err) + } + request.Header.Set("User-Agent", "pb33f-jsonpath-spectral-corpus") + response, err := client.Do(request) + if err != nil { + return yaml.Node{}, fmt.Errorf("fetch %s: %w", source.Name, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return yaml.Node{}, fmt.Errorf("fetch %s: %s", source.Name, response.Status) + } + body, err := io.ReadAll(response.Body) + if err != nil { + return yaml.Node{}, fmt.Errorf("read %s: %w", source.Name, err) + } + var document yaml.Node + if err := yaml.Unmarshal(body, &document); err != nil { + return yaml.Node{}, fmt.Errorf("parse %s: %w", source.Name, err) + } + return document, nil +} + +func collectGivenSelectors(node *yaml.Node, selectors *[]string) { + if node == nil { + return + } + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + key, value := node.Content[i], node.Content[i+1] + if key.Value == "given" { + collectGivenValue(value, selectors) + } + collectGivenSelectors(value, selectors) + } + return + } + for _, child := range node.Content { + collectGivenSelectors(child, selectors) + } +} + +func collectGivenValue(node *yaml.Node, selectors *[]string) { + switch node.Kind { + case yaml.ScalarNode: + if node.Value != "" { + *selectors = append(*selectors, node.Value) + } + case yaml.SequenceNode: + for _, child := range node.Content { + if child.Kind == yaml.ScalarNode && child.Value != "" { + *selectors = append(*selectors, child.Value) + } + } + } +} + +func collectAliasDefinitions(document *yaml.Node) map[string][]string { + aliases := make(map[string][]string) + root := document + if root.Kind == yaml.DocumentNode && len(root.Content) == 1 { + root = root.Content[0] + } + if root.Kind != yaml.MappingNode { + return aliases + } + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value != "aliases" || root.Content[i+1].Kind != yaml.MappingNode { + continue + } + definitions := root.Content[i+1] + for j := 0; j+1 < len(definitions.Content); j += 2 { + name, value := definitions.Content[j].Value, definitions.Content[j+1] + var expressions []string + collectGivenSelectors(value, &expressions) + collectAliasScalarExpressions(value, &expressions) + aliases[name] = uniqueStrings(expressions) + } + } + return aliases +} + +func collectAliasScalarExpressions(node *yaml.Node, expressions *[]string) { + if node == nil { + return + } + if node.Kind == yaml.ScalarNode && (strings.HasPrefix(node.Value, "$") || strings.HasPrefix(node.Value, "#")) { + *expressions = append(*expressions, node.Value) + return + } + for _, child := range node.Content { + collectAliasScalarExpressions(child, expressions) + } +} + +func expandAliasExpression(expression string, aliases map[string][]string, active map[string]bool) []string { + if !strings.HasPrefix(expression, "#") { + return []string{expression} + } + end := strings.IndexAny(expression, ".[ ") + if end < 0 { + end = len(expression) + } + name := expression[1:end] + definitions := aliases[name] + if len(definitions) == 0 { + return []string{expression} + } + if active == nil { + active = make(map[string]bool) + } + if active[name] { + return []string{expression} + } + active[name] = true + defer delete(active, name) + suffix := expression[end:] + var expanded []string + for _, definition := range definitions { + for _, base := range expandAliasExpression(definition, aliases, active) { + expanded = append(expanded, base+suffix) + } + } + return uniqueStrings(expanded) +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/pkg/jsonpath/testdata/spectral/generate/collect_public_test.go b/pkg/jsonpath/testdata/spectral/generate/collect_public_test.go new file mode 100644 index 0000000..af36499 --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/generate/collect_public_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "reflect" + "sort" + "testing" + + "go.yaml.in/yaml/v4" +) + +func TestCollectPublicSelectorsExpandsAliasesAndLists(t *testing.T) { + source := ` +aliases: + ResponsesObject: + targets: + - given: $..responses + HttpStatus: + - "#ResponsesObject.*~" +rules: + aliased: + given: "#HttpStatus" + listed: + given: + - $.paths + - $.channels +` + var document yaml.Node + if err := yaml.Unmarshal([]byte(source), &document); err != nil { + t.Fatal(err) + } + aliases := collectAliasDefinitions(&document) + var collected []string + collectGivenSelectors(&document, &collected) + var expanded []string + for _, expression := range collected { + expanded = append(expanded, expandAliasExpression(expression, aliases, nil)...) + } + expanded = uniqueStrings(expanded) + sort.Strings(expanded) + want := []string{"$..responses", "$..responses.*~", "$.channels", "$.paths"} + if !reflect.DeepEqual(expanded, want) { + t.Fatalf("expanded selectors = %v, want %v", expanded, want) + } +} + +func TestExpandPublicAliasLeavesUnknownAndCyclesClassifiable(t *testing.T) { + aliases := map[string][]string{ + "CycleA": {"#CycleB"}, + "CycleB": {"#CycleA"}, + } + if got := expandAliasExpression("#Missing", aliases, nil); !reflect.DeepEqual(got, []string{"#Missing"}) { + t.Fatalf("unknown alias expansion = %v", got) + } + if got := expandAliasExpression("#CycleA", aliases, nil); !reflect.DeepEqual(got, []string{"#CycleA"}) { + t.Fatalf("cyclic alias expansion = %v", got) + } +} diff --git a/pkg/jsonpath/testdata/spectral/generate/generate.mjs b/pkg/jsonpath/testdata/spectral/generate/generate.mjs new file mode 100644 index 0000000..744ac5e --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/generate/generate.mjs @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Nimma from 'nimma'; +import { jsonPathPlus } from 'nimma/fallbacks'; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const corpusPath = path.resolve(directory, '..', 'corpus.json'); +const corpus = JSON.parse(await fs.readFile(corpusPath, 'utf8')); +const officialSelectorsPath = path.resolve(directory, '..', 'official-selectors.json'); +const officialInputPath = path.resolve(directory, '..', 'official-input.json'); +const officialParityPath = path.resolve(directory, '..', 'official-parity.json'); + +function normalizedPath(root, segments) { + let result = '$'; + let current = root; + for (const segment of segments) { + if (Array.isArray(current)) { + result += `[${segment}]`; + } else { + result += `['${String(segment).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}']`; + } + current = current?.[segment]; + } + return result; +} + +function selectedEngine(expression) { + try { + new Nimma([expression], { fallback: null, unsafe: false, output: 'auto' }); + return 'nimma'; + } catch { + return 'jsonpath-plus'; + } +} + +function executeCase(testCase) { + const paths = []; + const query = new Nimma([testCase.expression], { + fallback: jsonPathPlus, + unsafe: false, + output: 'auto', + customShorthands: {}, + }); + query.query(testCase.input, { + [testCase.expression]: scope => paths.push(normalizedPath(testCase.input, scope.path)), + }); + testCase.engine = selectedEngine(testCase.expression); + testCase.expectedPaths = paths.sort(); + return testCase; +} + +for (const testCase of corpus.cases) { + executeCase(testCase); +} + +corpus.baseline.generatedBy = 'npm ci && npm run generate'; +await fs.writeFile(corpusPath, `${JSON.stringify(corpus, null, 2)}\n`); + +const officialSelectors = JSON.parse(await fs.readFile(officialSelectorsPath, 'utf8')); +const officialInput = JSON.parse(await fs.readFile(officialInputPath, 'utf8')); +const officialParity = { + baseline: { + spectralCommit: corpus.baseline.spectralCommit, + spectralCoreVersion: corpus.baseline.spectralCoreVersion, + nimmaVersion: corpus.baseline.nimmaVersion, + jsonPathPlusVersion: corpus.baseline.jsonPathPlusVersion, + unsafe: false, + generatedBy: 'npm ci && npm run collect-official && npm run generate', + sourceRepository: officialSelectors.repository, + sourceLicense: officialSelectors.license, + }, + input: officialInput, + cases: officialSelectors.selectors.map(expression => executeCase({ expression, input: officialInput })).map(testCase => ({ + expression: testCase.expression, + engine: testCase.engine, + expectedPaths: testCase.expectedPaths, + })), +}; +await fs.writeFile(officialParityPath, `${JSON.stringify(officialParity, null, 2)}\n`); diff --git a/pkg/jsonpath/testdata/spectral/generate/package-lock.json b/pkg/jsonpath/testdata/spectral/generate/package-lock.json new file mode 100644 index 0000000..aef475a --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/generate/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "jsonpath-spectral-corpus-generator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jsonpath-spectral-corpus-generator", + "version": "1.0.0", + "dependencies": { + "jsonpath-plus": "10.3.0", + "nimma": "0.2.3" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "license": "MIT", + "optional": true + }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + } + } +} diff --git a/pkg/jsonpath/testdata/spectral/generate/package.json b/pkg/jsonpath/testdata/spectral/generate/package.json new file mode 100644 index 0000000..d1cd546 --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/generate/package.json @@ -0,0 +1,15 @@ +{ + "name": "jsonpath-spectral-corpus-generator", + "version": "1.0.0", + "private": true, + "description": "Pinned differential generator for the pb33f/jsonpath Spectral compatibility corpus", + "scripts": { + "generate": "node generate.mjs", + "collect-official": "node collect-official.mjs", + "collect-public": "go run collect_public.go" + }, + "dependencies": { + "jsonpath-plus": "10.3.0", + "nimma": "0.2.3" + } +} diff --git a/pkg/jsonpath/testdata/spectral/official-input.json b/pkg/jsonpath/testdata/spectral/official-input.json new file mode 100644 index 0000000..1632a4e --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/official-input.json @@ -0,0 +1,108 @@ +{ + "openapi": "3.1.0", + "asyncapi": "3.0.0", + "info": { + "contact": { "name": "API Team" }, + "tags": [{ "name": "core", "description": "Core operations" }] + }, + "tags": [{ "name": "core", "description": "Core operations" }], + "security": [{ "oauth": [] }], + "paths": { + "/pets": { + "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string", "example": "one" } }], + "get": { + "parameters": [{ "in": "query", "name": "limit", "examples": { "small": { "value": 1 } } }], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Pet" } }, + "examples": { "ok": { "value": [{ "name": "cat" }] } } + } + }, + "headers": { "rate": { "examples": { "default": { "value": 1 } } } } + } + }, + "servers": [{ "url": "https://api.example.test" }], + "tags": [{ "name": "core" }], + "traits": [{ "tags": [{ "name": "trait" }] }] + } + } + }, + "responses": { "Shared": { "description": "shared" } }, + "definitions": { + "Legacy": { "type": "string", "enum": ["a", "b"], "example": "a" } + }, + "components": { + "schemas": { + "Pet": { + "type": ["object"], + "properties": { "name": { "type": "string", "default": "cat" } }, + "example": { "name": "cat" } + } + }, + "examples": { "Pet": { "value": { "name": "cat" } } }, + "headers": { "rate": { "examples": { "one": { "value": 1 } } } }, + "links": { "next": { "operationId": "listPets" } }, + "parameters": { + "limit": { "in": "query", "schema": { "default": 10, "examples": [10] }, "examples": { "ten": { "value": 10 } } } + }, + "responses": { "Missing": { "description": "missing" } }, + "servers": { "production": { "$ref": "#/servers/production", "tags": [{ "name": "prod" }] } }, + "messages": { + "Pet": { + "payload": { "type": "object", "default": {}, "examples": [{}] }, + "headers": { "type": "object" }, + "tags": [{ "name": "message" }], + "traits": [{ "tags": [{ "name": "message-trait" }] }] + } + }, + "messageTraits": { + "common": { "payload": { "default": {}, "examples": [{}] }, "headers": {}, "tags": [{ "name": "common" }] } + }, + "channels": { + "pets": { + "publish": { "message": { "tags": [{ "name": "publish" }], "traits": [] }, "tags": [] }, + "messages": { "Pet": { "traits": [{ "tags": [] }] } } + } + }, + "operations": { "publishPet": { "tags": [{ "name": "publish" }] } }, + "operationTraits": { "common": { "tags": [{ "name": "common" }] } } + }, + "channels": { + "pets": { + "parameters": { "id": { "schema": { "default": "one", "examples": ["one"] } } }, + "publish": { + "message": { + "payload": { "default": {}, "examples": [{}] }, + "headers": {}, + "tags": [{ "name": "publish" }], + "traits": [{ "tags": [{ "name": "trait" }] }], + "oneOf": [{ "tags": [{ "name": "variant" }], "traits": [{ "tags": [] }] }] + }, + "security": [{ "oauth": [] }], + "tags": [{ "name": "publish" }], + "traits": [{ "tags": [] }] + }, + "messages": { "Pet": { "payload": {}, "headers": {}, "tags": [], "traits": [{ "tags": [] }] } } + } + }, + "operations": { + "publishPet": { "security": [{ "oauth": [] }], "tags": [{ "name": "publish" }], "traits": [{ "tags": [] }] } + }, + "servers": { + "production": { + "$ref": "#/servers/production", + "host": "api.example.test", + "pathname": "/v1", + "url": "https://api.example.test/v1", + "security": [{ "oauth": [] }], + "tags": [{ "name": "prod" }] + } + }, + "webhooks": { + "petCreated": { "post": { "callbacks": {}, "servers": [] } }, + "servers": [] + } +} diff --git a/pkg/jsonpath/testdata/spectral/official-parity.json b/pkg/jsonpath/testdata/spectral/official-parity.json new file mode 100644 index 0000000..d06041b --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/official-parity.json @@ -0,0 +1,1274 @@ +{ + "baseline": { + "spectralCommit": "f08df7bb4c62015391cc2b0d51cdaecf30ac109c", + "spectralCoreVersion": "1.23.1", + "nimmaVersion": "0.2.3", + "jsonPathPlusVersion": "10.3.0", + "unsafe": false, + "generatedBy": "npm ci && npm run collect-official && npm run generate", + "sourceRepository": "https://github.com/stoplightio/spectral", + "sourceLicense": "Apache-2.0" + }, + "input": { + "openapi": "3.1.0", + "asyncapi": "3.0.0", + "info": { + "contact": { + "name": "API Team" + }, + "tags": [ + { + "name": "core", + "description": "Core operations" + } + ] + }, + "tags": [ + { + "name": "core", + "description": "Core operations" + } + ], + "security": [ + { + "oauth": [] + } + ], + "paths": { + "/pets": { + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "example": "one" + } + } + ], + "get": { + "parameters": [ + { + "in": "query", + "name": "limit", + "examples": { + "small": { + "value": 1 + } + } + } + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "examples": { + "ok": { + "value": [ + { + "name": "cat" + } + ] + } + } + } + }, + "headers": { + "rate": { + "examples": { + "default": { + "value": 1 + } + } + } + } + } + }, + "servers": [ + { + "url": "https://api.example.test" + } + ], + "tags": [ + { + "name": "core" + } + ], + "traits": [ + { + "tags": [ + { + "name": "trait" + } + ] + } + ] + } + } + }, + "responses": { + "Shared": { + "description": "shared" + } + }, + "definitions": { + "Legacy": { + "type": "string", + "enum": [ + "a", + "b" + ], + "example": "a" + } + }, + "components": { + "schemas": { + "Pet": { + "type": [ + "object" + ], + "properties": { + "name": { + "type": "string", + "default": "cat" + } + }, + "example": { + "name": "cat" + } + } + }, + "examples": { + "Pet": { + "value": { + "name": "cat" + } + } + }, + "headers": { + "rate": { + "examples": { + "one": { + "value": 1 + } + } + } + }, + "links": { + "next": { + "operationId": "listPets" + } + }, + "parameters": { + "limit": { + "in": "query", + "schema": { + "default": 10, + "examples": [ + 10 + ] + }, + "examples": { + "ten": { + "value": 10 + } + } + } + }, + "responses": { + "Missing": { + "description": "missing" + } + }, + "servers": { + "production": { + "$ref": "#/servers/production", + "tags": [ + { + "name": "prod" + } + ] + } + }, + "messages": { + "Pet": { + "payload": { + "type": "object", + "default": {}, + "examples": [ + {} + ] + }, + "headers": { + "type": "object" + }, + "tags": [ + { + "name": "message" + } + ], + "traits": [ + { + "tags": [ + { + "name": "message-trait" + } + ] + } + ] + } + }, + "messageTraits": { + "common": { + "payload": { + "default": {}, + "examples": [ + {} + ] + }, + "headers": {}, + "tags": [ + { + "name": "common" + } + ] + } + }, + "channels": { + "pets": { + "publish": { + "message": { + "tags": [ + { + "name": "publish" + } + ], + "traits": [] + }, + "tags": [] + }, + "messages": { + "Pet": { + "traits": [ + { + "tags": [] + } + ] + } + } + } + }, + "operations": { + "publishPet": { + "tags": [ + { + "name": "publish" + } + ] + } + }, + "operationTraits": { + "common": { + "tags": [ + { + "name": "common" + } + ] + } + } + }, + "channels": { + "pets": { + "parameters": { + "id": { + "schema": { + "default": "one", + "examples": [ + "one" + ] + } + } + }, + "publish": { + "message": { + "payload": { + "default": {}, + "examples": [ + {} + ] + }, + "headers": {}, + "tags": [ + { + "name": "publish" + } + ], + "traits": [ + { + "tags": [ + { + "name": "trait" + } + ] + } + ], + "oneOf": [ + { + "tags": [ + { + "name": "variant" + } + ], + "traits": [ + { + "tags": [] + } + ] + } + ] + }, + "security": [ + { + "oauth": [] + } + ], + "tags": [ + { + "name": "publish" + } + ], + "traits": [ + { + "tags": [] + } + ] + }, + "messages": { + "Pet": { + "payload": {}, + "headers": {}, + "tags": [], + "traits": [ + { + "tags": [] + } + ] + } + } + } + }, + "operations": { + "publishPet": { + "security": [ + { + "oauth": [] + } + ], + "tags": [ + { + "name": "publish" + } + ], + "traits": [ + { + "tags": [] + } + ] + } + }, + "servers": { + "production": { + "$ref": "#/servers/production", + "host": "api.example.test", + "pathname": "/v1", + "url": "https://api.example.test/v1", + "security": [ + { + "oauth": [] + } + ], + "tags": [ + { + "name": "prod" + } + ] + } + }, + "webhooks": { + "petCreated": { + "post": { + "callbacks": {}, + "servers": [] + } + }, + "servers": [] + } + }, + "cases": [ + { + "expression": "$", + "engine": "nimma", + "expectedPaths": [ + "$" + ] + }, + { + "expression": "$..[?(@ && @.enum && @.type)]", + "engine": "nimma", + "expectedPaths": [ + "$['definitions']['Legacy']" + ] + }, + { + "expression": "$..[?(@ && @.type && @.type.constructor.name === \"Array\" && @.type.includes(\"array\"))]", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$..[?(@ && @.type==\"array\")]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['get']['responses']['200']['content']['application/json']['schema']" + ] + }, + { + "expression": "$..[?(@property !== 'properties' && @.enum && @.enum.constructor.name === 'Array')]", + "engine": "nimma", + "expectedPaths": [ + "$['definitions']['Legacy']" + ] + }, + { + "expression": "$..[?(@property === '$ref')]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['servers']['production']['$ref']", + "$['paths']['/pets']['get']['responses']['200']['content']['application/json']['schema']['items']['$ref']", + "$['servers']['production']['$ref']" + ] + }, + { + "expression": "$..[description,title]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['responses']['Missing']['description']", + "$['info']['tags'][0]['description']", + "$['paths']['/pets']['get']['responses']['200']['description']", + "$['responses']['Shared']['description']", + "$['tags'][0]['description']" + ] + }, + { + "expression": "$..anyOf", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$..content..[?(@ && @.schema && (@.example !== void 0 || @.examples))]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['get']['responses']['200']['content']['application/json']" + ] + }, + { + "expression": "$..content..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$..definitions..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [ + "$['definitions']['Legacy']" + ] + }, + { + "expression": "$..headers..[?(@ && @.schema && (@.example !== void 0 || @.examples))]", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$..headers..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$..oneOf", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['oneOf']" + ] + }, + { + "expression": "$..parameters..[?(@ && @.schema && (@.example !== void 0 || @.examples))]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']" + ] + }, + { + "expression": "$..parameters..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['parameters'][0]['schema']" + ] + }, + { + "expression": "$..parameters..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['parameters'][0]['schema']" + ] + }, + { + "expression": "$..parameters[?(@ && @.in)]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']", + "$['paths']['/pets']['get']['parameters'][0]", + "$['paths']['/pets']['parameters'][0]" + ] + }, + { + "expression": "$..responses..[?(@ && @.schema && @.examples)]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['get']['responses']['200']['content']['application/json']" + ] + }, + { + "expression": "$..responses..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.asyncapi", + "engine": "nimma", + "expectedPaths": [ + "$['asyncapi']" + ] + }, + { + "expression": "$.channels", + "engine": "nimma", + "expectedPaths": [ + "$['channels']" + ] + }, + { + "expression": "$.channels.*", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']" + ] + }, + { + "expression": "$.channels.*..messages.*.traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['messages']['Pet']['traits'][0]['tags']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.headers", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['headers']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.oneOf.*", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['oneOf'][0]" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.oneOf.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['oneOf'][0]['tags']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.oneOf.*.traits.*", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['oneOf'][0]['traits'][0]" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.oneOf.*.traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['oneOf'][0]['traits'][0]['tags']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['tags']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.traits.*", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['traits'][0]" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['traits'][0]['tags']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].message.traits[*].headers", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.channels.*.[publish,subscribe].tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['tags']" + ] + }, + { + "expression": "$.channels.*.[publish,subscribe].traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['traits'][0]['tags']" + ] + }, + { + "expression": "$.channels.*.messages.*.headers", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['messages']['Pet']['headers']" + ] + }, + { + "expression": "$.channels.*.messages.*.payload", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['messages']['Pet']['payload']" + ] + }, + { + "expression": "$.channels.*.messages.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['messages']['Pet']['tags']" + ] + }, + { + "expression": "$.channels.*.messages.*.traits[*].headers", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.channels.*.parameters.*", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['parameters']['id']" + ] + }, + { + "expression": "$.channels.*.parameters.*.schema.default^", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['parameters']['id']['schema']" + ] + }, + { + "expression": "$.channels.*.parameters.*.schema.examples^", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['parameters']['id']['schema']" + ] + }, + { + "expression": "$.channels[*][publish,subscribe]", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']" + ] + }, + { + "expression": "$.channels[*][publish,subscribe].message", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']" + ] + }, + { + "expression": "$.channels[*][publish,subscribe].security.*", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['security'][0]" + ] + }, + { + "expression": "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['payload']" + ] + }, + { + "expression": "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload.default^", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['payload']" + ] + }, + { + "expression": "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload.examples^", + "engine": "nimma", + "expectedPaths": [ + "$['channels']['pets']['publish']['message']['payload']" + ] + }, + { + "expression": "$.components.channels.*", + "engine": "nimma", + "expectedPaths": [ + "$['components']['channels']['pets']" + ] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message", + "engine": "nimma", + "expectedPaths": [ + "$['components']['channels']['pets']['publish']['message']" + ] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.oneOf.*", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.oneOf.*.tags", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.oneOf.*.traits.*", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.oneOf.*.traits.*.tags", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['channels']['pets']['publish']['message']['tags']" + ] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.traits.*", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].message.traits.*.tags", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['channels']['pets']['publish']['tags']" + ] + }, + { + "expression": "$.components.channels.*.[publish,subscribe].traits.*.tags", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.channels.*.messages.*.traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['channels']['pets']['messages']['Pet']['traits'][0]['tags']" + ] + }, + { + "expression": "$.components.channels.*.messages.tags", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.examples[*]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['examples']['Pet']" + ] + }, + { + "expression": "$.components.headers[*].examples[*]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['headers']['rate']['examples']['one']" + ] + }, + { + "expression": "$.components.links[*]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['links']['next']" + ] + }, + { + "expression": "$.components.messageTraits.*", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messageTraits']['common']" + ] + }, + { + "expression": "$.components.messageTraits.*.headers", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messageTraits']['common']['headers']" + ] + }, + { + "expression": "$.components.messageTraits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messageTraits']['common']['tags']" + ] + }, + { + "expression": "$.components.messageTraits[?(@.schemaFormat === void 0)].payload", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messageTraits']['common']['payload']" + ] + }, + { + "expression": "$.components.messageTraits[?(@.schemaFormat === void 0)].payload.default^", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messageTraits']['common']['payload']" + ] + }, + { + "expression": "$.components.messageTraits[?(@.schemaFormat === void 0)].payload.examples^", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messageTraits']['common']['payload']" + ] + }, + { + "expression": "$.components.messages.*", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']" + ] + }, + { + "expression": "$.components.messages.*.headers", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['headers']" + ] + }, + { + "expression": "$.components.messages.*.payload", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['payload']" + ] + }, + { + "expression": "$.components.messages.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['tags']" + ] + }, + { + "expression": "$.components.messages.*.traits.*", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['traits'][0]" + ] + }, + { + "expression": "$.components.messages.*.traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['traits'][0]['tags']" + ] + }, + { + "expression": "$.components.messages[?(@.schemaFormat === void 0)].payload", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['payload']" + ] + }, + { + "expression": "$.components.messages[?(@.schemaFormat === void 0)].payload.default^", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['payload']" + ] + }, + { + "expression": "$.components.messages[?(@.schemaFormat === void 0)].payload.examples^", + "engine": "nimma", + "expectedPaths": [ + "$['components']['messages']['Pet']['payload']" + ] + }, + { + "expression": "$.components.operationTraits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['operationTraits']['common']['tags']" + ] + }, + { + "expression": "$.components.operations.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['operations']['publishPet']['tags']" + ] + }, + { + "expression": "$.components.operations.traits.*.tags", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.parameters.*", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']" + ] + }, + { + "expression": "$.components.parameters.*.schema.default^", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']['schema']" + ] + }, + { + "expression": "$.components.parameters.*.schema.examples^", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']['schema']" + ] + }, + { + "expression": "$.components.parameters[*].examples[*]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']['examples']['ten']" + ] + }, + { + "expression": "$.components.parameters[?(@ && @.in)]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['parameters']['limit']" + ] + }, + { + "expression": "$.components.responses[*]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['responses']['Missing']" + ] + }, + { + "expression": "$.components.schemas", + "engine": "nimma", + "expectedPaths": [ + "$['components']['schemas']" + ] + }, + { + "expression": "$.components.schemas.*.default^", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.schemas.*.examples^", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.components.schemas..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "engine": "nimma", + "expectedPaths": [ + "$['components']['schemas']['Pet']", + "$['components']['schemas']['Pet']['properties']['name']" + ] + }, + { + "expression": "$.components.servers", + "engine": "nimma", + "expectedPaths": [ + "$['components']['servers']" + ] + }, + { + "expression": "$.components.servers.*", + "engine": "nimma", + "expectedPaths": [ + "$['components']['servers']['production']" + ] + }, + { + "expression": "$.components.servers.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['components']['servers']['production']['tags']" + ] + }, + { + "expression": "$.definitions", + "engine": "nimma", + "expectedPaths": [ + "$['definitions']" + ] + }, + { + "expression": "$.definitions[?(@.discriminator)]", + "engine": "nimma", + "expectedPaths": [] + }, + { + "expression": "$.info", + "engine": "nimma", + "expectedPaths": [ + "$['info']" + ] + }, + { + "expression": "$.info.contact", + "engine": "nimma", + "expectedPaths": [ + "$['info']['contact']" + ] + }, + { + "expression": "$.info.tags", + "engine": "nimma", + "expectedPaths": [ + "$['info']['tags']" + ] + }, + { + "expression": "$.info.tags[*]", + "engine": "nimma", + "expectedPaths": [ + "$['info']['tags'][0]" + ] + }, + { + "expression": "$.operations.*", + "engine": "nimma", + "expectedPaths": [ + "$['operations']['publishPet']" + ] + }, + { + "expression": "$.operations.*.security.*", + "engine": "nimma", + "expectedPaths": [ + "$['operations']['publishPet']['security'][0]" + ] + }, + { + "expression": "$.operations.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['operations']['publishPet']['tags']" + ] + }, + { + "expression": "$.operations.*.traits.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['operations']['publishPet']['traits'][0]['tags']" + ] + }, + { + "expression": "$.paths", + "engine": "nimma", + "expectedPaths": [ + "$['paths']" + ] + }, + { + "expression": "$.paths[*]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']" + ] + }, + { + "expression": "$.paths[*][*]..content[*].examples[*]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['get']['responses']['200']['content']['application/json']['examples']['ok']" + ] + }, + { + "expression": "$.paths[*][*]..headers[*].examples[*]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['get']['responses']['200']['headers']['rate']['examples']['default']" + ] + }, + { + "expression": "$.paths[*][*]..parameters[*].examples[*]", + "engine": "nimma", + "expectedPaths": [ + "$['paths']['/pets']['get']['parameters'][0]['examples']['small']" + ] + }, + { + "expression": "$.responses[*]", + "engine": "nimma", + "expectedPaths": [ + "$['responses']['Shared']" + ] + }, + { + "expression": "$.security[*]", + "engine": "nimma", + "expectedPaths": [ + "$['security'][0]" + ] + }, + { + "expression": "$.servers.*", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']" + ] + }, + { + "expression": "$.servers.*.$ref", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['$ref']" + ] + }, + { + "expression": "$.servers.*.host", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['host']" + ] + }, + { + "expression": "$.servers.*.security.*", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['security'][0]" + ] + }, + { + "expression": "$.servers.*.tags", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['tags']" + ] + }, + { + "expression": "$.servers[*]", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']" + ] + }, + { + "expression": "$.servers[*].host", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['host']" + ] + }, + { + "expression": "$.servers[*].pathname", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['pathname']" + ] + }, + { + "expression": "$.servers[*].url", + "engine": "nimma", + "expectedPaths": [ + "$['servers']['production']['url']" + ] + }, + { + "expression": "$.tags", + "engine": "nimma", + "expectedPaths": [ + "$['tags']" + ] + }, + { + "expression": "$.tags[*]", + "engine": "nimma", + "expectedPaths": [ + "$['tags'][0]" + ] + }, + { + "expression": "$.webhooks.servers", + "engine": "nimma", + "expectedPaths": [ + "$['webhooks']['servers']" + ] + }, + { + "expression": "$.webhooks[*][*].callbacks", + "engine": "nimma", + "expectedPaths": [ + "$['webhooks']['petCreated']['post']['callbacks']" + ] + }, + { + "expression": "$.webhooks[*][*].servers", + "engine": "nimma", + "expectedPaths": [ + "$['webhooks']['petCreated']['post']['servers']" + ] + } + ] +} diff --git a/pkg/jsonpath/testdata/spectral/official-selectors.json b/pkg/jsonpath/testdata/spectral/official-selectors.json new file mode 100644 index 0000000..d3ca3d8 --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/official-selectors.json @@ -0,0 +1,138 @@ +{ + "repository": "https://github.com/stoplightio/spectral", + "license": "Apache-2.0", + "commit": "f08df7bb4c62015391cc2b0d51cdaecf30ac109c", + "files": [ + "packages/rulesets/src/oas/index.ts", + "packages/rulesets/src/asyncapi/index.ts" + ], + "selectors": [ + "$", + "$..[?(@ && @.enum && @.type)]", + "$..[?(@ && @.type && @.type.constructor.name === \"Array\" && @.type.includes(\"array\"))]", + "$..[?(@ && @.type==\"array\")]", + "$..[?(@property !== 'properties' && @.enum && @.enum.constructor.name === 'Array')]", + "$..[?(@property === '$ref')]", + "$..[description,title]", + "$..anyOf", + "$..content..[?(@ && @.schema && (@.example !== void 0 || @.examples))]", + "$..content..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$..definitions..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$..headers..[?(@ && @.schema && (@.example !== void 0 || @.examples))]", + "$..headers..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$..oneOf", + "$..parameters..[?(@ && @.schema && (@.example !== void 0 || @.examples))]", + "$..parameters..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$..parameters..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$..parameters[?(@ && @.in)]", + "$..responses..[?(@ && @.schema && @.examples)]", + "$..responses..[?(@property !== 'properties' && @ && (@.example !== void 0 || @['x-example'] !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$.asyncapi", + "$.channels", + "$.channels.*", + "$.channels.*..messages.*.traits.*.tags", + "$.channels.*.[publish,subscribe].message", + "$.channels.*.[publish,subscribe].message.headers", + "$.channels.*.[publish,subscribe].message.oneOf.*", + "$.channels.*.[publish,subscribe].message.oneOf.*.tags", + "$.channels.*.[publish,subscribe].message.oneOf.*.traits.*", + "$.channels.*.[publish,subscribe].message.oneOf.*.traits.*.tags", + "$.channels.*.[publish,subscribe].message.tags", + "$.channels.*.[publish,subscribe].message.traits.*", + "$.channels.*.[publish,subscribe].message.traits.*.tags", + "$.channels.*.[publish,subscribe].message.traits[*].headers", + "$.channels.*.[publish,subscribe].tags", + "$.channels.*.[publish,subscribe].traits.*.tags", + "$.channels.*.messages.*.headers", + "$.channels.*.messages.*.payload", + "$.channels.*.messages.*.tags", + "$.channels.*.messages.*.traits[*].headers", + "$.channels.*.parameters.*", + "$.channels.*.parameters.*.schema.default^", + "$.channels.*.parameters.*.schema.examples^", + "$.channels[*][publish,subscribe]", + "$.channels[*][publish,subscribe].message", + "$.channels[*][publish,subscribe].security.*", + "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload", + "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload.default^", + "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload.examples^", + "$.components.channels.*", + "$.components.channels.*.[publish,subscribe].message", + "$.components.channels.*.[publish,subscribe].message.oneOf.*", + "$.components.channels.*.[publish,subscribe].message.oneOf.*.tags", + "$.components.channels.*.[publish,subscribe].message.oneOf.*.traits.*", + "$.components.channels.*.[publish,subscribe].message.oneOf.*.traits.*.tags", + "$.components.channels.*.[publish,subscribe].message.tags", + "$.components.channels.*.[publish,subscribe].message.traits.*", + "$.components.channels.*.[publish,subscribe].message.traits.*.tags", + "$.components.channels.*.[publish,subscribe].tags", + "$.components.channels.*.[publish,subscribe].traits.*.tags", + "$.components.channels.*.messages.*.traits.*.tags", + "$.components.channels.*.messages.tags", + "$.components.examples[*]", + "$.components.headers[*].examples[*]", + "$.components.links[*]", + "$.components.messageTraits.*", + "$.components.messageTraits.*.headers", + "$.components.messageTraits.*.tags", + "$.components.messageTraits[?(@.schemaFormat === void 0)].payload", + "$.components.messageTraits[?(@.schemaFormat === void 0)].payload.default^", + "$.components.messageTraits[?(@.schemaFormat === void 0)].payload.examples^", + "$.components.messages.*", + "$.components.messages.*.headers", + "$.components.messages.*.payload", + "$.components.messages.*.tags", + "$.components.messages.*.traits.*", + "$.components.messages.*.traits.*.tags", + "$.components.messages[?(@.schemaFormat === void 0)].payload", + "$.components.messages[?(@.schemaFormat === void 0)].payload.default^", + "$.components.messages[?(@.schemaFormat === void 0)].payload.examples^", + "$.components.operationTraits.*.tags", + "$.components.operations.*.tags", + "$.components.operations.traits.*.tags", + "$.components.parameters.*", + "$.components.parameters.*.schema.default^", + "$.components.parameters.*.schema.examples^", + "$.components.parameters[*].examples[*]", + "$.components.parameters[?(@ && @.in)]", + "$.components.responses[*]", + "$.components.schemas", + "$.components.schemas.*.default^", + "$.components.schemas.*.examples^", + "$.components.schemas..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]", + "$.components.servers", + "$.components.servers.*", + "$.components.servers.*.tags", + "$.definitions", + "$.definitions[?(@.discriminator)]", + "$.info", + "$.info.contact", + "$.info.tags", + "$.info.tags[*]", + "$.operations.*", + "$.operations.*.security.*", + "$.operations.*.tags", + "$.operations.*.traits.*.tags", + "$.paths", + "$.paths[*]", + "$.paths[*][*]..content[*].examples[*]", + "$.paths[*][*]..headers[*].examples[*]", + "$.paths[*][*]..parameters[*].examples[*]", + "$.responses[*]", + "$.security[*]", + "$.servers.*", + "$.servers.*.$ref", + "$.servers.*.host", + "$.servers.*.security.*", + "$.servers.*.tags", + "$.servers[*]", + "$.servers[*].host", + "$.servers[*].pathname", + "$.servers[*].url", + "$.tags", + "$.tags[*]", + "$.webhooks.servers", + "$.webhooks[*][*].callbacks", + "$.webhooks[*][*].servers" + ] +} diff --git a/pkg/jsonpath/testdata/spectral/public-selectors.json b/pkg/jsonpath/testdata/spectral/public-selectors.json new file mode 100644 index 0000000..026e59c --- /dev/null +++ b/pkg/jsonpath/testdata/spectral/public-selectors.json @@ -0,0 +1,393 @@ +{ + "generatedBy": "npm run collect-public", + "sources": [ + { + "name": "Harbor", + "repository": "https://github.com/goharbor/harbor", + "license": "Apache-2.0", + "commit": "691eb2fe36f4043f040c5d18ef365511042a917b", + "path": ".spectral.yaml" + }, + { + "name": "Kibana", + "repository": "https://github.com/elastic/kibana", + "license": "NOASSERTION", + "commit": "aa6146fd7a49bf4291921c51b35bce3a7417dc01", + "path": "oas_docs/linters/.spectral.yaml" + }, + { + "name": "Postman Knowledge Base", + "repository": "https://github.com/postman-open-technologies/knowledge-base", + "license": "Apache-2.0", + "commit": "2dd6098ab1d63dd9dc4ea52f9c6f6dc9c64fbab2", + "path": "spectral/postman/http-status-codes.spectral.yaml" + }, + { + "name": "Cimpress API Style Guide", + "repository": "https://github.com/Cimpress/cimpress-api-style-guide", + "license": "NOASSERTION", + "commit": "873a6aa7d37933a3c7a0f8bfd7a6018b63c5b3a7", + "path": "spectral/cimpress.spectral.yaml" + } + ], + "selectors": [ + { + "expression": "$", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$..*.description", + "sources": [ + "Kibana" + ], + "status": "supported" + }, + { + "expression": "$..parameters..[?(@.in == 'body' \u0026\u0026 (@.example || @.schema.$ref))]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$..parameters..[?(@.in == 'body')]..[?(@property !== 'properties' \u0026\u0026 @.example \u0026\u0026 ( @.type || @.format || @.$ref ))]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$..properties.*~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$..properties.createdAt", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$..properties.updatedAt", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$..responses", + "sources": [ + "Postman Knowledge Base" + ], + "status": "supported" + }, + { + "expression": "$..responses.*~", + "sources": [ + "Postman Knowledge Base" + ], + "status": "supported" + }, + { + "expression": "$.components.schemas.*.properties.*", + "sources": [ + "Kibana" + ], + "status": "supported" + }, + { + "expression": "$.components.schemas.*~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.components.securitySchemes[*]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.definitions", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.definitions..properties[*]~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.definitions.~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.definitions[*]~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.info.description", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.info.summary", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.info.title", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.info.version", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*.parameters[?(@.in=='path')].name", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*.parameters[?(@.in=='query')].name", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*.parameters[?(@.in==='query' || @.in==='path' || @.in==='cookie')].description", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*.parameters[?(@.in==='query' || @.in==='path' || @.in==='cookie')].name", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*.responses", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*.responses.*~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.*[responses,requestBody]..content..schema", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.delete", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*.get", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*[?(@property === 'get' \u0026\u0026 @.responses \u0026\u0026 @.responses['409'])]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.*~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths..responses[?( @property == 200 \u0026\u0026 @property \u003c 300 \u0026\u0026 @property != 204)].content[?(@property === \"application/hal+json\")]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths..responses[?( @property \u003e= 200 \u0026\u0026 @property \u003c 300 \u0026\u0026 @property != 204)]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths..responses[?( @property \u003e= 400 \u0026\u0026 @property \u003c 600 )]", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.[*].responses[?(@property.match(/^(2)/))].content.*~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths.[*].responses[?(@property.match(/^(4|5)/))].content.*~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[*].delete.responses", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[*].get.responses", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[*].post.responses", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[*].put.responses", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[*][*]", + "sources": [ + "Harbor", + "Kibana" + ], + "status": "supported" + }, + { + "expression": "$.paths[*][*].description", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[*][*].operationId", + "sources": [ + "Harbor" + ], + "status": "supported" + }, + { + "expression": "$.paths[*][*].responses.[200].content.[application/json]", + "sources": [ + "Kibana" + ], + "status": "supported" + }, + { + "expression": "$.paths[*][*].summary", + "sources": [ + "Kibana" + ], + "status": "supported" + }, + { + "expression": "$.paths[*][get,put,post,delete,options,head,patch,trace]", + "sources": [ + "Kibana" + ], + "status": "supported" + }, + { + "expression": "$.paths[*]~", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.paths[?(@property.match(/.*\\/{.*}.*/))]..responses", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.servers..url", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + }, + { + "expression": "$.servers[*].url", + "sources": [ + "Cimpress API Style Guide" + ], + "status": "supported" + } + ] +} diff --git a/pkg/jsonpath/token/spectral_token_test.go b/pkg/jsonpath/token/spectral_token_test.go new file mode 100644 index 0000000..1eaba7c --- /dev/null +++ b/pkg/jsonpath/token/spectral_token_test.go @@ -0,0 +1,79 @@ +package token + +import ( + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" +) + +func TestSpectralRegexToken(t *testing.T) { + expression := "$.values[?(\n@property.match(/a[\\/]\\d+/imsug)\n)]" + tokens := NewTokenizer(expression, config.WithSpectralCompatibility()).Tokenize() + var regex *TokenInfo + for i := range tokens { + if tokens[i].Token == REGEX { + regex = &tokens[i] + break + } + } + if regex == nil { + t.Fatal("missing regex token") + } + if regex.Literal != `/a[\/]\d+/imsug` || regex.Line != 2 || regex.Column != 16 || regex.Len != 15 { + t.Fatalf("regex token = %+v", *regex) + } +} + +func TestSpectralRegexAndUnquotedMemberAreDistinguished(t *testing.T) { + tokens := NewTokenizer(`$.paths[/openapi.json]`, config.WithSpectralCompatibility()).Tokenize() + if len(tokens) != 6 || tokens[4].Token != STRING_LITERAL || tokens[4].Literal != "/openapi.json" { + t.Fatalf("unquoted member tokens = %+v", tokens) + } + tokens = NewTokenizer(`$[?(@property.match(/[/]/))]`, config.WithSpectralCompatibility()).Tokenize() + found := false + for _, tok := range tokens { + if tok.Token == REGEX && tok.Literal == `/[/]/` { + found = true + } + } + if !found { + t.Fatalf("character-class regex tokens = %+v", tokens) + } +} + +func TestSpectralUnterminatedRegexToken(t *testing.T) { + tests := []struct { + expression string + message string + }{ + {`$[?(@property.match(/abc))]`, "unterminated regex literal"}, + {`$[?(@property.match(/[abc`, "unterminated regex character class"}, + } + for _, test := range tests { + tokens := NewTokenizer(test.expression, config.WithSpectralCompatibility()).Tokenize() + found := false + for _, tok := range tokens { + if tok.Token == ILLEGAL && tok.Literal == test.message { + found = true + } + } + if !found { + t.Errorf("%s tokens = %+v", test.expression, tokens) + } + } +} + +func TestStrictRFCRejectsStrictJavaScriptEqualityToken(t *testing.T) { + for _, expression := range []string{`$[?(@.a === @.b)]`, `$[?(@.a !== @.b)]`} { + tokens := NewTokenizer(expression, config.WithStrictRFC9535()).Tokenize() + foundIllegal := false + for _, tok := range tokens { + if tok.Token == ILLEGAL { + foundIllegal = true + } + } + if !foundIllegal { + t.Errorf("strict mode accepted %s: %+v", expression, tokens) + } + } +} diff --git a/pkg/jsonpath/token/token.go b/pkg/jsonpath/token/token.go index 7e92c12..ef7a1fe 100644 --- a/pkg/jsonpath/token/token.go +++ b/pkg/jsonpath/token/token.go @@ -1,10 +1,10 @@ package token import ( - "fmt" - "github.com/pb33f/jsonpath/pkg/jsonpath/config" - "strconv" - "strings" + "fmt" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "strconv" + "strings" ) // ***************************************************************************** @@ -162,245 +162,254 @@ type Token int // The list of tokens. const ( - ILLEGAL Token = iota - STRING - INTEGER - FLOAT - STRING_LITERAL - TRUE - FALSE - NULL - ROOT - CURRENT - WILDCARD - PROPERTY_NAME - RECURSIVE - CHILD - ARRAY_SLICE - FILTER - PAREN_LEFT - PAREN_RIGHT - BRACKET_LEFT - BRACKET_RIGHT - COMMA - TILDE - AND - OR - NOT - EQ - NE - GT - GE - LT - LE - MATCHES - FUNCTION - - // JSONPath Plus context variable tokens - CONTEXT_PROPERTY // @property - current property name - CONTEXT_ROOT // @root - root node access in filter - CONTEXT_PARENT // @parent - parent node reference - CONTEXT_PARENT_PROPERTY // @parentProperty - parent's property name - CONTEXT_PATH // @path - absolute path to current node - CONTEXT_INDEX // @index - current array index - - // JSONPath Plus parent selector - PARENT_SELECTOR // ^ - select parent of current node + ILLEGAL Token = iota + STRING + INTEGER + FLOAT + STRING_LITERAL + TRUE + FALSE + NULL + ROOT + CURRENT + WILDCARD + PROPERTY_NAME + RECURSIVE + CHILD + ARRAY_SLICE + FILTER + PAREN_LEFT + PAREN_RIGHT + BRACKET_LEFT + BRACKET_RIGHT + COMMA + TILDE + AND + OR + NOT + EQ + NE + GT + GE + LT + LE + MATCHES + FUNCTION + + // JSONPath Plus context variable tokens + CONTEXT_PROPERTY // @property - current property name + CONTEXT_ROOT // @root - root node access in filter + CONTEXT_PARENT // @parent - parent node reference + CONTEXT_PARENT_PROPERTY // @parentProperty - parent's property name + CONTEXT_PATH // @path - absolute path to current node + CONTEXT_INDEX // @index - current array index + + // JSONPath Plus parent selector + PARENT_SELECTOR // ^ - select parent of current node + + // Spectral compatibility tokens are appended to preserve every existing + // exported token's numeric value. + REGEX + STRICT_EQ + STRICT_NE ) var SimpleTokens = [...]Token{ - STRING, - INTEGER, - STRING_LITERAL, - CHILD, - BRACKET_LEFT, - BRACKET_RIGHT, - ROOT, + STRING, + INTEGER, + STRING_LITERAL, + CHILD, + BRACKET_LEFT, + BRACKET_RIGHT, + ROOT, } var tokens = [...]string{ - ILLEGAL: "ILLEGAL", - STRING: "STRING", - INTEGER: "INTEGER", - FLOAT: "FLOAT", - STRING_LITERAL: "STRING_LITERAL", - TRUE: "TRUE", - FALSE: "FALSE", - NULL: "NULL", - // root node identifier (Section 2.2) - ROOT: "$", - // current node identifier (Section 2.3.5) - // (valid only within filter selectors) - CURRENT: "@", - WILDCARD: "*", - RECURSIVE: "..", - CHILD: ".", - // start:end:step array slice operator (Section 2.3.4) - ARRAY_SLICE: ":", - // filter selector (Section 2.3.5): selects - // particular children using a logical - // expression - FILTER: "?", - PAREN_LEFT: "(", - PAREN_RIGHT: ")", - BRACKET_LEFT: "[", - BRACKET_RIGHT: "]", - COMMA: ",", - TILDE: "~", - AND: "&&", - OR: "||", - NOT: "!", - EQ: "==", - NE: "!=", - GT: ">", - GE: ">=", - LT: "<", - LE: "<=", - MATCHES: "=~", - FUNCTION: "FUNCTION", - - // JSONPath Plus context variables - CONTEXT_PROPERTY: "@property", - CONTEXT_ROOT: "@root", - CONTEXT_PARENT: "@parent", - CONTEXT_PARENT_PROPERTY: "@parentProperty", - CONTEXT_PATH: "@path", - CONTEXT_INDEX: "@index", - - // JSONPath Plus parent selector - PARENT_SELECTOR: "^", + ILLEGAL: "ILLEGAL", + STRING: "STRING", + INTEGER: "INTEGER", + FLOAT: "FLOAT", + STRING_LITERAL: "STRING_LITERAL", + TRUE: "TRUE", + FALSE: "FALSE", + NULL: "NULL", + // root node identifier (Section 2.2) + ROOT: "$", + // current node identifier (Section 2.3.5) + // (valid only within filter selectors) + CURRENT: "@", + WILDCARD: "*", + RECURSIVE: "..", + CHILD: ".", + // start:end:step array slice operator (Section 2.3.4) + ARRAY_SLICE: ":", + // filter selector (Section 2.3.5): selects + // particular children using a logical + // expression + FILTER: "?", + PAREN_LEFT: "(", + PAREN_RIGHT: ")", + BRACKET_LEFT: "[", + BRACKET_RIGHT: "]", + COMMA: ",", + TILDE: "~", + AND: "&&", + OR: "||", + NOT: "!", + EQ: "==", + NE: "!=", + GT: ">", + GE: ">=", + LT: "<", + LE: "<=", + MATCHES: "=~", + FUNCTION: "FUNCTION", + REGEX: "REGEX", + STRICT_EQ: "===", + STRICT_NE: "!==", + + // JSONPath Plus context variables + CONTEXT_PROPERTY: "@property", + CONTEXT_ROOT: "@root", + CONTEXT_PARENT: "@parent", + CONTEXT_PARENT_PROPERTY: "@parentProperty", + CONTEXT_PATH: "@path", + CONTEXT_INDEX: "@index", + + // JSONPath Plus parent selector + PARENT_SELECTOR: "^", } // String returns the string representation of the token. func (tok Token) String() string { - if tok >= 0 && tok < Token(len(tokens)) { - return tokens[tok] - } - return "token(" + strconv.Itoa(int(tok)) + ")" + if tok >= 0 && tok < Token(len(tokens)) { + return tokens[tok] + } + return "token(" + strconv.Itoa(int(tok)) + ")" } func (tok Tokens) IsSimple() bool { - if len(tok) == 0 { - return false - } - if tok[0].Token != ROOT { - return false - } - for _, token := range tok { - isSimple := false - for _, simpleToken := range SimpleTokens { - if token.Token == simpleToken { - isSimple = true - } - } - if !isSimple { - return false - } - } - return true + if len(tok) == 0 { + return false + } + if tok[0].Token != ROOT { + return false + } + for _, token := range tok { + isSimple := false + for _, simpleToken := range SimpleTokens { + if token.Token == simpleToken { + isSimple = true + } + } + if !isSimple { + return false + } + } + return true } // When there's an error in the tokenizer, this helps represent it. func (t Tokenizer) ErrorString(target *TokenInfo, msg string) string { - var errorBuilder strings.Builder - - var token TokenInfo - if target == nil { - // grab last token (as value) - token = t.tokens[len(t.tokens)-1] - // set column to +1 - token.Column++ - target = &token - } - - // Write the error message with line and column information - errorBuilder.WriteString(fmt.Sprintf("Error at line %d, column %d: %s\n", target.Line, target.Column, msg)) - - // Find the start and end positions of the line containing the target token - lineStart := 0 - lineEnd := len(t.input) - for i := target.Line - 1; i > 0; i-- { - if pos := strings.LastIndexByte(t.input[:lineStart], '\n'); pos != -1 { - lineStart = pos + 1 - break - } - } - if pos := strings.IndexByte(t.input[lineStart:], '\n'); pos != -1 { - lineEnd = lineStart + pos - } - - // Extract the line containing the target token - line := t.input[lineStart:lineEnd] - errorBuilder.WriteString(line) - errorBuilder.WriteString("\n") - - // Calculate the number of spaces before the target token - spaces := strings.Repeat(" ", target.Column) - - // Write the caret symbol pointing to the target token - errorBuilder.WriteString(spaces) - dots := "" - if target.Len > 0 { - dots = strings.Repeat(".", target.Len-1) - } - errorBuilder.WriteString("^" + dots + "\n") - - return errorBuilder.String() + var errorBuilder strings.Builder + + var token TokenInfo + if target == nil { + // grab last token (as value) + token = t.tokens[len(t.tokens)-1] + // set column to +1 + token.Column++ + target = &token + } + + // Write the error message with line and column information + errorBuilder.WriteString(fmt.Sprintf("Error at line %d, column %d: %s\n", target.Line, target.Column, msg)) + + // Find the start and end positions of the line containing the target token + lineStart := 0 + lineEnd := len(t.input) + for i := target.Line - 1; i > 0; i-- { + if pos := strings.LastIndexByte(t.input[:lineStart], '\n'); pos != -1 { + lineStart = pos + 1 + break + } + } + if pos := strings.IndexByte(t.input[lineStart:], '\n'); pos != -1 { + lineEnd = lineStart + pos + } + + // Extract the line containing the target token + line := t.input[lineStart:lineEnd] + errorBuilder.WriteString(line) + errorBuilder.WriteString("\n") + + // Calculate the number of spaces before the target token + spaces := strings.Repeat(" ", target.Column) + + // Write the caret symbol pointing to the target token + errorBuilder.WriteString(spaces) + dots := "" + if target.Len > 0 { + dots = strings.Repeat(".", target.Len-1) + } + errorBuilder.WriteString("^" + dots + "\n") + + return errorBuilder.String() } // When there's an error func (t Tokenizer) ErrorTokenString(target *TokenInfo, msg string) string { - var errorBuilder strings.Builder - var token TokenInfo - if target == nil { - // grab last token (as value) - token = t.tokens[len(t.tokens)-1] - // set column to +1 - token.Column++ - target = &token - } - // Write the error message with line and column information - errorBuilder.WriteString(t.ErrorString(target, msg)) - - // Find the start and end positions of the line containing the target token - lineStart := 0 - lineEnd := len(t.input) - for i := target.Line - 1; i > 0; i-- { - if pos := strings.LastIndexByte(t.input[:lineStart], '\n'); pos != -1 { - lineStart = pos + 1 - break - } - } - if pos := strings.IndexByte(t.input[lineStart:], '\n'); pos != -1 { - lineEnd = lineStart + pos - } - - // Extract the line containing the target token - line := t.input[lineStart:lineEnd] - - // Calculate the number of spaces before the target token - for _, token := range t.tokens { - errorBuilder.WriteString(line) - errorBuilder.WriteString("\n") - spaces := strings.Repeat(" ", token.Column) - dots := "" - if token.Len > 0 { - dots = strings.Repeat(".", token.Len-1) - } - errorBuilder.WriteString(spaces) - errorBuilder.WriteString(fmt.Sprintf("^%s %s\n", dots, tokens[token.Token])) - } - - return errorBuilder.String() + var errorBuilder strings.Builder + var token TokenInfo + if target == nil { + // grab last token (as value) + token = t.tokens[len(t.tokens)-1] + // set column to +1 + token.Column++ + target = &token + } + // Write the error message with line and column information + errorBuilder.WriteString(t.ErrorString(target, msg)) + + // Find the start and end positions of the line containing the target token + lineStart := 0 + lineEnd := len(t.input) + for i := target.Line - 1; i > 0; i-- { + if pos := strings.LastIndexByte(t.input[:lineStart], '\n'); pos != -1 { + lineStart = pos + 1 + break + } + } + if pos := strings.IndexByte(t.input[lineStart:], '\n'); pos != -1 { + lineEnd = lineStart + pos + } + + // Extract the line containing the target token + line := t.input[lineStart:lineEnd] + + // Calculate the number of spaces before the target token + for _, token := range t.tokens { + errorBuilder.WriteString(line) + errorBuilder.WriteString("\n") + spaces := strings.Repeat(" ", token.Column) + dots := "" + if token.Len > 0 { + dots = strings.Repeat(".", token.Len-1) + } + errorBuilder.WriteString(spaces) + errorBuilder.WriteString(fmt.Sprintf("^%s %s\n", dots, tokens[token.Token])) + } + + return errorBuilder.String() } // TokenInfo represents a token and its associated information. type TokenInfo struct { - Token Token - Line int - Column int - Literal string - Len int + Token Token + Line int + Column int + Literal string + Len int } // Tokens represents the list of tokens @@ -408,512 +417,622 @@ type Tokens []TokenInfo // Tokenizer represents a JSONPath tokenizer. type Tokenizer struct { - input string - pos int - line int - column int - tokens []TokenInfo - stack []Token - illegalWhitespace bool - config config.Config - bracketFilterState []bool // lazy-init: nil until first BRACKET_LEFT; tracks filter context per bracket depth + input string + pos int + line int + column int + tokens []TokenInfo + stack []Token + illegalWhitespace bool + config config.Config + bracketFilterState []bool // lazy-init: nil until first BRACKET_LEFT; tracks filter context per bracket depth } // NewTokenizer creates a new JSONPath tokenizer for the given input string. func NewTokenizer(input string, opts ...config.Option) *Tokenizer { - cfg := config.New(opts...) - return &Tokenizer{ - input: input, - config: cfg, - line: 1, - stack: make([]Token, 0), - } + return NewTokenizerWithConfig(input, config.New(opts...)) +} + +// NewTokenizerWithConfig creates a tokenizer using an already resolved +// configuration. It avoids resolving the same options again when a caller +// owns both tokenization and parsing. +func NewTokenizerWithConfig(input string, cfg config.Config) *Tokenizer { + if cfg == nil { + cfg = config.New() + } + return &Tokenizer{ + input: input, + config: cfg, + line: 1, + stack: make([]Token, 0), + } } // Tokenize tokenizes the input string and returns a slice of TokenInfo. func (t *Tokenizer) Tokenize() Tokens { - for t.pos < len(t.input) { - if !t.illegalWhitespace { - t.skipWhitespace() - } - if t.pos >= len(t.input) { - break - } - - switch ch := t.input[t.pos]; { - case ch == '$': - t.addToken(ROOT, 1, "") - case ch == '@': - // Check for JSONPath Plus context variables when enabled - handled := false - if t.config.JSONPathPlusEnabled() { - if contextToken, length := t.tryContextVariable(); contextToken != ILLEGAL { - t.addToken(contextToken, length, "") - // Advance past the token (minus 1 because main loop does pos++) - t.pos += length - 1 - t.column += length - 1 - handled = true - } - } - if !handled { - t.addToken(CURRENT, 1, "") - } - case ch == '*': - t.addToken(WILDCARD, 1, "") - case ch == '~': - if t.config.PropertyNameEnabled() { - t.addToken(PROPERTY_NAME, 1, "") - } else { - t.addToken(ILLEGAL, 1, "invalid property name token without config.PropertyNameExtension set to true") - } - case ch == '^': - // JSONPath Plus parent selector - if t.config.JSONPathPlusEnabled() { - t.addToken(PARENT_SELECTOR, 1, "") - } else { - t.addToken(ILLEGAL, 1, "parent selector ^ requires JSONPath Plus mode (enabled by default, disabled with StrictRFC9535)") - } - case ch == '.': - if t.peek() == '.' { - t.addToken(RECURSIVE, 2, "") - t.pos++ - t.column++ - t.illegalWhitespace = true - } else { - t.addToken(CHILD, 1, "") - t.illegalWhitespace = true - } - case ch == ',': - t.addToken(COMMA, 1, "") - case ch == ':': - t.addToken(ARRAY_SLICE, 1, "") - case ch == '?': - t.addToken(FILTER, 1, "") - // Mark current bracket as filter context - if len(t.bracketFilterState) > 0 { - t.bracketFilterState[len(t.bracketFilterState)-1] = true - } - case ch == '(': - t.addToken(PAREN_LEFT, 1, "") - t.stack = append(t.stack, PAREN_LEFT) - case ch == ')': - t.addToken(PAREN_RIGHT, 1, "") - if len(t.stack) > 0 && t.stack[len(t.stack)-1] == PAREN_LEFT { - t.stack = t.stack[:len(t.stack)-1] - } else { - t.addToken(ILLEGAL, 1, "unmatched closing parenthesis") - } - case ch == '[': - t.addToken(BRACKET_LEFT, 1, "") - t.stack = append(t.stack, BRACKET_LEFT) - // Lazy-init bracketFilterState on first bracket - if t.bracketFilterState == nil { - t.bracketFilterState = make([]bool, 0, 4) - } - // Inherit parent filter state: if parent is in filter context, so is this bracket - inFilter := len(t.bracketFilterState) > 0 && t.bracketFilterState[len(t.bracketFilterState)-1] - t.bracketFilterState = append(t.bracketFilterState, inFilter) - case ch == ']': - if len(t.stack) > 0 && t.stack[len(t.stack)-1] == BRACKET_LEFT { - t.addToken(BRACKET_RIGHT, 1, "") - t.stack = t.stack[:len(t.stack)-1] - // Pop bracket filter state - if len(t.bracketFilterState) > 0 { - t.bracketFilterState = t.bracketFilterState[:len(t.bracketFilterState)-1] - } - } else { - t.addToken(ILLEGAL, 1, "unmatched closing bracket") - } - case ch == '&': - if t.peek() == '&' { - t.addToken(AND, 2, "") - t.pos++ - t.column++ - } else { - t.addToken(ILLEGAL, 1, "invalid token") - } - case ch == '|': - if t.peek() == '|' { - t.addToken(OR, 2, "") - t.pos++ - t.column++ - } else { - t.addToken(ILLEGAL, 1, "invalid token") - } - case ch == '!': - if t.peek() == '=' { - // Check for JavaScript !== (strict not-equals) - treat as RFC 9535 != - if t.pos+2 < len(t.input) && t.input[t.pos+2] == '=' { - t.addToken(NE, 3, "") // !== becomes != - t.pos += 2 - t.column += 2 - } else { - t.addToken(NE, 2, "") - t.pos++ - t.column++ - } - } else { - t.addToken(NOT, 1, "") - } - case ch == '=': - if t.peek() == '=' { - // Check for JavaScript === (strict equals) - treat as RFC 9535 == - if t.pos+2 < len(t.input) && t.input[t.pos+2] == '=' { - t.addToken(EQ, 3, "") // === becomes == - t.pos += 2 - t.column += 2 - } else { - t.addToken(EQ, 2, "") - t.pos++ - t.column++ - } - } else if t.peek() == '~' { - t.addToken(MATCHES, 2, "") - t.pos++ - t.column++ - } else { - t.addToken(ILLEGAL, 1, "invalid token") - } - case ch == '>': - if t.peek() == '=' { - t.addToken(GE, 2, "") - t.pos++ - t.column++ - } else { - t.addToken(GT, 1, "") - } - case ch == '<': - if t.peek() == '=' { - t.addToken(LE, 2, "") - t.pos++ - t.column++ - } else { - t.addToken(LT, 1, "") - } - case ch == '"' || ch == '\'': - t.scanString(rune(ch)) - case ch == '-' && isDigit(t.peek()): - fallthrough - case isDigit(ch): - t.scanNumber() - case isLiteralChar(ch): - if t.config.JSONPathPlusEnabled() && t.isInsideBracket() && !t.isInFilterContext() { - t.scanUnquotedBracketString() - } else { - t.scanLiteral() - } - default: - // JSONPath Plus: handle special characters inside brackets as unquoted strings - // e.g., application/vnd.api+json where / and + would otherwise be ILLEGAL - if t.config.JSONPathPlusEnabled() && t.isInsideBracket() && !t.isInFilterContext() && isUnquotedBracketStartChar(ch) { - t.scanUnquotedBracketString() - } else { - t.addToken(ILLEGAL, 1, string(ch)) - } - } - t.pos++ - t.column++ - } - - if len(t.stack) > 0 { - t.addToken(ILLEGAL, 1, fmt.Sprintf("unmatched %s", t.stack[len(t.stack)-1].String())) - } - return t.tokens + for t.pos < len(t.input) { + if !t.illegalWhitespace { + t.skipWhitespace() + } + if t.pos >= len(t.input) { + break + } + + switch ch := t.input[t.pos]; { + case ch == '$': + if config.SpectralCompatibilityEnabled(t.config) && t.spectralDollarMemberCanStartHere() { + t.addToken(STRING, 4, "$ref") + t.pos += 3 + t.column += 3 + } else { + t.addToken(ROOT, 1, "") + } + case ch == '@': + // Check for JSONPath Plus context variables when enabled + handled := false + if t.config.JSONPathPlusEnabled() { + if contextToken, length := t.tryContextVariable(); contextToken != ILLEGAL { + t.addToken(contextToken, length, "") + // Advance past the token (minus 1 because main loop does pos++) + t.pos += length - 1 + t.column += length - 1 + handled = true + } + } + if !handled { + t.addToken(CURRENT, 1, "") + } + case ch == '*': + t.addToken(WILDCARD, 1, "") + case ch == '~': + if t.config.PropertyNameEnabled() { + t.addToken(PROPERTY_NAME, 1, "") + } else { + t.addToken(ILLEGAL, 1, "invalid property name token without config.PropertyNameExtension set to true") + } + case ch == '^': + // JSONPath Plus parent selector + if t.config.JSONPathPlusEnabled() { + t.addToken(PARENT_SELECTOR, 1, "") + } else { + t.addToken(ILLEGAL, 1, "parent selector ^ requires JSONPath Plus mode (enabled by default, disabled with StrictRFC9535)") + } + case ch == '.': + if t.peek() == '.' { + t.addToken(RECURSIVE, 2, "") + t.pos++ + t.column++ + t.illegalWhitespace = true + } else { + t.addToken(CHILD, 1, "") + t.illegalWhitespace = true + } + case ch == ',': + t.addToken(COMMA, 1, "") + case ch == ':': + t.addToken(ARRAY_SLICE, 1, "") + case ch == '?': + t.addToken(FILTER, 1, "") + // Mark current bracket as filter context + if len(t.bracketFilterState) > 0 { + t.bracketFilterState[len(t.bracketFilterState)-1] = true + } + case ch == '(': + t.addToken(PAREN_LEFT, 1, "") + t.stack = append(t.stack, PAREN_LEFT) + case ch == ')': + t.addToken(PAREN_RIGHT, 1, "") + if len(t.stack) > 0 && t.stack[len(t.stack)-1] == PAREN_LEFT { + t.stack = t.stack[:len(t.stack)-1] + } else { + t.addToken(ILLEGAL, 1, "unmatched closing parenthesis") + } + case ch == '[': + t.addToken(BRACKET_LEFT, 1, "") + t.stack = append(t.stack, BRACKET_LEFT) + // Lazy-init bracketFilterState on first bracket + if t.bracketFilterState == nil { + t.bracketFilterState = make([]bool, 0, 4) + } + // Inherit parent filter state: if parent is in filter context, so is this bracket + inFilter := len(t.bracketFilterState) > 0 && t.bracketFilterState[len(t.bracketFilterState)-1] + t.bracketFilterState = append(t.bracketFilterState, inFilter) + case ch == ']': + if len(t.stack) > 0 && t.stack[len(t.stack)-1] == BRACKET_LEFT { + t.addToken(BRACKET_RIGHT, 1, "") + t.stack = t.stack[:len(t.stack)-1] + // Pop bracket filter state + if len(t.bracketFilterState) > 0 { + t.bracketFilterState = t.bracketFilterState[:len(t.bracketFilterState)-1] + } + } else { + t.addToken(ILLEGAL, 1, "unmatched closing bracket") + } + case ch == '&': + if t.peek() == '&' { + t.addToken(AND, 2, "") + t.pos++ + t.column++ + } else { + t.addToken(ILLEGAL, 1, "invalid token") + } + case ch == '|': + if t.peek() == '|' { + t.addToken(OR, 2, "") + t.pos++ + t.column++ + } else { + t.addToken(ILLEGAL, 1, "invalid token") + } + case ch == '!': + if t.peek() == '=' { + if t.pos+2 < len(t.input) && t.input[t.pos+2] == '=' { + if config.SpectralCompatibilityEnabled(t.config) { + t.addToken(STRICT_NE, 3, "") + } else if !t.config.JSONPathPlusEnabled() { + t.addToken(ILLEGAL, 3, "strict JavaScript inequality is not part of RFC 9535") + } else { + t.addToken(NE, 3, "") + } + t.pos += 2 + t.column += 2 + } else { + t.addToken(NE, 2, "") + t.pos++ + t.column++ + } + } else { + t.addToken(NOT, 1, "") + } + case ch == '=': + if t.peek() == '=' { + if t.pos+2 < len(t.input) && t.input[t.pos+2] == '=' { + if config.SpectralCompatibilityEnabled(t.config) { + t.addToken(STRICT_EQ, 3, "") + } else if !t.config.JSONPathPlusEnabled() { + t.addToken(ILLEGAL, 3, "strict JavaScript equality is not part of RFC 9535") + } else { + t.addToken(EQ, 3, "") + } + t.pos += 2 + t.column += 2 + } else { + t.addToken(EQ, 2, "") + t.pos++ + t.column++ + } + } else if t.peek() == '~' { + t.addToken(MATCHES, 2, "") + t.pos++ + t.column++ + } else { + message := "invalid token" + if config.SpectralCompatibilityEnabled(t.config) && t.isInFilterContext() { + message = "assignments are not supported" + } + t.addToken(ILLEGAL, 1, message) + } + case ch == '>': + if t.peek() == '=' { + t.addToken(GE, 2, "") + t.pos++ + t.column++ + } else { + t.addToken(GT, 1, "") + } + case ch == '<': + if t.peek() == '=' { + t.addToken(LE, 2, "") + t.pos++ + t.column++ + } else { + t.addToken(LT, 1, "") + } + case ch == '"' || ch == '\'': + t.scanString(rune(ch)) + case ch == '/' && config.SpectralCompatibilityEnabled(t.config) && t.isInFilterContext() && t.regexCanStartHere(): + t.scanRegex() + case ch == '-' && isDigit(t.peek()): + fallthrough + case isDigit(ch): + t.scanNumber() + case isLiteralChar(ch): + if t.config.JSONPathPlusEnabled() && t.isInsideBracket() && !t.isInFilterContext() { + t.scanUnquotedBracketString() + } else { + t.scanLiteral() + } + default: + // JSONPath Plus: handle special characters inside brackets as unquoted strings + // e.g., application/vnd.api+json where / and + would otherwise be ILLEGAL + if t.config.JSONPathPlusEnabled() && t.isInsideBracket() && !t.isInFilterContext() && isUnquotedBracketStartChar(ch) { + t.scanUnquotedBracketString() + } else { + message := string(ch) + if config.SpectralCompatibilityEnabled(t.config) && t.isInFilterContext() && ch == ';' { + message = "statement separators are not supported" + } else if config.SpectralCompatibilityEnabled(t.config) && t.isInFilterContext() && (ch == '{' || ch == '}') { + message = "function declarations and statement blocks are not supported" + } + t.addToken(ILLEGAL, 1, message) + } + } + t.pos++ + t.column++ + } + + if len(t.stack) > 0 { + t.addToken(ILLEGAL, 1, fmt.Sprintf("unmatched %s", t.stack[len(t.stack)-1].String())) + } + return t.tokens +} + +func (t *Tokenizer) spectralDollarMemberCanStartHere() bool { + if len(t.tokens) == 0 || t.tokens[len(t.tokens)-1].Token != CHILD || !strings.HasPrefix(t.input[t.pos:], "$ref") { + return false + } + end := t.pos + len("$ref") + return end == len(t.input) || !isLiteralChar(t.input[end]) && !isDigit(t.input[end]) +} + +// regexCanStartHere distinguishes a Spectral regex primary from an unquoted +// bracket member. A regex may begin only where an expression or argument is +// expected. +func (t *Tokenizer) regexCanStartHere() bool { + if len(t.tokens) == 0 { + return false + } + switch t.tokens[len(t.tokens)-1].Token { + case PAREN_LEFT, COMMA, AND, OR, NOT, EQ, NE, STRICT_EQ, STRICT_NE, GT, GE, LT, LE: + return true + default: + return false + } +} + +// scanRegex scans a JavaScript-style regex literal without interpreting its +// pattern. Compatibility validation and compilation happen once in the parser. +func (t *Tokenizer) scanRegex() { + start := t.pos + inClass := false + escaped := false + for i := start + 1; i < len(t.input); i++ { + ch := t.input[i] + if ch == '\n' || ch == '\r' { + t.addToken(ILLEGAL, i-start, "unterminated regex literal") + t.pos = i - 1 + t.column += i - start - 1 + return + } + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + switch ch { + case '[': + inClass = true + case ']': + inClass = false + case '/': + if inClass { + continue + } + end := i + 1 + for end < len(t.input) && isLiteralChar(t.input[end]) { + end++ + } + t.addToken(REGEX, end-start, t.input[start:end]) + t.pos = end - 1 + t.column += end - start - 1 + return + } + } + msg := "unterminated regex literal" + if inClass { + msg = "unterminated regex character class" + } + t.addToken(ILLEGAL, len(t.input)-start, msg) + t.pos = len(t.input) - 1 + t.column += len(t.input) - start - 1 } func (t *Tokenizer) addToken(token Token, len int, literal string) { - t.tokens = append(t.tokens, TokenInfo{ - Token: token, - Line: t.line, - Column: t.column, - Len: len, - Literal: literal, - }) - t.illegalWhitespace = false + t.tokens = append(t.tokens, TokenInfo{ + Token: token, + Line: t.line, + Column: t.column, + Len: len, + Literal: literal, + }) + t.illegalWhitespace = false } func (t *Tokenizer) scanString(quote rune) { - start := t.pos + 1 - var literal strings.Builder + start := t.pos + 1 + var literal strings.Builder illegal: - for i := start; i < len(t.input); i++ { - if t.input[i] == byte(quote) { - t.addToken(STRING_LITERAL, len(t.input[start:i])+2, literal.String()) - t.pos = i - t.column += i - start + 1 - return - } - if t.input[i] == '\\' { - i++ - if i >= len(t.input) { - t.addToken(ILLEGAL, len(t.input[start:]), literal.String()) - t.pos = len(t.input) - 1 - t.column = len(t.input) - 1 - return - } - switch t.input[i] { - case 'b': - literal.WriteByte('\b') - case 'f': - literal.WriteByte('\f') - case 'n': - literal.WriteByte('\n') - case 'r': - literal.WriteByte('\n') - case 't': - literal.WriteByte('\t') - case '\'': - if quote != '\'' { - // don't escape it, when we're not in a single quoted string - break illegal - } else { - literal.WriteByte(t.input[i]) - } - case '"': - if quote != '"' { - // don't escape it, when we're not in a single quoted string - break illegal - } else { - literal.WriteByte(t.input[i]) - } - case '\\', '/': - literal.WriteByte(t.input[i]) - default: - break illegal - } - } else { - literal.WriteByte(t.input[i]) - } - } - t.addToken(ILLEGAL, len(t.input[start:]), literal.String()) - t.pos = len(t.input) - 1 - t.column = len(t.input) - 1 + for i := start; i < len(t.input); i++ { + if t.input[i] == byte(quote) { + t.addToken(STRING_LITERAL, len(t.input[start:i])+2, literal.String()) + t.pos = i + t.column += i - start + 1 + return + } + if t.input[i] == '\\' { + i++ + if i >= len(t.input) { + t.addToken(ILLEGAL, len(t.input[start:]), literal.String()) + t.pos = len(t.input) - 1 + t.column = len(t.input) - 1 + return + } + switch t.input[i] { + case 'b': + literal.WriteByte('\b') + case 'f': + literal.WriteByte('\f') + case 'n': + literal.WriteByte('\n') + case 'r': + literal.WriteByte('\n') + case 't': + literal.WriteByte('\t') + case '\'': + if quote != '\'' { + // don't escape it, when we're not in a single quoted string + break illegal + } else { + literal.WriteByte(t.input[i]) + } + case '"': + if quote != '"' { + // don't escape it, when we're not in a single quoted string + break illegal + } else { + literal.WriteByte(t.input[i]) + } + case '\\', '/': + literal.WriteByte(t.input[i]) + default: + break illegal + } + } else { + literal.WriteByte(t.input[i]) + } + } + t.addToken(ILLEGAL, len(t.input[start:]), literal.String()) + t.pos = len(t.input) - 1 + t.column = len(t.input) - 1 } func (t *Tokenizer) scanNumber() { - start := t.pos - tokenType := INTEGER - dotSeen := false - exponentSeen := false - - for i := start; i < len(t.input); i++ { - if i == start && t.input[i] == '-' { - continue - } - - if t.input[i] == '.' { - if dotSeen || exponentSeen { - t.addToken(ILLEGAL, len(t.input[start:i]), t.input[start:i]) - t.pos = i - t.column += i - start - return - } - // Peek ahead: if '.' is NOT followed by a digit, it's a CHILD separator, - // not a decimal point. Stop the number scan here. - if i+1 >= len(t.input) || !isDigit(t.input[i+1]) { - literal := t.input[start:i] - t.addToken(tokenType, len(literal), literal) - t.pos = i - 1 - t.column += i - start - 1 - return - } - tokenType = FLOAT - dotSeen = true - continue - } - - if t.input[i] == 'e' || t.input[i] == 'E' { - if exponentSeen || (len(t.input) > 0 && t.input[i-1] == '.') { - t.addToken(ILLEGAL, len(t.input[start:i]), t.input[start:i]) - t.pos = i - t.column += i - start - return - } - tokenType = FLOAT - exponentSeen = true - if i+1 < len(t.input) && (t.input[i+1] == '+' || t.input[i+1] == '-') { - i++ - } - continue - } - - if !isDigit(t.input[i]) { - literal := t.input[start:i] - // check for legal numbers - _, err := strconv.ParseFloat(literal, 64) - if err != nil { - tokenType = ILLEGAL - } - // conformance spec - if len(literal) > 1 && literal[0] == '0' && !dotSeen { - // no leading zero - tokenType = ILLEGAL - } else if len(literal) > 2 && literal[0] == '-' && literal[1] == '0' && !dotSeen { - // no negative zero without fraction - tokenType = ILLEGAL - } else if len(literal) > 0 && literal[len(literal)-1] == '.' { - // no trailing dot - tokenType = ILLEGAL - } else if literal[len(literal)-1] == 'e' || literal[len(literal)-1] == 'E' { - // no exponent - tokenType = ILLEGAL - } - - t.addToken(tokenType, len(literal), literal) - t.pos = i - 1 - t.column += i - start - 1 - return - } - } - - if exponentSeen && !isDigit(t.input[len(t.input)-1]) { - t.addToken(ILLEGAL, len(t.input[start:]), t.input[start:]) - t.pos = len(t.input) - 1 - t.column = len(t.input) - 1 - return - } - - literal := t.input[start:] - t.addToken(tokenType, len(literal), literal) - t.pos = len(t.input) - 1 - t.column = len(t.input) - 1 + start := t.pos + tokenType := INTEGER + dotSeen := false + exponentSeen := false + + for i := start; i < len(t.input); i++ { + if i == start && t.input[i] == '-' { + continue + } + + if t.input[i] == '.' { + if dotSeen || exponentSeen { + t.addToken(ILLEGAL, len(t.input[start:i]), t.input[start:i]) + t.pos = i + t.column += i - start + return + } + // Peek ahead: if '.' is NOT followed by a digit, it's a CHILD separator, + // not a decimal point. Stop the number scan here. + if i+1 >= len(t.input) || !isDigit(t.input[i+1]) { + literal := t.input[start:i] + t.addToken(tokenType, len(literal), literal) + t.pos = i - 1 + t.column += i - start - 1 + return + } + tokenType = FLOAT + dotSeen = true + continue + } + + if t.input[i] == 'e' || t.input[i] == 'E' { + if exponentSeen || (len(t.input) > 0 && t.input[i-1] == '.') { + t.addToken(ILLEGAL, len(t.input[start:i]), t.input[start:i]) + t.pos = i + t.column += i - start + return + } + tokenType = FLOAT + exponentSeen = true + if i+1 < len(t.input) && (t.input[i+1] == '+' || t.input[i+1] == '-') { + i++ + } + continue + } + + if !isDigit(t.input[i]) { + literal := t.input[start:i] + // check for legal numbers + _, err := strconv.ParseFloat(literal, 64) + if err != nil { + tokenType = ILLEGAL + } + // conformance spec + if len(literal) > 1 && literal[0] == '0' && !dotSeen { + // no leading zero + tokenType = ILLEGAL + } else if len(literal) > 2 && literal[0] == '-' && literal[1] == '0' && !dotSeen { + // no negative zero without fraction + tokenType = ILLEGAL + } else if len(literal) > 0 && literal[len(literal)-1] == '.' { + // no trailing dot + tokenType = ILLEGAL + } else if literal[len(literal)-1] == 'e' || literal[len(literal)-1] == 'E' { + // no exponent + tokenType = ILLEGAL + } + + t.addToken(tokenType, len(literal), literal) + t.pos = i - 1 + t.column += i - start - 1 + return + } + } + + if exponentSeen && !isDigit(t.input[len(t.input)-1]) { + t.addToken(ILLEGAL, len(t.input[start:]), t.input[start:]) + t.pos = len(t.input) - 1 + t.column = len(t.input) - 1 + return + } + + literal := t.input[start:] + t.addToken(tokenType, len(literal), literal) + t.pos = len(t.input) - 1 + t.column = len(t.input) - 1 } func (t *Tokenizer) scanLiteral() { - start := t.pos - for i := start; i < len(t.input); i++ { - if !isLiteralChar(t.input[i]) && !isDigit(t.input[i]) { - literal := t.input[start:i] - switch literal { - case "true": - t.addToken(TRUE, len(literal), literal) - case "false": - t.addToken(FALSE, len(literal), literal) - case "null": - t.addToken(NULL, len(literal), literal) - default: - // Only treat as FUNCTION if it's a function name AND followed by '(' - // Otherwise it's a property name (STRING) - if isFunctionName(literal) && i < len(t.input) && t.input[i] == '(' { - t.addToken(FUNCTION, len(literal), literal) - t.illegalWhitespace = true - } else { - t.addToken(STRING, len(literal), literal) - } - } - t.pos = i - 1 - t.column += i - start - 1 - return - } - } - literal := t.input[start:] - switch literal { - case "true": - t.addToken(TRUE, len(literal), literal) - case "false": - t.addToken(FALSE, len(literal), literal) - case "null": - t.addToken(NULL, len(literal), literal) - default: - t.addToken(STRING, len(literal), literal) - } - t.pos = len(t.input) - 1 - t.column = len(t.input) - 1 + start := t.pos + for i := start; i < len(t.input); i++ { + if !isLiteralChar(t.input[i]) && !isDigit(t.input[i]) { + literal := t.input[start:i] + switch literal { + case "true": + t.addToken(TRUE, len(literal), literal) + case "false": + t.addToken(FALSE, len(literal), literal) + case "null": + t.addToken(NULL, len(literal), literal) + default: + // Only treat as FUNCTION if it's a function name AND followed by '(' + // Otherwise it's a property name (STRING) + if isFunctionName(literal) && i < len(t.input) && t.input[i] == '(' { + t.addToken(FUNCTION, len(literal), literal) + t.illegalWhitespace = true + } else { + t.addToken(STRING, len(literal), literal) + } + } + t.pos = i - 1 + t.column += i - start - 1 + return + } + } + literal := t.input[start:] + switch literal { + case "true": + t.addToken(TRUE, len(literal), literal) + case "false": + t.addToken(FALSE, len(literal), literal) + case "null": + t.addToken(NULL, len(literal), literal) + default: + t.addToken(STRING, len(literal), literal) + } + t.pos = len(t.input) - 1 + t.column = len(t.input) - 1 } func isFunctionName(literal string) bool { - switch literal { - // RFC 9535 standard functions - case "length", "count", "match", "search", "value": - return true - // JSONPath Plus type selector functions - case "isNull", "isBoolean", "isNumber", "isString", "isArray", "isObject", "isInteger": - return true - } - return false + switch literal { + // RFC 9535 standard functions + case "length", "count", "match", "search", "value": + return true + // JSONPath Plus type selector functions + case "isNull", "isBoolean", "isNumber", "isString", "isArray", "isObject", "isInteger": + return true + } + return false } func (t *Tokenizer) skipWhitespace() { - // S = *B ; optional blank space - // B = %x20 / ; Space - // %x09 / ; Horizontal tab - // %x0A / ; Line feed or New line - // %x0D ; Carriage return - for len(t.tokens) > 0 && t.pos+1 < len(t.input) { - ch := t.input[t.pos] - if ch == '\n' { - t.line++ - t.pos++ - t.column = 0 - } else if !isSpace(ch) { - break - } else { - t.pos++ - t.column++ - } - } + // S = *B ; optional blank space + // B = %x20 / ; Space + // %x09 / ; Horizontal tab + // %x0A / ; Line feed or New line + // %x0D ; Carriage return + for len(t.tokens) > 0 && t.pos+1 < len(t.input) { + ch := t.input[t.pos] + if ch == '\n' { + t.line++ + t.pos++ + t.column = 0 + } else if !isSpace(ch) { + break + } else { + t.pos++ + t.column++ + } + } } func (t *Tokenizer) peek() byte { - if t.pos+1 < len(t.input) { - return t.input[t.pos+1] - } - return 0 + if t.pos+1 < len(t.input) { + return t.input[t.pos+1] + } + return 0 } // isInsideBracket returns true if the tokenizer is currently inside a bracket pair. // Nil-safe: returns false when bracketFilterState has not been initialized. func (t *Tokenizer) isInsideBracket() bool { - return len(t.bracketFilterState) > 0 + return len(t.bracketFilterState) > 0 } // isInFilterContext returns true if the current bracket context is a filter expression. func (t *Tokenizer) isInFilterContext() bool { - return len(t.bracketFilterState) > 0 && t.bracketFilterState[len(t.bracketFilterState)-1] + return len(t.bracketFilterState) > 0 && t.bracketFilterState[len(t.bracketFilterState)-1] } // scanUnquotedBracketString scans an unquoted string inside brackets (JSONPath Plus extension). // Handles values like: get, post, application/vnd.api+json, default, etc. // Zero-allocation: uses direct substring of t.input, matching scanLiteral's pattern. func (t *Tokenizer) scanUnquotedBracketString() { - start := t.pos - end := start - for end < len(t.input) { - ch := t.input[end] - if ch == ']' || ch == ',' || ch == '[' || ch == '\'' || ch == '"' || ch == '?' { - break - } - if isSpace(ch) { - break - } - end++ - } - // Trim trailing whitespace by adjusting end index - trimEnd := end - for trimEnd > start && isSpace(t.input[trimEnd-1]) { - trimEnd-- - } - if trimEnd <= start { - t.addToken(ILLEGAL, 1, string(t.input[t.pos])) - return - } - literal := t.input[start:trimEnd] - t.addToken(STRING_LITERAL, len(literal), literal) - t.pos = end - 1 - t.column += end - start - 1 + start := t.pos + end := start + for end < len(t.input) { + ch := t.input[end] + if ch == ']' || ch == ',' || ch == '[' || ch == '\'' || ch == '"' || ch == '?' { + break + } + if isSpace(ch) { + break + } + end++ + } + // Trim trailing whitespace by adjusting end index + trimEnd := end + for trimEnd > start && isSpace(t.input[trimEnd-1]) { + trimEnd-- + } + if trimEnd <= start { + t.addToken(ILLEGAL, 1, string(t.input[t.pos])) + return + } + literal := t.input[start:trimEnd] + t.addToken(STRING_LITERAL, len(literal), literal) + t.pos = end - 1 + t.column += end - start - 1 } func isDigit(ch byte) bool { - return '0' <= ch && ch <= '9' + return '0' <= ch && ch <= '9' } func isLiteralChar(ch byte) bool { - // allow unicode characters - return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch >= 0x80 + // allow unicode characters + return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch >= 0x80 } func isSpace(ch byte) bool { - return ch == ' ' || ch == '\t' || ch == '\r' + return ch == ' ' || ch == '\t' || ch == '\r' } // isUnquotedBracketStartChar returns true if ch can start an unquoted bracket string @@ -922,50 +1041,50 @@ func isSpace(ch byte) bool { // Characters like + that appear mid-value (e.g., application/vnd.api+json) are handled // by scanUnquotedBracketString which scans until a delimiter is found. func isUnquotedBracketStartChar(ch byte) bool { - return ch == '/' || ch == '%' || ch == '#' + return ch == '/' || ch == '%' || ch == '#' } // contextVariableKeywords maps context variable names to their token types. // These are JSONPath Plus extensions for accessing filter context. var contextVariableKeywords = map[string]Token{ - "property": CONTEXT_PROPERTY, - "root": CONTEXT_ROOT, - "parent": CONTEXT_PARENT, - "parentProperty": CONTEXT_PARENT_PROPERTY, - "path": CONTEXT_PATH, - "index": CONTEXT_INDEX, + "property": CONTEXT_PROPERTY, + "root": CONTEXT_ROOT, + "parent": CONTEXT_PARENT, + "parentProperty": CONTEXT_PARENT_PROPERTY, + "path": CONTEXT_PATH, + "index": CONTEXT_INDEX, } // tryContextVariable checks if the current position starts a context variable. // It returns the token type and total length (including @) if found, or ILLEGAL and 0 if not. // Context variables are @property, @root, @parent, @parentProperty, @path, @index. func (t *Tokenizer) tryContextVariable() (Token, int) { - // Must start with @ - if t.pos >= len(t.input) || t.input[t.pos] != '@' { - return ILLEGAL, 0 - } - - // Extract the word following @ - start := t.pos + 1 - if start >= len(t.input) { - return ILLEGAL, 0 - } - - // Find the end of the identifier - end := start - for end < len(t.input) && isLiteralChar(t.input[end]) { - end++ - } - - if end == start { - return ILLEGAL, 0 - } - - keyword := t.input[start:end] - if tok, ok := contextVariableKeywords[keyword]; ok { - // Return the token and total length including @ - return tok, end - t.pos - } - - return ILLEGAL, 0 + // Must start with @ + if t.pos >= len(t.input) || t.input[t.pos] != '@' { + return ILLEGAL, 0 + } + + // Extract the word following @ + start := t.pos + 1 + if start >= len(t.input) { + return ILLEGAL, 0 + } + + // Find the end of the identifier + end := start + for end < len(t.input) && isLiteralChar(t.input[end]) { + end++ + } + + if end == start { + return ILLEGAL, 0 + } + + keyword := t.input[start:end] + if tok, ok := contextVariableKeywords[keyword]; ok { + // Return the token and total length including @ + return tok, end - t.pos + } + + return ILLEGAL, 0 } diff --git a/pkg/jsonpath/yaml_query.go b/pkg/jsonpath/yaml_query.go index 79bd242..5c80963 100644 --- a/pkg/jsonpath/yaml_query.go +++ b/pkg/jsonpath/yaml_query.go @@ -151,13 +151,16 @@ func (s *innerSegment) hasParentReferences() bool { } func (s *selector) hasParentReferences() bool { - if s.filter != nil && s.filter.hasParentReferences() { + if s.filter.present() && s.filter.hasParentReferences() { return true } return false } func (f *filterSelector) hasParentReferences() bool { + if f.spectralExpression != nil { + return f.spectralExpression.usage.parent + } if f.expression != nil { return f.expression.hasParentReferences() } @@ -276,6 +279,8 @@ func (s segment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Nod return []*yaml.Node{found} } return []*yaml.Node{} + case segmentKindRecursivePropertyName: + return recursivePropertyNames(idx, value) case segmentKindParent: parent := idx.getParentNode(value) if parent != nil { @@ -286,6 +291,35 @@ func (s segment) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.Nod panic("no segment type") } +func recursivePropertyNames(idx index, value *yaml.Node) []*yaml.Node { + var result []*yaml.Node + if current := idx.getPropertyKey(value); current != nil { + result = append(result, current) + } + var walk func(*yaml.Node) + walk = func(node *yaml.Node) { + if node == nil { + return + } + switch node.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key, child := node.Content[i], node.Content[i+1] + idx.setPropertyKey(key, node) + idx.setPropertyKey(child, key) + result = append(result, key) + walk(child) + } + case yaml.SequenceNode: + for _, child := range node.Content { + walk(child) + } + } + } + walk(value) + return result +} + func unique(nodes []*yaml.Node) []*yaml.Node { res := make([]*yaml.Node, 0) seen := make(map[*yaml.Node]bool) @@ -433,6 +467,9 @@ func (s selector) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.No if trackParents { idx.setParentNode(child, value) } + if hasFc && s.spectral { + fc.SetPropertyName(keyStr) + } return []*yaml.Node{child} } } @@ -468,6 +505,9 @@ func (s selector) Query(idx index, value *yaml.Node, root *yaml.Node) []*yaml.No } else { fc.PushPathSegment(thisSegment) } + if s.spectral { + fc.SetPropertyName(strconv.Itoa(actualIndex)) + } } return []*yaml.Node{child} case selectorSubKindWildcard: @@ -674,6 +714,9 @@ func bounds(start, end *int64, step, length int64) (int64, int64) { } func (s filterSelector) Matches(idx index, node *yaml.Node, root *yaml.Node) bool { + if s.spectralExpression != nil { + return s.spectralExpression.Matches(idx, node, root) + } return s.expression.Matches(idx, node, root) }