From ac47b5e584d596a03178042957fae6765cd95d1b Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 5 Feb 2026 17:09:10 +0100 Subject: [PATCH 01/11] Split PathNode into PathNode and PatternNode Separate concrete paths from wildcard patterns at the type level: - PathNode: for concrete paths without wildcards (used by Get, Set, Walk) - PatternNode: for patterns that may include wildcards (used by WalkType, Validate) - PathNodeBase: shared struct with common fields and methods Add cleaner parsing API: - ParsePath(s) -> (*PathNode, error) - ParsePattern(s) -> (*PatternNode, error) - MustParsePath(s), MustParsePattern(s) for tests Rename validation functions for consistency: - ValidatePath for PathNode - ValidatePattern for PatternNode Update HasChange to accept *PathNode with precalculated paths. Co-Authored-By: Claude Opus 4.5 --- .../config/mutator/log_resource_references.go | 2 +- bundle/configsync/diff.go | 2 +- bundle/configsync/patch.go | 3 +- bundle/configsync/resolve.go | 32 +- bundle/configsync/resolve_test.go | 2 +- bundle/deploy/terraform/tfdyn/spec_fields.go | 2 +- bundle/deployplan/plan.go | 17 +- bundle/direct/bundle_plan.go | 8 +- bundle/direct/dresources/all_test.go | 8 +- .../dresources/model_serving_endpoint.go | 17 +- bundle/direct/dresources/type_test.go | 8 +- bundle/internal/validation/enum.go | 2 +- bundle/internal/validation/required.go | 2 +- cmd/bundle/debug/refschema.go | 2 +- libs/structs/structaccess/get.go | 6 +- libs/structs/structaccess/get_test.go | 2 +- libs/structs/structaccess/set.go | 6 +- libs/structs/structaccess/set_test.go | 63 +-- libs/structs/structaccess/typecheck.go | 54 +- libs/structs/structpath/path.go | 468 ++++++++++++------ libs/structs/structpath/path_test.go | 173 ++++--- libs/structs/structvar/structvar.go | 2 +- libs/structs/structwalk/walk_test.go | 2 +- libs/structs/structwalk/walktype.go | 38 +- .../structs/structwalk/walktype_bench_test.go | 2 +- libs/structs/structwalk/walktype_test.go | 14 +- 26 files changed, 580 insertions(+), 357 deletions(-) diff --git a/bundle/config/mutator/log_resource_references.go b/bundle/config/mutator/log_resource_references.go index 14c6beed49..a18b3aaa24 100644 --- a/bundle/config/mutator/log_resource_references.go +++ b/bundle/config/mutator/log_resource_references.go @@ -117,7 +117,7 @@ func truncate(s string, n int, suffix string) string { func censorValue(ctx context.Context, v any, path dyn.Path) (string, error) { pathString := path.String() - pathNode, err := structpath.Parse(pathString) + pathNode, err := structpath.ParsePath(pathString) if err != nil { log.Warnf(ctx, "internal error: parsing %q: %s", pathString, err) return "err", err diff --git a/bundle/configsync/diff.go b/bundle/configsync/diff.go index 047e3428bd..1e27da9150 100644 --- a/bundle/configsync/diff.go +++ b/bundle/configsync/diff.go @@ -49,7 +49,7 @@ func normalizeValue(v any) (any, error) { } func isEntityPath(path string) bool { - pathNode, err := structpath.Parse(path) + pathNode, err := structpath.ParsePath(path) if err != nil { return false } diff --git a/bundle/configsync/patch.go b/bundle/configsync/patch.go index 701894b451..3a1ae110f8 100644 --- a/bundle/configsync/patch.go +++ b/bundle/configsync/patch.go @@ -229,8 +229,9 @@ func buildNestedMaps(targetPath, missingPath yamlpatch.Path, leafValue any) any // strPathToJSONPointer converts a structpath string to JSON Pointer format. // Example: "resources.jobs.test[0].name" -> "/resources/jobs/test/0/name" +// The path may contain [*] which is converted to "-" (JSON Pointer append syntax). func strPathToJSONPointer(pathStr string) (string, error) { - node, err := structpath.Parse(pathStr) + node, err := structpath.ParsePattern(pathStr) if err != nil { return "", fmt.Errorf("failed to parse path %q: %w", pathStr, err) } diff --git a/bundle/configsync/resolve.go b/bundle/configsync/resolve.go index d2643c8a84..72fca35956 100644 --- a/bundle/configsync/resolve.go +++ b/bundle/configsync/resolve.go @@ -2,7 +2,6 @@ package configsync import ( "context" - "errors" "fmt" "sort" @@ -21,19 +20,20 @@ type FieldChange struct { // resolveSelectors converts key-value selectors to numeric indices that match // the YAML file positions. It also returns the location of the resolved leaf value. // Example: "resources.jobs.foo.tasks[task_key='main'].name" -> "resources.jobs.foo.tasks[1].name" -func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) (*structpath.PathNode, dyn.Location, error) { - node, err := structpath.Parse(pathStr) +// Returns a PatternNode because for Add operations, [*] may be used as a placeholder for new elements. +func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) (*structpath.PatternNode, dyn.Location, error) { + node, err := structpath.ParsePath(pathStr) if err != nil { return nil, dyn.Location{}, fmt.Errorf("failed to parse path %s: %w", pathStr, err) } nodes := node.AsSlice() - var result *structpath.PathNode + var result *structpath.PatternNode currentValue := b.Config.Value() for _, n := range nodes { if key, ok := n.StringKey(); ok { - result = structpath.NewStringKey(result, key) + result = structpath.NewPatternStringKey(result, key) if currentValue.IsValid() { currentValue, _ = dyn.GetByPath(currentValue, dyn.Path{dyn.Key(key)}) } @@ -41,7 +41,7 @@ func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) } if idx, ok := n.Index(); ok { - result = structpath.NewIndex(result, idx) + result = structpath.NewPatternIndex(result, idx) if currentValue.IsValid() { currentValue, _ = dyn.GetByPath(currentValue, dyn.Path{dyn.Index(idx)}) } @@ -71,7 +71,7 @@ func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) if foundIndex == -1 { if operation == OperationAdd { - result = structpath.NewBracketStar(result) + result = structpath.NewPatternBracketStar(result) // Can't navigate further into non-existent element currentValue = dyn.Value{} continue @@ -82,14 +82,10 @@ func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) // Mutators may reorder sequence elements (e.g., tasks sorted by task_key). // Use location information to determine the original YAML file position. yamlIndex := yamlFileIndex(seq, foundIndex) - result = structpath.NewIndex(result, yamlIndex) + result = structpath.NewPatternIndex(result, yamlIndex) currentValue = seq[foundIndex] continue } - - if n.DotStar() || n.BracketStar() { - return nil, dyn.Location{}, errors.New("wildcard patterns are not supported in field paths") - } } return result, currentValue.Location(), nil @@ -120,21 +116,21 @@ func yamlFileIndex(seq []dyn.Value, sortedIndex int) int { } func pathDepth(pathStr string) int { - node, err := structpath.Parse(pathStr) + node, err := structpath.ParsePath(pathStr) if err != nil { return 0 } return len(node.AsSlice()) } -// adjustArrayIndex adjusts the index in a PathNode based on previous operations. +// adjustArrayIndex adjusts the index in a PatternNode based on previous operations. // When operations are applied sequentially, removals and additions shift array indices. // This function adjusts the index to account for those shifts. -func adjustArrayIndex(path *structpath.PathNode, operations map[string][]struct { +func adjustArrayIndex(path *structpath.PatternNode, operations map[string][]struct { index int operation OperationType }, -) *structpath.PathNode { +) *structpath.PatternNode { originalIndex, ok := path.Index() if !ok { return path @@ -162,7 +158,7 @@ func adjustArrayIndex(path *structpath.PathNode, operations map[string][]struct adjustedIndex = 0 } - return structpath.NewIndex(parentPath, adjustedIndex) + return structpath.NewPatternIndex(parentPath, adjustedIndex) } // ResolveChanges resolves selectors and computes field path candidates for each change. @@ -241,7 +237,7 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes if ok && len(indices) > 0 { index := indices[0] indicesToReplaceMap[parentPath] = indices[1:] - resolvedPath = structpath.NewIndex(resolvedPath.Parent(), index) + resolvedPath = structpath.NewPatternIndex(resolvedPath.Parent(), index) } } diff --git a/bundle/configsync/resolve_test.go b/bundle/configsync/resolve_test.go index 214c8da57a..e59ed242b1 100644 --- a/bundle/configsync/resolve_test.go +++ b/bundle/configsync/resolve_test.go @@ -203,7 +203,7 @@ func TestResolveSelectors_WildcardNotSupported(t *testing.T) { _, _, err = resolveSelectors("resources.jobs.test_job.tasks.*.task_key", b, OperationReplace) require.Error(t, err) - assert.Contains(t, err.Error(), "wildcard patterns are not supported") + assert.Contains(t, err.Error(), "wildcards not allowed in path") } func TestYamlFileIndex(t *testing.T) { diff --git a/bundle/deploy/terraform/tfdyn/spec_fields.go b/bundle/deploy/terraform/tfdyn/spec_fields.go index 684b41d367..189e82f4b7 100644 --- a/bundle/deploy/terraform/tfdyn/spec_fields.go +++ b/bundle/deploy/terraform/tfdyn/spec_fields.go @@ -14,7 +14,7 @@ import ( // included without manual updates to the converters. func specFieldNames(v any) []string { var names []string - _ = structwalk.WalkType(reflect.TypeOf(v), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + _ = structwalk.WalkType(reflect.TypeOf(v), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { // Skip root node (path is nil) if path == nil { return true diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index 9ee4d9fe07..22f7db34c7 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -113,26 +113,19 @@ const ( // HasChange checks if there are any changes for fields with the given prefix. // This function is path-aware and correctly handles path component boundaries. // For example: -// - HasChange("a") matches "a" and "a.b" but not "aa" -// - HasChange("config") matches "config" and "config.name" but not "configuration" -// -// Note: This function does not support wildcard patterns. -func (c *Changes) HasChange(fieldPath string) bool { +// - HasChange for path "a" matches "a" and "a.b" but not "aa" +// - HasChange for path "config" matches "config" and "config.name" but not "configuration" +func (c *Changes) HasChange(fieldPath *structpath.PathNode) bool { if c == nil { return false } - fieldPathNode, err := structpath.Parse(fieldPath) - if err != nil { - return false - } - for field := range *c { - fieldNode, err := structpath.Parse(field) + fieldNode, err := structpath.ParsePath(field) if err != nil { continue } - if fieldNode.HasPrefix(fieldPathNode) { + if fieldNode.HasPrefix(fieldPath) { return true } } diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index b3d0674537..cd2d8a18ab 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -365,7 +365,7 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change var toDrop []string for pathString, ch := range changes { - path, err := structpath.Parse(pathString) + path, err := structpath.ParsePath(pathString) if err != nil { return err } @@ -594,8 +594,8 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s return nil, fmt.Errorf("internal error: %s: unknown resource type %q", targetResourceKey, targetGroup) } - configValidErr := structaccess.Validate(reflect.TypeOf(localConfig), fieldPath) - remoteValidErr := structaccess.Validate(adapter.RemoteType(), fieldPath) + configValidErr := structaccess.ValidatePath(reflect.TypeOf(localConfig), fieldPath) + remoteValidErr := structaccess.ValidatePath(adapter.RemoteType(), fieldPath) // Note: using adapter.RemoteType() over reflect.TypeOf(remoteState) because remoteState might be untyped nil if configValidErr != nil && remoteValidErr != nil { @@ -665,7 +665,7 @@ func (b *DeploymentBundle) resolveReferences(ctx context.Context, resourceKey st for _, pathString := range refs.References() { ref := "${" + pathString + "}" - targetPath, err := structpath.Parse(pathString) + targetPath, err := structpath.ParsePath(pathString) if err != nil { logdiag.LogError(ctx, fmt.Errorf("%s: cannot parse reference %q: %w", errorPrefix, ref, err)) return false diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index bcbecdf590..5233ecff47 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -785,7 +785,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W err = adapter.DoDelete(ctx, createdID) require.NoError(t, err) - p, err := structpath.Parse("name") + p, err := structpath.ParsePath("name") require.NoError(t, err) if adapter.HasOverrideChangeDesc() { @@ -842,13 +842,13 @@ func TestGeneratedResourceConfig(t *testing.T) { func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceLifecycleConfig) { for _, p := range cfg.RecreateOnChanges { - assert.NoError(t, structaccess.Validate(stateType, p.Field), "RecreateOnChanges: %s", p.Field) + assert.NoError(t, structaccess.ValidatePath(stateType, p.Field), "RecreateOnChanges: %s", p.Field) } for _, p := range cfg.UpdateIDOnChanges { - assert.NoError(t, structaccess.Validate(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) + assert.NoError(t, structaccess.ValidatePath(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) } for _, p := range cfg.IgnoreRemoteChanges { - assert.NoError(t, structaccess.Validate(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) + assert.NoError(t, structaccess.ValidatePath(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) } } diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index afb2fa1df5..8a8aa126b2 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -6,11 +6,20 @@ import ( "time" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/serving" ) +// Precalculated paths for HasChange checks +var ( + pathTags = structpath.MustParsePath("tags") + pathAiGateway = structpath.MustParsePath("ai_gateway") + pathConfig = structpath.MustParsePath("config") + pathEmailNotifications = structpath.MustParsePath("email_notifications") +) + type ResourceModelServingEndpoint struct { client *databricks.WorkspaceClient } @@ -282,28 +291,28 @@ func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, id string, // Terraform makes these API calls sequentially. We do the same here. // It's an unknown as of 1st Dec 2025 if these APIs are safe to make in parallel. (we did not check) // https://github.com/databricks/terraform-provider-databricks/blob/c61a32300445f84efb2bb6827dee35e6e523f4ff/serving/resource_model_serving.go#L373 - if changes.HasChange("tags") { + if changes.HasChange(pathTags) { err = r.updateTags(ctx, id, config.Tags) if err != nil { return nil, err } } - if changes.HasChange("ai_gateway") { + if changes.HasChange(pathAiGateway) { err = r.updateAiGateway(ctx, id, config.AiGateway) if err != nil { return nil, err } } - if changes.HasChange("config") { + if changes.HasChange(pathConfig) { err = r.updateConfig(ctx, id, config.Config) if err != nil { return nil, err } } - if changes.HasChange("email_notifications") { + if changes.HasChange(pathEmailNotifications) { err = r.updateNotifications(ctx, id, config.EmailNotifications) if err != nil { return nil, err diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 572cd46fd6..6f237f415b 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -115,7 +115,7 @@ func TestInputSubset(t *testing.T) { // Validate that all fields in InputType exist in StateType var missingFields []string - err := structwalk.WalkType(inputType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(inputType, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { if path.IsRoot() { return true } @@ -126,7 +126,7 @@ func TestInputSubset(t *testing.T) { return false // don't recurse into internal/readonly fields } } - if structaccess.Validate(stateType, path) != nil { + if structaccess.ValidatePattern(stateType, path) != nil { missingFields = append(missingFields, path.String()) return false // don't recurse into missing field } @@ -179,11 +179,11 @@ func TestRemoteSuperset(t *testing.T) { // Validate that all fields in StateType exist in RemoteType var missingFields []string - err := structwalk.WalkType(stateType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(stateType, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { if path.IsRoot() { return true } - if structaccess.Validate(remoteType, path) != nil { + if structaccess.ValidatePattern(remoteType, path) != nil { missingFields = append(missingFields, path.String()) return false // don't recurse into missing field } diff --git a/bundle/internal/validation/enum.go b/bundle/internal/validation/enum.go index 3612830fc8..d0c201e909 100644 --- a/bundle/internal/validation/enum.go +++ b/bundle/internal/validation/enum.go @@ -111,7 +111,7 @@ func getEnumValues(typ reflect.Type) ([]string, error) { func extractEnumFields(typ reflect.Type) ([]EnumPatternInfo, error) { fieldsByPattern := make(map[string][]string) - err := structwalk.WalkType(typ, func(path *structpath.PathNode, fieldType reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(typ, func(path *structpath.PatternNode, fieldType reflect.Type, field *reflect.StructField) bool { if path == nil { return true } diff --git a/bundle/internal/validation/required.go b/bundle/internal/validation/required.go index 76465f52d0..b08e0f4065 100644 --- a/bundle/internal/validation/required.go +++ b/bundle/internal/validation/required.go @@ -47,7 +47,7 @@ func formatSliceToString(values []string) string { func extractRequiredFields(typ reflect.Type) ([]RequiredPatternInfo, error) { fieldsByPattern := make(map[string][]string) - err := structwalk.WalkType(typ, func(path *structpath.PathNode, _ reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(typ, func(path *structpath.PatternNode, _ reflect.Type, field *reflect.StructField) bool { if path == nil || field == nil { return true } diff --git a/cmd/bundle/debug/refschema.go b/cmd/bundle/debug/refschema.go index c2ffc17a0c..932b8d62ab 100644 --- a/cmd/bundle/debug/refschema.go +++ b/cmd/bundle/debug/refschema.go @@ -63,7 +63,7 @@ func dumpRemoteSchemas(out io.Writer) error { pathTypes := make(map[string]map[string]map[string]struct{}) collect := func(root reflect.Type, source string) error { - return structwalk.WalkType(root, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + return structwalk.WalkType(root, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { if path == nil { return true } diff --git a/libs/structs/structaccess/get.go b/libs/structs/structaccess/get.go index 0c35cf520d..f9b6b70baf 100644 --- a/libs/structs/structaccess/get.go +++ b/libs/structs/structaccess/get.go @@ -25,7 +25,7 @@ func GetByString(v any, path string) (any, error) { return v, nil } - pathNode, err := structpath.Parse(path) + pathNode, err := structpath.ParsePath(path) if err != nil { return nil, err } @@ -45,9 +45,7 @@ func getValue(v any, path *structpath.PathNode) (reflect.Value, error) { cur := reflect.ValueOf(v) for _, node := range pathSegments { - if node.DotStar() || node.BracketStar() { - return reflect.Value{}, fmt.Errorf("wildcards not supported: %s", path.String()) - } + // Note: wildcards cannot appear in PathNode (Parse rejects them) var ok bool cur, ok = deref(cur) diff --git a/libs/structs/structaccess/get_test.go b/libs/structs/structaccess/get_test.go index c583f9baff..cfc3365b75 100644 --- a/libs/structs/structaccess/get_test.go +++ b/libs/structs/structaccess/get_test.go @@ -184,7 +184,7 @@ func runCommonTests(t *testing.T, obj any) { { name: "wildcard not supported for Get", path: "items[*].id", - getOnlyErr: "wildcards not supported: items[*].id", + getOnlyErr: "wildcards not allowed in path", }, { name: "missing field", diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 1f5857354f..7f12a77751 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -57,7 +57,7 @@ func SetByString(target any, path string, value any) error { return errors.New("cannot set empty path") } - pathNode, err := structpath.Parse(path) + pathNode, err := structpath.ParsePath(path) if err != nil { return err } @@ -81,9 +81,7 @@ func setValueAtNode(parentVal reflect.Value, node *structpath.PathNode, value an return setArrayElement(parentVal, idx, valueVal) } - if node.DotStar() || node.BracketStar() { - return errors.New("wildcards not supported") - } + // Note: wildcards cannot appear in PathNode (Parse rejects them) if key, matchValue, isKeyValue := node.KeyValue(); isKeyValue { return fmt.Errorf("cannot set value at key-value selector [%s='%s'] - key-value syntax can only be used for path traversal, not as a final target", key, matchValue) diff --git a/libs/structs/structaccess/set_test.go b/libs/structs/structaccess/set_test.go index 2156327f71..25674c1211 100644 --- a/libs/structs/structaccess/set_test.go +++ b/libs/structs/structaccess/set_test.go @@ -41,15 +41,6 @@ type TestStruct struct { Internal string `json:"-"` } -// mustParsePath is a helper to parse path strings in tests -func mustParsePath(path string) *structpath.PathNode { - p, err := structpath.Parse(path) - if err != nil { - panic(err) - } - return p -} - // newTestStruct creates a fresh TestStruct instance for testing func newTestStruct() *TestStruct { return &TestStruct{ @@ -89,7 +80,7 @@ func TestSet(t *testing.T) { value: "NewName", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "NewName", }, @@ -101,7 +92,7 @@ func TestSet(t *testing.T) { value: "BracketName", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "BracketName", }, @@ -113,7 +104,7 @@ func TestSet(t *testing.T) { value: 30, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: 30, }, @@ -125,7 +116,7 @@ func TestSet(t *testing.T) { value: "new_version", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("info.version"), + Path: structpath.MustParsePath("info.version"), Old: "old_version", New: "new_version", }, @@ -137,7 +128,7 @@ func TestSet(t *testing.T) { value: 200, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("info.build"), + Path: structpath.MustParsePath("info.build"), Old: 100, New: 200, }, @@ -149,7 +140,7 @@ func TestSet(t *testing.T) { value: "new_map_value", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("tags['version']"), + Path: structpath.MustParsePath("tags['version']"), Old: nil, // new key New: "new_map_value", }, @@ -161,7 +152,7 @@ func TestSet(t *testing.T) { value: "dot_map_value", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("tags['version']"), + Path: structpath.MustParsePath("tags['version']"), Old: nil, // new key New: "dot_map_value", }, @@ -173,7 +164,7 @@ func TestSet(t *testing.T) { value: "new_item", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("items[1]"), + Path: structpath.MustParsePath("items[1]"), Old: "old_b", New: "new_item", }, @@ -185,7 +176,7 @@ func TestSet(t *testing.T) { value: 42, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("count"), + Path: structpath.MustParsePath("count"), Old: nil, // structdiff reports this as interface{}(nil) New: intPtr(42), }, @@ -197,7 +188,7 @@ func TestSet(t *testing.T) { value: "new custom", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("custom"), + Path: structpath.MustParsePath("custom"), Old: CustomString("old custom"), New: CustomString("new custom"), }, @@ -209,7 +200,7 @@ func TestSet(t *testing.T) { value: CustomString("typed custom"), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("custom"), + Path: structpath.MustParsePath("custom"), Old: CustomString("old custom"), New: CustomString("typed custom"), }, @@ -237,7 +228,7 @@ func TestSet(t *testing.T) { name: "error on wildcard", path: "items[*]", value: "value", - errorMsg: "wildcards not supported", + errorMsg: "wildcards not allowed in path", }, { name: "custom string to string field", @@ -245,7 +236,7 @@ func TestSet(t *testing.T) { value: CustomString("custom to regular"), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "custom to regular", }, @@ -257,7 +248,7 @@ func TestSet(t *testing.T) { value: CustomInt(35), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: 35, }, @@ -281,7 +272,7 @@ func TestSet(t *testing.T) { value: "42", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: 42, }, @@ -299,7 +290,7 @@ func TestSet(t *testing.T) { value: "false", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("active"), + Path: structpath.MustParsePath("active"), Old: true, New: false, }, @@ -317,7 +308,7 @@ func TestSet(t *testing.T) { value: "3.14", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("score"), + Path: structpath.MustParsePath("score"), Old: 85.5, New: 3.14, }, @@ -329,7 +320,7 @@ func TestSet(t *testing.T) { value: "0", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("score"), + Path: structpath.MustParsePath("score"), Old: 85.5, New: 0.0, }, @@ -347,7 +338,7 @@ func TestSet(t *testing.T) { value: "200", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("priority"), + Path: structpath.MustParsePath("priority"), Old: uint8(10), New: uint8(200), }, @@ -371,7 +362,7 @@ func TestSet(t *testing.T) { value: 42, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "42", }, @@ -383,7 +374,7 @@ func TestSet(t *testing.T) { value: true, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "true", }, @@ -395,7 +386,7 @@ func TestSet(t *testing.T) { value: false, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "false", }, @@ -407,7 +398,7 @@ func TestSet(t *testing.T) { value: 3.14, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "3.14", }, @@ -419,7 +410,7 @@ func TestSet(t *testing.T) { value: uint8(200), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "200", }, @@ -431,7 +422,7 @@ func TestSet(t *testing.T) { value: "-10", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: -10, }, @@ -443,7 +434,7 @@ func TestSet(t *testing.T) { value: "0", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("priority"), + Path: structpath.MustParsePath("priority"), Old: uint8(10), New: uint8(0), }, @@ -457,7 +448,7 @@ func TestSet(t *testing.T) { value: "updated", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("nested_items[1].name"), + Path: structpath.MustParsePath("nested_items[1].name"), Old: "second", New: "updated", }, diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index 24b2dba04d..1eb909fbec 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -11,39 +11,70 @@ import ( // ValidateByString reports whether the given path string is valid for the provided type. // It returns nil if the path resolves fully, or an error indicating where resolution failed. -// This is a convenience function that parses the path string and calls Validate. +// This is a convenience function that parses the path string and calls ValidatePattern. +// Wildcards are allowed in the path. func ValidateByString(t reflect.Type, path string) error { if path == "" { return nil } - pathNode, err := structpath.Parse(path) + patternNode, err := structpath.ParsePattern(path) if err != nil { return err } - return Validate(t, pathNode) + return ValidatePattern(t, patternNode) } -// Validate reports whether the given path is valid for the provided type. +// validatableNode is an interface for path nodes that can be validated. +type validatableNode interface { + Index() (int, bool) + KeyValue() (key, value string, ok bool) + StringKey() (string, bool) + String() string + BracketStar() bool + DotStar() bool +} + +// ValidatePath reports whether the given path is valid for the provided type. // It returns nil if the path resolves fully, or an error indicating where resolution failed. -func Validate(t reflect.Type, path *structpath.PathNode) error { +// Paths cannot contain wildcards. +func ValidatePath(t reflect.Type, path *structpath.PathNode) error { if path.IsRoot() { return nil } + nodes := path.AsSlice() + validateNodes := make([]validatableNode, len(nodes)) + for i, n := range nodes { + validateNodes[i] = n + } + return validateNodeSlice(t, validateNodes) +} - // Convert path to slice for easier iteration - pathSegments := path.AsSlice() +// ValidatePattern reports whether the given pattern path is valid for the provided type. +// It returns nil if the path resolves fully, or an error indicating where resolution failed. +// Patterns may include wildcards ([*] and .*). +func ValidatePattern(t reflect.Type, path *structpath.PatternNode) error { + if path.IsRoot() { + return nil + } + nodes := path.AsSlice() + validateNodes := make([]validatableNode, len(nodes)) + for i, n := range nodes { + validateNodes[i] = n + } + return validateNodeSlice(t, validateNodes) +} +// validateNodeSlice is the shared implementation for ValidatePath and ValidatePattern. +func validateNodeSlice(t reflect.Type, nodes []validatableNode) error { cur := t - for _, node := range pathSegments { - // Always dereference pointers at the type level. + for _, node := range nodes { for cur.Kind() == reflect.Pointer { cur = cur.Elem() } if _, isIndex := node.Index(); isIndex { - // Index access: slice/array kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { return fmt.Errorf("%s: cannot index %s", node.String(), kind) @@ -52,7 +83,6 @@ func Validate(t reflect.Type, path *structpath.PathNode) error { continue } - // Handle wildcards - treat like index/key access if node.BracketStar() { kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { @@ -69,7 +99,6 @@ func Validate(t reflect.Type, path *structpath.PathNode) error { continue } - // Handle key-value selector: validates that we can index the slice/array if _, _, isKeyValue := node.KeyValue(); isKeyValue { kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { @@ -80,7 +109,6 @@ func Validate(t reflect.Type, path *structpath.PathNode) error { } key, ok := node.StringKey() - if !ok { return errors.New("unsupported path node type") } diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 302e927464..6c06eb7eda 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -28,60 +28,114 @@ const ( tagBracketString = -6 ) -// PathNode represents a node in a path for struct diffing. -// It can represent struct fields, map keys, or array/slice indices. -type PathNode struct { - prev *PathNode - key string // Computed key (JSON key for structs, string key for maps, or Go field name for fallback) +type PathNodeBase struct { + key string // Computed key (JSON key for structs, string key for maps, or Go field name for fallback) // If index >= 0, the node specifies a slice/array index in index. // If index < 0, this describes the type of node index int value string // Used for tagKeyValue: stores the value part of [key='value'] } +// PathNode represents a node in a path for struct diffing. +// It can represent struct fields, map keys, or array/slice indices. +type PathNode struct { + PathNodeBase + prev *PathNode +} + +// PatternNode represents a node that can also support wildcards +type PatternNode struct { + PathNodeBase + prev *PatternNode +} + func (p *PathNode) IsRoot() bool { return p == nil } +func (p *PatternNode) IsRoot() bool { + return p == nil +} + +func (p *PatternNode) Parent() *PatternNode { + if p == nil { + return nil + } + return p.prev +} + +// Index - nil-safe wrappers func (p *PathNode) Index() (int, bool) { if p == nil { return -1, false } + return p.PathNodeBase.Index() +} + +func (p *PatternNode) Index() (int, bool) { + if p == nil { + return -1, false + } + return p.PathNodeBase.Index() +} + +func (p *PathNodeBase) Index() (int, bool) { if p.index >= 0 { return p.index, true } return -1, false } -func (p *PathNode) DotStar() bool { - if p == nil { - return false - } +func (p *PathNodeBase) DotStar() bool { return p.index == tagDotStar } -func (p *PathNode) BracketStar() bool { - if p == nil { - return false - } +func (p *PathNodeBase) BracketStar() bool { return p.index == tagBracketStar } +// KeyValue - nil-safe wrappers func (p *PathNode) KeyValue() (key, value string, ok bool) { if p == nil { return "", "", false } + return p.PathNodeBase.KeyValue() +} + +func (p *PatternNode) KeyValue() (key, value string, ok bool) { + if p == nil { + return "", "", false + } + return p.PathNodeBase.KeyValue() +} + +func (p *PathNodeBase) KeyValue() (key, value string, ok bool) { if p.index == tagKeyValue { return p.key, p.value, true } return "", "", false } -func (p *PathNode) IsDotString() bool { +// StringKey - nil-safe wrappers (required because Go panics accessing embedded field on nil receiver) +func (p *PathNode) StringKey() (string, bool) { + if p == nil { + return "", false + } + return p.PathNodeBase.StringKey() +} + +func (p *PatternNode) StringKey() (string, bool) { + if p == nil { + return "", false + } + return p.PathNodeBase.StringKey() +} + +func (p *PathNodeBase) IsDotString() bool { return p != nil && p.index == tagDotString } -func (p *PathNode) DotString() (string, bool) { +func (p *PathNodeBase) DotString() (string, bool) { if p == nil { return "", false } @@ -91,7 +145,7 @@ func (p *PathNode) DotString() (string, bool) { return "", false } -func (p *PathNode) BracketString() (string, bool) { +func (p *PathNodeBase) BracketString() (string, bool) { if p == nil { return "", false } @@ -101,8 +155,41 @@ func (p *PathNode) BracketString() (string, bool) { return "", false } +// formatNode writes the string representation of this node to the builder. +// isFirst indicates if this is the first node in the path (affects dot prefix). +func (p *PathNodeBase) formatNode(result *strings.Builder, isFirst bool) { + if p.index >= 0 { + result.WriteString("[") + result.WriteString(strconv.Itoa(p.index)) + result.WriteString("]") + } else if p.index == tagDotStar { + if isFirst { + result.WriteString("*") + } else { + result.WriteString(".*") + } + } else if p.index == tagBracketStar { + result.WriteString("[*]") + } else if p.index == tagKeyValue { + result.WriteString("[") + result.WriteString(p.key) + result.WriteString("=") + result.WriteString(EncodeMapKey(p.value)) + result.WriteString("]") + } else if p.index == tagDotString { + if !isFirst { + result.WriteString(".") + } + result.WriteString(p.key) + } else if p.index == tagBracketString { + result.WriteString("[") + result.WriteString(EncodeMapKey(p.key)) + result.WriteString("]") + } +} + // StringKey returns either Field() or MapKey() if either is available -func (p *PathNode) StringKey() (string, bool) { +func (p *PathNodeBase) StringKey() (string, bool) { if p == nil { return "", false } @@ -135,32 +222,69 @@ func (p *PathNode) AsSlice() []*PathNode { return segments } +// AsSlice returns the pattern as a slice of PatternNodes from root to current. +func (p *PatternNode) AsSlice() []*PatternNode { + length := p.Len() + segments := make([]*PatternNode, length) + + // Fill in reverse order + current := p + for i := length - 1; i >= 0; i-- { + segments[i] = current + current = current.Parent() + } + + return segments +} + +// Len returns the number of components in the pattern. +func (p *PatternNode) Len() int { + length := 0 + current := p + for current != nil { + length++ + current = current.Parent() + } + return length +} + +// String returns the string representation of the pattern. +func (p *PatternNode) String() string { + if p == nil { + return "" + } + components := p.AsSlice() + var result strings.Builder + for i, node := range components { + node.formatNode(&result, i == 0) + } + return result.String() +} + // NewIndex creates a new PathNode for an array/slice index. func NewIndex(prev *PathNode, index int) *PathNode { if index < 0 { panic("index must be non-negative") } return &PathNode{ - prev: prev, - index: index, + PathNodeBase: PathNodeBase{index: index}, + prev: prev, } } // NewDotString creates a PathNode for dot notation (.field). func NewDotString(prev *PathNode, fieldName string) *PathNode { return &PathNode{ - prev: prev, - key: fieldName, - index: tagDotString, + PathNodeBase: PathNodeBase{key: fieldName, index: tagDotString}, + prev: prev, } } // NewBracketString creates a PathNode for bracket notation (["field"]). func NewBracketString(prev *PathNode, fieldName string) *PathNode { return &PathNode{ - prev: prev, - key: fieldName, - index: tagBracketString, + PathNodeBase: PathNodeBase{key: fieldName, index: tagBracketString}, + prev: prev, } } @@ -173,26 +297,69 @@ func NewStringKey(prev *PathNode, fieldName string) *PathNode { return NewBracketString(prev, fieldName) } -func NewDotStar(prev *PathNode) *PathNode { +func NewKeyValue(prev *PathNode, key, value string) *PathNode { return &PathNode{ - prev: prev, - index: tagDotStar, + PathNodeBase: PathNodeBase{key: key, index: tagKeyValue, value: value}, + prev: prev, } } -func NewBracketStar(prev *PathNode) *PathNode { - return &PathNode{ - prev: prev, - index: tagBracketStar, +// PatternNode constructors + +// NewPatternIndex creates a new PatternNode for an array/slice index. +func NewPatternIndex(prev *PatternNode, index int) *PatternNode { + if index < 0 { + panic("index must be non-negative") + } + return &PatternNode{ + PathNodeBase: PathNodeBase{index: index}, + prev: prev, } } -func NewKeyValue(prev *PathNode, key, value string) *PathNode { - return &PathNode{ - prev: prev, - key: key, - index: tagKeyValue, - value: value, +// NewPatternDotString creates a PatternNode for dot notation (.field). +func NewPatternDotString(prev *PatternNode, fieldName string) *PatternNode { + return &PatternNode{ + PathNodeBase: PathNodeBase{key: fieldName, index: tagDotString}, + prev: prev, + } +} + +// NewPatternBracketString creates a PatternNode for bracket notation (["field"]). +func NewPatternBracketString(prev *PatternNode, fieldName string) *PatternNode { + return &PatternNode{ + PathNodeBase: PathNodeBase{key: fieldName, index: tagBracketString}, + prev: prev, + } +} + +// NewPatternStringKey creates a PatternNode, choosing dot notation if the fieldName is a valid field name, +// otherwise bracket notation. +func NewPatternStringKey(prev *PatternNode, fieldName string) *PatternNode { + if isValidField(fieldName) { + return NewPatternDotString(prev, fieldName) + } + return NewPatternBracketString(prev, fieldName) +} + +func NewPatternDotStar(prev *PatternNode) *PatternNode { + return &PatternNode{ + PathNodeBase: PathNodeBase{index: tagDotStar}, + prev: prev, + } +} + +func NewPatternBracketStar(prev *PatternNode) *PatternNode { + return &PatternNode{ + PathNodeBase: PathNodeBase{index: tagBracketStar}, + prev: prev, + } +} + +func NewPatternKeyValue(prev *PatternNode, key, value string) *PatternNode { + return &PatternNode{ + PathNodeBase: PathNodeBase{key: key, index: tagKeyValue, value: value}, + prev: prev, } } @@ -208,46 +375,11 @@ func (p *PathNode) String() string { if p == nil { return "" } - - // Get all path components from root to current components := p.AsSlice() - var result strings.Builder - for i, node := range components { - if node.index >= 0 { - // Array/slice index - result.WriteString("[") - result.WriteString(strconv.Itoa(node.index)) - result.WriteString("]") - } else if node.index == tagDotStar { - if i == 0 { - result.WriteString("*") - } else { - result.WriteString(".*") - } - } else if node.index == tagBracketStar { - result.WriteString("[*]") - } else if node.index == tagKeyValue { - result.WriteString("[") - result.WriteString(node.key) - result.WriteString("=") - result.WriteString(EncodeMapKey(node.value)) - result.WriteString("]") - } else if node.index == tagDotString { - // Dot notation: .field - if i != 0 { - result.WriteString(".") - } - result.WriteString(node.key) - } else if node.index == tagBracketString { - // Bracket notation: ['field'] - result.WriteString("[") - result.WriteString(EncodeMapKey(node.key)) - result.WriteString("]") - } + node.formatNode(&result, i == 0) } - return result.String() } @@ -257,6 +389,8 @@ func EncodeMapKey(s string) string { } // Parse parses a string representation of a path using a state machine. +// If wildcardAllowed is true, returns (nil, *PatternNode, nil) on success. +// If wildcardAllowed is false, returns (*PathNode, nil, nil) on success but errors if wildcards are found. // // State Machine for Path Parsing: // @@ -276,25 +410,9 @@ func EncodeMapKey(s string) string { // - KEYVALUE_VALUE_QUOTE: Encountered quote in value, expects same quote (escape) or "]" (end) // - EXPECT_DOT_OR_END: After bracket close, expects ".", "[" or end of string // - END: Successfully completed parsing -// -// Transitions: -// - START: [a-zA-Z_-] -> FIELD, "[" -> BRACKET_OPEN, "*" -> DOT_STAR, EOF -> END -// - FIELD_START: [a-zA-Z_-] -> FIELD, "*" -> DOT_STAR, other -> ERROR -// - FIELD: [a-zA-Z0-9_-] -> FIELD, "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END -// - DOT_STAR: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END, other -> ERROR -// - BRACKET_OPEN: [0-9] -> INDEX, "'" -> MAP_KEY, "*" -> WILDCARD, identifier -> KEYVALUE_KEY -// - INDEX: [0-9] -> INDEX, "]" -> EXPECT_DOT_OR_END -// - MAP_KEY: (any except "'") -> MAP_KEY, "'" -> MAP_KEY_QUOTE -// - MAP_KEY_QUOTE: "'" -> MAP_KEY (escape), "]" -> EXPECT_DOT_OR_END (end key) -// - WILDCARD: "]" -> EXPECT_DOT_OR_END -// - KEYVALUE_KEY: identifier -> KEYVALUE_KEY, "=" -> KEYVALUE_EQUALS -// - KEYVALUE_EQUALS: "'" or '"' -> KEYVALUE_VALUE -// - KEYVALUE_VALUE: (any except quote) -> KEYVALUE_VALUE, quote -> KEYVALUE_VALUE_QUOTE -// - KEYVALUE_VALUE_QUOTE: quote -> KEYVALUE_VALUE (escape), "]" -> EXPECT_DOT_OR_END -// - EXPECT_DOT_OR_END: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END -func Parse(s string) (*PathNode, error) { +func Parse(s string, wildcardAllowed bool) (*PathNode, *PatternNode, error) { if s == "" { - return nil, nil + return nil, nil, nil } // State machine states @@ -316,12 +434,15 @@ func Parse(s string) (*PathNode, error) { stateEnd ) + // Parse into a slice of PathNodeBase values first + var nodes []PathNodeBase state := stateStart - var result *PathNode var currentToken strings.Builder - var keyValueKey string // Stores the key part of [key='value'] + var keyValueKey string pos := 0 + hasWildcard := false +parseLoop: for pos < len(s) { ch := s[pos] @@ -330,49 +451,51 @@ func Parse(s string) (*PathNode, error) { if ch == '[' { state = stateBracketOpen } else if ch == '*' { + hasWildcard = true state = stateDotStar } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) state = stateField } else { - return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) } case stateFieldStart: if ch == '*' { + hasWildcard = true state = stateDotStar } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) state = stateField } else { - return nil, fmt.Errorf("expected field name after '.' but got '%c' at position %d", ch, pos) + return nil, nil, fmt.Errorf("expected field name after '.' but got '%c' at position %d", ch, pos) } case stateField: if ch == '.' { - result = NewDotString(result, currentToken.String()) + nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagDotString}) currentToken.Reset() state = stateFieldStart } else if ch == '[' { - result = NewDotString(result, currentToken.String()) + nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagDotString}) currentToken.Reset() state = stateBracketOpen } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) } else { - return nil, fmt.Errorf("invalid character '%c' in field name at position %d", ch, pos) + return nil, nil, fmt.Errorf("invalid character '%c' in field name at position %d", ch, pos) } case stateDotStar: switch ch { case '.': - result = NewDotStar(result) + nodes = append(nodes, PathNodeBase{index: tagDotStar}) state = stateFieldStart case '[': - result = NewDotStar(result) + nodes = append(nodes, PathNodeBase{index: tagDotStar}) state = stateBracketOpen default: - return nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) } case stateBracketOpen: @@ -382,12 +505,13 @@ func Parse(s string) (*PathNode, error) { } else if ch == '\'' { state = stateMapKey } else if ch == '*' { + hasWildcard = true state = stateWildcard } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) state = stateKeyValueKey } else { - return nil, fmt.Errorf("unexpected character '%c' after '[' at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' after '[' at position %d", ch, pos) } case stateIndex: @@ -396,13 +520,13 @@ func Parse(s string) (*PathNode, error) { } else if ch == ']' { index, err := strconv.Atoi(currentToken.String()) if err != nil { - return nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) + return nil, nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) } - result = NewIndex(result, index) + nodes = append(nodes, PathNodeBase{index: index}) currentToken.Reset() state = stateExpectDotOrEnd } else { - return nil, fmt.Errorf("unexpected character '%c' in index at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' in index at position %d", ch, pos) } case stateMapKey: @@ -416,24 +540,22 @@ func Parse(s string) (*PathNode, error) { case stateMapKeyQuote: switch ch { case '\'': - // Escaped quote - add single quote to key and continue currentToken.WriteByte('\'') state = stateMapKey case ']': - // End of map key - result = NewBracketString(result, currentToken.String()) + nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagBracketString}) currentToken.Reset() state = stateExpectDotOrEnd default: - return nil, fmt.Errorf("unexpected character '%c' after quote in map key at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' after quote in map key at position %d", ch, pos) } case stateWildcard: if ch == ']' { - result = NewBracketStar(result) + nodes = append(nodes, PathNodeBase{index: tagBracketStar}) state = stateExpectDotOrEnd } else { - return nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) } case stateKeyValueKey: @@ -444,14 +566,14 @@ func Parse(s string) (*PathNode, error) { } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) } else { - return nil, fmt.Errorf("unexpected character '%c' in key-value key at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' in key-value key at position %d", ch, pos) } case stateKeyValueEquals: if ch == '\'' { state = stateKeyValueValue } else { - return nil, fmt.Errorf("expected quote after '=' but got '%c' at position %d", ch, pos) + return nil, nil, fmt.Errorf("expected quote after '=' but got '%c' at position %d", ch, pos) } case stateKeyValueValue: @@ -464,17 +586,15 @@ func Parse(s string) (*PathNode, error) { case stateKeyValueValueQuote: switch ch { case '\'': - // Escaped quote - add single quote to value and continue currentToken.WriteByte(ch) state = stateKeyValueValue case ']': - // End of key-value - result = NewKeyValue(result, keyValueKey, currentToken.String()) + nodes = append(nodes, PathNodeBase{key: keyValueKey, index: tagKeyValue, value: currentToken.String()}) currentToken.Reset() keyValueKey = "" state = stateExpectDotOrEnd default: - return nil, fmt.Errorf("unexpected character '%c' after quote in key-value at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' after quote in key-value at position %d", ch, pos) } case stateExpectDotOrEnd: @@ -484,14 +604,14 @@ func Parse(s string) (*PathNode, error) { case '[': state = stateBracketOpen default: - return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) + return nil, nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) } case stateEnd: - return result, nil + break parseLoop default: - return nil, fmt.Errorf("parser error at position %d", pos) + return nil, nil, fmt.Errorf("parser error at position %d", pos) } pos++ @@ -500,40 +620,100 @@ func Parse(s string) (*PathNode, error) { // Handle end-of-input based on final state switch state { case stateStart: - return result, nil // Empty path, result is nil + // Empty path case stateField: - result = NewDotString(result, currentToken.String()) - return result, nil + nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagDotString}) case stateDotStar: - result = NewDotStar(result) - return result, nil + nodes = append(nodes, PathNodeBase{index: tagDotStar}) case stateExpectDotOrEnd: - return result, nil + // Already complete case stateFieldStart: - return nil, errors.New("unexpected end of input after '.'") + return nil, nil, errors.New("unexpected end of input after '.'") case stateBracketOpen: - return nil, errors.New("unexpected end of input after '['") + return nil, nil, errors.New("unexpected end of input after '['") case stateIndex: - return nil, errors.New("unexpected end of input while parsing index") + return nil, nil, errors.New("unexpected end of input while parsing index") case stateMapKey: - return nil, errors.New("unexpected end of input while parsing map key") + return nil, nil, errors.New("unexpected end of input while parsing map key") case stateMapKeyQuote: - return nil, errors.New("unexpected end of input after quote in map key") + return nil, nil, errors.New("unexpected end of input after quote in map key") case stateWildcard: - return nil, errors.New("unexpected end of input after wildcard '*'") + return nil, nil, errors.New("unexpected end of input after wildcard '*'") case stateKeyValueKey: - return nil, errors.New("unexpected end of input while parsing key-value key") + return nil, nil, errors.New("unexpected end of input while parsing key-value key") case stateKeyValueEquals: - return nil, errors.New("unexpected end of input after '=' in key-value") + return nil, nil, errors.New("unexpected end of input after '=' in key-value") case stateKeyValueValue: - return nil, errors.New("unexpected end of input while parsing key-value value") + return nil, nil, errors.New("unexpected end of input while parsing key-value value") case stateKeyValueValueQuote: - return nil, errors.New("unexpected end of input after quote in key-value value") + return nil, nil, errors.New("unexpected end of input after quote in key-value value") case stateEnd: - return result, nil + // Already complete default: - return nil, fmt.Errorf("parser error at position %d", pos) + return nil, nil, fmt.Errorf("parser error at position %d", pos) + } + + // Check wildcard constraint + if hasWildcard && !wildcardAllowed { + return nil, nil, errors.New("wildcards not allowed in path") + } + + // Build the appropriate linked list + if len(nodes) == 0 { + return nil, nil, nil + } + + if wildcardAllowed { + // Build PatternNode chain + var result *PatternNode + for _, node := range nodes { + result = &PatternNode{ + PathNodeBase: node, + prev: result, + } + } + return nil, result, nil + } + + // Build PathNode chain + var result *PathNode + for _, node := range nodes { + result = &PathNode{ + PathNodeBase: node, + prev: result, + } + } + return result, nil, nil +} + +// ParsePath parses a path string. Wildcards are not allowed. +func ParsePath(s string) (*PathNode, error) { + path, _, err := Parse(s, false) + return path, err +} + +// ParsePattern parses a pattern string. Wildcards are allowed. +func ParsePattern(s string) (*PatternNode, error) { + _, pattern, err := Parse(s, true) + return pattern, err +} + +// MustParsePath parses a path string and panics on error. Wildcards are not allowed. +func MustParsePath(s string) *PathNode { + path, err := ParsePath(s) + if err != nil { + panic(err) + } + return path +} + +// MustParsePattern parses a pattern string and panics on error. Wildcards are allowed. +func MustParsePattern(s string) *PatternNode { + pattern, err := ParsePattern(s) + if err != nil { + panic(err) } + return pattern } // isReservedFieldChar checks if character is reserved and cannot be used in field names @@ -585,7 +765,7 @@ func PureReferenceToPath(s string) (*PathNode, bool) { return nil, false } - pathNode, err := Parse(ref.References()[0]) + pathNode, _, err := Parse(ref.References()[0], false) if err != nil { return nil, false } @@ -611,9 +791,8 @@ func (p *PathNode) SkipPrefix(n int) *PathNode { current := p for current != startNode { result = &PathNode{ - prev: result, - key: current.key, - index: current.index, + prev: result, + PathNodeBase: current.PathNodeBase, value: current.value, } current = current.Parent() @@ -734,12 +913,13 @@ func (p *PathNode) MarshalYAML() (any, error) { } // UnmarshalYAML implements yaml.Unmarshaler for PathNode. +// Note: wildcards are not allowed in PathNode; use PatternNode for paths with wildcards. func (p *PathNode) UnmarshalYAML(unmarshal func(any) error) error { var s string if err := unmarshal(&s); err != nil { return err } - parsed, err := Parse(s) + parsed, _, err := Parse(s, false) if err != nil { return err } diff --git a/libs/structs/structpath/path_test.go b/libs/structs/structpath/path_test.go index 05daa2e645..3d94961d21 100644 --- a/libs/structs/structpath/path_test.go +++ b/libs/structs/structpath/path_test.go @@ -10,15 +10,13 @@ import ( func TestPathNode(t *testing.T) { tests := []struct { - name string - node *PathNode - String string - Index any - StringKey any - KeyValue []string // [key, value] or nil - Root any - DotStar bool - BracketStar bool + name string + node *PathNode + String string + Index any + StringKey any + KeyValue []string // [key, value] or nil + Root any }{ // Single node tests { @@ -39,18 +37,6 @@ func TestPathNode(t *testing.T) { String: `mykey`, StringKey: "mykey", }, - { - name: "dot star", - node: NewDotStar(nil), - String: "*", - DotStar: true, - }, - { - name: "bracket star", - node: NewBracketStar(nil), - String: "[*]", - BracketStar: true, - }, { name: "key value", node: NewKeyValue(nil, "name", "foo"), @@ -95,18 +81,6 @@ func TestPathNode(t *testing.T) { String: `[1]['status{}']`, StringKey: "status{}", }, - { - name: "dot star with parent", - node: NewDotStar(NewStringKey(nil, "Parent")), - String: "Parent.*", - DotStar: true, - }, - { - name: "bracket star with parent", - node: NewBracketStar(NewStringKey(nil, "Parent")), - String: "Parent[*]", - BracketStar: true, - }, // Edge cases with special characters in map keys { @@ -172,19 +146,6 @@ func TestPathNode(t *testing.T) { StringKey: "key\x00[],`", }, - { - name: "field dot star bracket index", - node: NewIndex(NewDotStar(NewStringKey(nil, "bla")), 0), - String: "bla.*[0]", - Index: 0, - }, - { - name: "field dot star bracket star", - node: NewBracketStar(NewDotStar(NewStringKey(nil, "bla"))), - String: "bla.*[*]", - BracketStar: true, - }, - // Key-value tests { name: "key value with parent", @@ -225,7 +186,7 @@ func TestPathNode(t *testing.T) { assert.Equal(t, tt.String, result, "String() method") // Test roundtrip conversion: String() -> Parse() -> String() - parsed, err := Parse(tt.String) + parsed, _, err := Parse(tt.String, false) if assert.NoError(t, err, "Parse() should not error") { assert.Equal(t, tt.node, parsed) roundtripResult := parsed.String() @@ -272,6 +233,68 @@ func TestPathNode(t *testing.T) { } else { assert.True(t, isRoot) } + }) + } +} + +func TestPatternNode(t *testing.T) { + tests := []struct { + name string + node *PatternNode + String string + DotStar bool + BracketStar bool + }{ + { + name: "dot star", + node: NewPatternDotStar(nil), + String: "*", + DotStar: true, + }, + { + name: "bracket star", + node: NewPatternBracketStar(nil), + String: "[*]", + BracketStar: true, + }, + { + name: "dot star with parent", + node: NewPatternDotStar(NewPatternStringKey(nil, "Parent")), + String: "Parent.*", + DotStar: true, + }, + { + name: "bracket star with parent", + node: NewPatternBracketStar(NewPatternStringKey(nil, "Parent")), + String: "Parent[*]", + BracketStar: true, + }, + { + name: "field dot star bracket index", + node: NewPatternIndex(NewPatternDotStar(NewPatternStringKey(nil, "bla")), 0), + String: "bla.*[0]", + }, + { + name: "field dot star bracket star", + node: NewPatternBracketStar(NewPatternDotStar(NewPatternStringKey(nil, "bla"))), + String: "bla.*[*]", + BracketStar: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test String() method + result := tt.node.String() + assert.Equal(t, tt.String, result, "String() method") + + // Test roundtrip conversion: String() -> Parse(wildcardAllowed=true) -> String() + _, parsed, err := Parse(tt.String, true) + if assert.NoError(t, err, "Parse() should not error") { + assert.Equal(t, tt.node, parsed) + roundtripResult := parsed.String() + assert.Equal(t, tt.String, roundtripResult, "Roundtrip conversion should be identical") + } // DotStar and BracketStar assert.Equal(t, tt.DotStar, tt.node.DotStar()) @@ -280,6 +303,25 @@ func TestPathNode(t *testing.T) { } } +func TestParseWildcardNotAllowed(t *testing.T) { + // Test that wildcards are rejected when wildcardAllowed=false + wildcardPaths := []string{ + "*", + "[*]", + "foo.*", + "foo[*]", + "foo.*[*]", + } + + for _, path := range wildcardPaths { + t.Run(path, func(t *testing.T) { + _, _, err := Parse(path, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "wildcards not allowed") + }) + } +} + func TestParseErrors(t *testing.T) { tests := []struct { name string @@ -484,7 +526,7 @@ func TestParseErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := Parse(tt.input) + _, _, err := Parse(tt.input, true) // Allow wildcards in error tests if assert.Error(t, err) { assert.Equal(t, tt.error, err.Error()) } @@ -587,7 +629,7 @@ func TestPrefixAndSkipPrefix(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - path, err := Parse(tt.input) + path, _, err := Parse(tt.input, false) assert.NoError(t, err) // Test Prefix @@ -634,7 +676,7 @@ func TestLen(t *testing.T) { t.Run(tt.input, func(t *testing.T) { var path *PathNode var err error - path, err = Parse(tt.input) + path, _, err = Parse(tt.input, false) assert.NoError(t, err) assert.Equal(t, tt.expected, path.Len()) }) @@ -763,20 +805,6 @@ func TestHasPrefix(t *testing.T) { expected: false, }, - // wildcard patterns are NOT supported - treated as literals - { - name: "regex pattern not respected - star quantifier", - s: "aaa", - prefix: "a*", - expected: false, - }, - { - name: "regex pattern not respected - bracket class", - s: "a[1]", - prefix: "a[*]", - expected: false, - }, - // Exact component matching - array indices, bracket keys, and key-value notation { name: "prefix longer than path", @@ -812,10 +840,10 @@ func TestHasPrefix(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, err := Parse(tt.s) + path, _, err := Parse(tt.s, false) require.NoError(t, err) - prefix, err := Parse(tt.prefix) + prefix, _, err := Parse(tt.prefix, false) require.NoError(t, err) result := path.HasPrefix(prefix) @@ -887,11 +915,6 @@ func TestPathNodeYAMLUnmarshal(t *testing.T) { input: "items[0].name", expected: "items[0].name", }, - { - name: "path with wildcard", - input: "tasks[*].name", - expected: "tasks[*].name", - }, { name: "path with key-value", input: "tags[key='server']", @@ -952,6 +975,11 @@ func TestPathNodeYAMLUnmarshalErrors(t *testing.T) { input: "field['key", error: "unexpected end of input while parsing map key", }, + { + name: "wildcard not allowed in PathNode", + input: "tasks[*].name", + error: "wildcards not allowed", + }, } for _, tt := range tests { @@ -970,7 +998,6 @@ func TestPathNodeYAMLRoundtrip(t *testing.T) { "name", "config.database", "items[0].name", - "tasks[*].settings", "tags[key='env'].value", "resources.jobs['my-job'].tasks[0]", } @@ -978,7 +1005,7 @@ func TestPathNodeYAMLRoundtrip(t *testing.T) { for _, path := range paths { t.Run(path, func(t *testing.T) { // Parse -> Marshal -> Unmarshal -> compare - original, err := Parse(path) + original, _, err := Parse(path, false) require.NoError(t, err) data, err := yaml.Marshal(original) diff --git a/libs/structs/structvar/structvar.go b/libs/structs/structvar/structvar.go index 68c372a528..b60f8c15ee 100644 --- a/libs/structs/structvar/structvar.go +++ b/libs/structs/structvar/structvar.go @@ -82,7 +82,7 @@ func (sv *StructVar) ResolveRef(reference string, value any) error { foundAny = true // Parse the path - pathNode, err := structpath.Parse(pathKey) + pathNode, err := structpath.ParsePath(pathKey) if err != nil { return fmt.Errorf("invalid path %q: %w", pathKey, err) } diff --git a/libs/structs/structwalk/walk_test.go b/libs/structs/structwalk/walk_test.go index 302679eaff..d754329b6d 100644 --- a/libs/structs/structwalk/walk_test.go +++ b/libs/structs/structwalk/walk_test.go @@ -18,7 +18,7 @@ func flatten(t *testing.T, value any) map[string]any { results[s] = value // Test path parsing round trip - newPath, err := structpath.Parse(s) + newPath, err := structpath.ParsePath(s) if assert.NoError(t, err, s) { newS := newPath.String() assert.Equal(t, path, newPath, "s=%q newS=%q", s, newS) diff --git a/libs/structs/structwalk/walktype.go b/libs/structs/structwalk/walktype.go index 56b60a92f2..604e3e5c4a 100644 --- a/libs/structs/structwalk/walktype.go +++ b/libs/structs/structwalk/walktype.go @@ -11,29 +11,31 @@ import ( // VisitTypeFunc is invoked for fields encountered while walking typ. This includes both leaf nodes as well as any // intermediate nodes encountered while walking the struct tree. // -// path PathNode representing the JSON-style path to the field. -// typ the field's type – if the field is a pointer to a scalar the pointer type is preserved; -// the callback receives the actual type (e.g., *string, *int, etc.). -// field the struct field if this node represents a struct field, nil otherwise. +// path PatternNode representing the JSON-style path to the field (may include wildcards). +// typ the field's type – if the field is a pointer to a scalar the pointer type is preserved; +// the callback receives the actual type (e.g., *string, *int, etc.). +// field the struct field if this node represents a struct field, nil otherwise. // // The function returns a boolean: -// continueWalk: if true, the WalkType function will continue recursively walking the current field. -// if false, the WalkType function will skip walking the current field and all its children. +// +// continueWalk: if true, the WalkType function will continue recursively walking the current field. +// if false, the WalkType function will skip walking the current field and all its children. // // NOTE: Fields lacking a json tag or tagged as "-" are ignored entirely. -// Dynamic types like func, chan, interface, etc. are *not* visited. -// Only maps with string keys are traversed so that paths stay JSON-like. +// +// Dynamic types like func, chan, interface, etc. are *not* visited. +// Only maps with string keys are traversed so that paths stay JSON-like. // // The walk is depth-first and deterministic (map keys are sorted lexicographically). // // Example: -// err := structwalk.WalkType(reflect.TypeOf(cfg), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) { -// fmt.Printf("%s = %v\n", path.String(), typ) -// }) +// +// err := structwalk.WalkType(reflect.TypeOf(cfg), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) { +// fmt.Printf("%s = %v\n", path.String(), typ) +// }) // // ****************************************************************************************************** - -type VisitTypeFunc func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) +type VisitTypeFunc func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) // WalkType validates that t is a struct or pointer to one and starts the recursive traversal. func WalkType(t reflect.Type, visit VisitTypeFunc) error { @@ -48,7 +50,7 @@ func WalkType(t reflect.Type, visit VisitTypeFunc) error { return nil } -func walkTypeValue(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { +func walkTypeValue(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { if typ == nil { return } @@ -84,14 +86,14 @@ func walkTypeValue(path *structpath.PathNode, typ reflect.Type, field *reflect.S walkTypeStruct(path, typ, visit, visitedCount) case reflect.Slice, reflect.Array: - walkTypeValue(structpath.NewBracketStar(path), typ.Elem(), nil, visit, visitedCount) + walkTypeValue(structpath.NewPatternBracketStar(path), typ.Elem(), nil, visit, visitedCount) case reflect.Map: if typ.Key().Kind() != reflect.String { return // unsupported map key type } // For maps, we walk the value type directly at the current path - walkTypeValue(structpath.NewDotStar(path), typ.Elem(), nil, visit, visitedCount) + walkTypeValue(structpath.NewPatternDotStar(path), typ.Elem(), nil, visit, visitedCount) default: // func, chan, interface, invalid, etc. -> ignore @@ -100,7 +102,7 @@ func walkTypeValue(path *structpath.PathNode, typ reflect.Type, field *reflect.S visitedCount[typ]-- } -func walkTypeStruct(path *structpath.PathNode, st reflect.Type, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { +func walkTypeStruct(path *structpath.PatternNode, st reflect.Type, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { for i := range st.NumField() { sf := st.Field(i) if sf.PkgPath != "" { @@ -127,7 +129,7 @@ func walkTypeStruct(path *structpath.PathNode, st reflect.Type, visit VisitTypeF if fieldName == "" { fieldName = sf.Name } - node := structpath.NewDotString(path, fieldName) + node := structpath.NewPatternDotString(path, fieldName) walkTypeValue(node, sf.Type, &sf, visit, visitedCount) } } diff --git a/libs/structs/structwalk/walktype_bench_test.go b/libs/structs/structwalk/walktype_bench_test.go index aea1cedec5..e0c8a8c344 100644 --- a/libs/structs/structwalk/walktype_bench_test.go +++ b/libs/structs/structwalk/walktype_bench_test.go @@ -11,7 +11,7 @@ import ( func countFields(typ reflect.Type) (int, error) { fieldCount := 0 - err := WalkType(typ, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(typ, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { fieldCount++ return true }) diff --git a/libs/structs/structwalk/walktype_test.go b/libs/structs/structwalk/walktype_test.go index 0ad985cf71..ba24d0f6cc 100644 --- a/libs/structs/structwalk/walktype_test.go +++ b/libs/structs/structwalk/walktype_test.go @@ -14,7 +14,7 @@ import ( func getScalarFields(t *testing.T, typ reflect.Type) map[string]any { results := make(map[string]any) - err := WalkType(typ, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(typ, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { for typ.Kind() == reflect.Pointer { typ = typ.Elem() } @@ -26,8 +26,8 @@ func getScalarFields(t *testing.T, typ reflect.Type) map[string]any { } // Test structpath round trip as well - pathNew, err := structpath.Parse(s) - if assert.NoError(t, err, "Parse(path.String()) failed for %q: %s", s, err) { + pathNew, err := structpath.ParsePattern(s) + if assert.NoError(t, err, "ParsePattern(path.String()) failed for %q: %s", s, err) { newS := pathNew.String() assert.Equal(t, path, pathNew, "Parse(path.String()) returned different path;\npath=%#v %q\npathNew=%#v %q", path, s, pathNew, newS) assert.Equal(t, s, newS, "Parse(path.String()).String() is different from path.String()\npath.String()=%q\npathNew.String()=%q", path, pathNew) @@ -167,7 +167,7 @@ func TestTypeRoot(t *testing.T) { func getReadonlyFields(t *testing.T, rootType reflect.Type) []string { var results []string - err := WalkType(rootType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(rootType, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil || field == nil { return true } @@ -206,7 +206,7 @@ func TestTypeBundleTag(t *testing.T) { } var readonly, internal []string - err := WalkType(reflect.TypeOf(Foo{}), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(reflect.TypeOf(Foo{}), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil || field == nil { return true } @@ -241,7 +241,7 @@ func TestWalkTypeVisited(t *testing.T) { } var visited []string - err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil { return true } @@ -280,7 +280,7 @@ func TestWalkSkip(t *testing.T) { } var seen []string - err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil { return true } From cd8bf1c85db10e80eab75918f189cd1be3878358 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 13:43:25 +0100 Subject: [PATCH 02/11] Split PathNode and PatternNode using type definition Introduce PatternNode as a type definition of PathNode (type PatternNode PathNode) to distinguish between concrete paths (no wildcards) and patterns (with wildcards). - PatternNode methods delegate to PathNode via type casting - Add ParsePath() and ParsePattern() wrapper functions - Add PatternNode constructors (NewPatternDotStar, NewPatternBracketStar, etc.) - Remove NewDotStar/NewBracketStar from PathNode (wildcards only valid in patterns) - Unify path_test.go to test concrete paths with both APIs Co-Authored-By: Claude Opus 4.5 --- libs/structs/structaccess/typecheck.go | 30 +- libs/structs/structpath/path.go | 412 ++++++++++------------- libs/structs/structpath/path_test.go | 433 +++++++++++++------------ 3 files changed, 399 insertions(+), 476 deletions(-) diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index 1eb909fbec..bf3f96a549 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -26,16 +26,6 @@ func ValidateByString(t reflect.Type, path string) error { return ValidatePattern(t, patternNode) } -// validatableNode is an interface for path nodes that can be validated. -type validatableNode interface { - Index() (int, bool) - KeyValue() (key, value string, ok bool) - StringKey() (string, bool) - String() string - BracketStar() bool - DotStar() bool -} - // ValidatePath reports whether the given path is valid for the provided type. // It returns nil if the path resolves fully, or an error indicating where resolution failed. // Paths cannot contain wildcards. @@ -43,12 +33,7 @@ func ValidatePath(t reflect.Type, path *structpath.PathNode) error { if path.IsRoot() { return nil } - nodes := path.AsSlice() - validateNodes := make([]validatableNode, len(nodes)) - for i, n := range nodes { - validateNodes[i] = n - } - return validateNodeSlice(t, validateNodes) + return validateNodeSlice(t, path.AsSlice()) } // ValidatePattern reports whether the given pattern path is valid for the provided type. @@ -58,16 +43,17 @@ func ValidatePattern(t reflect.Type, path *structpath.PatternNode) error { if path.IsRoot() { return nil } - nodes := path.AsSlice() - validateNodes := make([]validatableNode, len(nodes)) - for i, n := range nodes { - validateNodes[i] = n + // PatternNode is type definition of PathNode, so we can cast the slice + patternNodes := path.AsSlice() + pathNodes := make([]*structpath.PathNode, len(patternNodes)) + for i, n := range patternNodes { + pathNodes[i] = (*structpath.PathNode)(n) } - return validateNodeSlice(t, validateNodes) + return validateNodeSlice(t, pathNodes) } // validateNodeSlice is the shared implementation for ValidatePath and ValidatePattern. -func validateNodeSlice(t reflect.Type, nodes []validatableNode) error { +func validateNodeSlice(t reflect.Type, nodes []*structpath.PathNode) error { cur := t for _, node := range nodes { for cur.Kind() == reflect.Pointer { diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 6c06eb7eda..c5b3d9748f 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -28,114 +28,64 @@ const ( tagBracketString = -6 ) -type PathNodeBase struct { - key string // Computed key (JSON key for structs, string key for maps, or Go field name for fallback) - // If index >= 0, the node specifies a slice/array index in index. - // If index < 0, this describes the type of node - index int - value string // Used for tagKeyValue: stores the value part of [key='value'] -} - // PathNode represents a node in a path for struct diffing. // It can represent struct fields, map keys, or array/slice indices. type PathNode struct { - PathNodeBase prev *PathNode + key string // Computed key (JSON key for structs, string key for maps, or Go field name for fallback) + // If index >= 0, the node specifies a slice/array index in index. + // If index < 0, this describes the type of node + index int + value string // Used for tagKeyValue: stores the value part of [key='value'] } -// PatternNode represents a node that can also support wildcards -type PatternNode struct { - PathNodeBase - prev *PatternNode -} +// PatternNode is a PathNode that can also contain wildcards. +// Use type conversion to access PathNode methods: (*PathNode)(patternNode).Method() +type PatternNode PathNode func (p *PathNode) IsRoot() bool { return p == nil } -func (p *PatternNode) IsRoot() bool { - return p == nil -} - -func (p *PatternNode) Parent() *PatternNode { - if p == nil { - return nil - } - return p.prev -} - -// Index - nil-safe wrappers func (p *PathNode) Index() (int, bool) { if p == nil { return -1, false } - return p.PathNodeBase.Index() -} - -func (p *PatternNode) Index() (int, bool) { - if p == nil { - return -1, false - } - return p.PathNodeBase.Index() -} - -func (p *PathNodeBase) Index() (int, bool) { if p.index >= 0 { return p.index, true } return -1, false } -func (p *PathNodeBase) DotStar() bool { +func (p *PathNode) DotStar() bool { + if p == nil { + return false + } return p.index == tagDotStar } -func (p *PathNodeBase) BracketStar() bool { - return p.index == tagBracketStar -} - -// KeyValue - nil-safe wrappers -func (p *PathNode) KeyValue() (key, value string, ok bool) { +func (p *PathNode) BracketStar() bool { if p == nil { - return "", "", false + return false } - return p.PathNodeBase.KeyValue() + return p.index == tagBracketStar } -func (p *PatternNode) KeyValue() (key, value string, ok bool) { +func (p *PathNode) KeyValue() (key, value string, ok bool) { if p == nil { return "", "", false } - return p.PathNodeBase.KeyValue() -} - -func (p *PathNodeBase) KeyValue() (key, value string, ok bool) { if p.index == tagKeyValue { return p.key, p.value, true } return "", "", false } -// StringKey - nil-safe wrappers (required because Go panics accessing embedded field on nil receiver) -func (p *PathNode) StringKey() (string, bool) { - if p == nil { - return "", false - } - return p.PathNodeBase.StringKey() -} - -func (p *PatternNode) StringKey() (string, bool) { - if p == nil { - return "", false - } - return p.PathNodeBase.StringKey() -} - -func (p *PathNodeBase) IsDotString() bool { +func (p *PathNode) IsDotString() bool { return p != nil && p.index == tagDotString } -func (p *PathNodeBase) DotString() (string, bool) { +func (p *PathNode) DotString() (string, bool) { if p == nil { return "", false } @@ -145,7 +95,7 @@ func (p *PathNodeBase) DotString() (string, bool) { return "", false } -func (p *PathNodeBase) BracketString() (string, bool) { +func (p *PathNode) BracketString() (string, bool) { if p == nil { return "", false } @@ -155,41 +105,8 @@ func (p *PathNodeBase) BracketString() (string, bool) { return "", false } -// formatNode writes the string representation of this node to the builder. -// isFirst indicates if this is the first node in the path (affects dot prefix). -func (p *PathNodeBase) formatNode(result *strings.Builder, isFirst bool) { - if p.index >= 0 { - result.WriteString("[") - result.WriteString(strconv.Itoa(p.index)) - result.WriteString("]") - } else if p.index == tagDotStar { - if isFirst { - result.WriteString("*") - } else { - result.WriteString(".*") - } - } else if p.index == tagBracketStar { - result.WriteString("[*]") - } else if p.index == tagKeyValue { - result.WriteString("[") - result.WriteString(p.key) - result.WriteString("=") - result.WriteString(EncodeMapKey(p.value)) - result.WriteString("]") - } else if p.index == tagDotString { - if !isFirst { - result.WriteString(".") - } - result.WriteString(p.key) - } else if p.index == tagBracketString { - result.WriteString("[") - result.WriteString(EncodeMapKey(p.key)) - result.WriteString("]") - } -} - // StringKey returns either Field() or MapKey() if either is available -func (p *PathNodeBase) StringKey() (string, bool) { +func (p *PathNode) StringKey() (string, bool) { if p == nil { return "", false } @@ -222,69 +139,32 @@ func (p *PathNode) AsSlice() []*PathNode { return segments } -// AsSlice returns the pattern as a slice of PatternNodes from root to current. -func (p *PatternNode) AsSlice() []*PatternNode { - length := p.Len() - segments := make([]*PatternNode, length) - - // Fill in reverse order - current := p - for i := length - 1; i >= 0; i-- { - segments[i] = current - current = current.Parent() - } - - return segments -} - -// Len returns the number of components in the pattern. -func (p *PatternNode) Len() int { - length := 0 - current := p - for current != nil { - length++ - current = current.Parent() - } - return length -} - -// String returns the string representation of the pattern. -func (p *PatternNode) String() string { - if p == nil { - return "" - } - components := p.AsSlice() - var result strings.Builder - for i, node := range components { - node.formatNode(&result, i == 0) - } - return result.String() -} - // NewIndex creates a new PathNode for an array/slice index. func NewIndex(prev *PathNode, index int) *PathNode { if index < 0 { panic("index must be non-negative") } return &PathNode{ - PathNodeBase: PathNodeBase{index: index}, - prev: prev, + prev: prev, + index: index, } } // NewDotString creates a PathNode for dot notation (.field). func NewDotString(prev *PathNode, fieldName string) *PathNode { return &PathNode{ - PathNodeBase: PathNodeBase{key: fieldName, index: tagDotString}, - prev: prev, + prev: prev, + key: fieldName, + index: tagDotString, } } // NewBracketString creates a PathNode for bracket notation (["field"]). func NewBracketString(prev *PathNode, fieldName string) *PathNode { return &PathNode{ - PathNodeBase: PathNodeBase{key: fieldName, index: tagBracketString}, - prev: prev, + prev: prev, + key: fieldName, + index: tagBracketString, } } @@ -299,93 +179,160 @@ func NewStringKey(prev *PathNode, fieldName string) *PathNode { func NewKeyValue(prev *PathNode, key, value string) *PathNode { return &PathNode{ - PathNodeBase: PathNodeBase{key: key, index: tagKeyValue, value: value}, - prev: prev, + prev: prev, + key: key, + index: tagKeyValue, + value: value, + } +} + +// String returns the string representation of the path. +// The string keys are encoded in dot syntax (foo.bar) if they don't have any reserved characters (so can be parsed as fields). +// Otherwise they are encoded in brackets + single quotes: tags['name']. Single quote can escaped by placing two single quotes. +// This encoding is chosen over traditional double quotes because when encoded in JSON it does not need to be escaped: +// +// { +// "resources.jobs.foo.tags['cost-center']": {} +// } +func (p *PathNode) String() string { + if p == nil { + return "" + } + + // Get all path components from root to current + components := p.AsSlice() + + var result strings.Builder + + for i, node := range components { + if node.index >= 0 { + // Array/slice index + result.WriteString("[") + result.WriteString(strconv.Itoa(node.index)) + result.WriteString("]") + } else if node.index == tagDotStar { + if i == 0 { + result.WriteString("*") + } else { + result.WriteString(".*") + } + } else if node.index == tagBracketStar { + result.WriteString("[*]") + } else if node.index == tagKeyValue { + result.WriteString("[") + result.WriteString(node.key) + result.WriteString("=") + result.WriteString(EncodeMapKey(node.value)) + result.WriteString("]") + } else if node.index == tagDotString { + // Dot notation: .field + if i != 0 { + result.WriteString(".") + } + result.WriteString(node.key) + } else if node.index == tagBracketString { + // Bracket notation: ['field'] + result.WriteString("[") + result.WriteString(EncodeMapKey(node.key)) + result.WriteString("]") + } + } + + return result.String() +} + +func EncodeMapKey(s string) string { + escaped := strings.ReplaceAll(s, "'", "''") + return "'" + escaped + "'" +} + +// PatternNode methods - delegate to PathNode via casting + +func (p *PatternNode) IsRoot() bool { + return (*PathNode)(p).IsRoot() +} + +func (p *PatternNode) Index() (int, bool) { + return (*PathNode)(p).Index() +} + +func (p *PatternNode) DotStar() bool { + return (*PathNode)(p).DotStar() +} + +func (p *PatternNode) BracketStar() bool { + return (*PathNode)(p).BracketStar() +} + +func (p *PatternNode) KeyValue() (key, value string, ok bool) { + return (*PathNode)(p).KeyValue() +} + +func (p *PatternNode) StringKey() (string, bool) { + return (*PathNode)(p).StringKey() +} + +func (p *PatternNode) Parent() *PatternNode { + return (*PatternNode)((*PathNode)(p).Parent()) +} + +func (p *PatternNode) Len() int { + return (*PathNode)(p).Len() +} + +func (p *PatternNode) String() string { + return (*PathNode)(p).String() +} + +// AsSlice returns the pattern as a slice of PatternNodes from root to current. +func (p *PatternNode) AsSlice() []*PatternNode { + pathSlice := (*PathNode)(p).AsSlice() + result := make([]*PatternNode, len(pathSlice)) + for i, node := range pathSlice { + result[i] = (*PatternNode)(node) } + return result } // PatternNode constructors // NewPatternIndex creates a new PatternNode for an array/slice index. func NewPatternIndex(prev *PatternNode, index int) *PatternNode { - if index < 0 { - panic("index must be non-negative") - } - return &PatternNode{ - PathNodeBase: PathNodeBase{index: index}, - prev: prev, - } + return (*PatternNode)(NewIndex((*PathNode)(prev), index)) } // NewPatternDotString creates a PatternNode for dot notation (.field). func NewPatternDotString(prev *PatternNode, fieldName string) *PatternNode { - return &PatternNode{ - PathNodeBase: PathNodeBase{key: fieldName, index: tagDotString}, - prev: prev, - } + return (*PatternNode)(NewDotString((*PathNode)(prev), fieldName)) } // NewPatternBracketString creates a PatternNode for bracket notation (["field"]). func NewPatternBracketString(prev *PatternNode, fieldName string) *PatternNode { - return &PatternNode{ - PathNodeBase: PathNodeBase{key: fieldName, index: tagBracketString}, - prev: prev, - } + return (*PatternNode)(NewBracketString((*PathNode)(prev), fieldName)) } // NewPatternStringKey creates a PatternNode, choosing dot notation if the fieldName is a valid field name, // otherwise bracket notation. func NewPatternStringKey(prev *PatternNode, fieldName string) *PatternNode { - if isValidField(fieldName) { - return NewPatternDotString(prev, fieldName) - } - return NewPatternBracketString(prev, fieldName) + return (*PatternNode)(NewStringKey((*PathNode)(prev), fieldName)) } func NewPatternDotStar(prev *PatternNode) *PatternNode { - return &PatternNode{ - PathNodeBase: PathNodeBase{index: tagDotStar}, - prev: prev, - } + return (*PatternNode)(&PathNode{ + prev: (*PathNode)(prev), + index: tagDotStar, + }) } func NewPatternBracketStar(prev *PatternNode) *PatternNode { - return &PatternNode{ - PathNodeBase: PathNodeBase{index: tagBracketStar}, - prev: prev, - } + return (*PatternNode)(&PathNode{ + prev: (*PathNode)(prev), + index: tagBracketStar, + }) } func NewPatternKeyValue(prev *PatternNode, key, value string) *PatternNode { - return &PatternNode{ - PathNodeBase: PathNodeBase{key: key, index: tagKeyValue, value: value}, - prev: prev, - } -} - -// String returns the string representation of the path. -// The string keys are encoded in dot syntax (foo.bar) if they don't have any reserved characters (so can be parsed as fields). -// Otherwise they are encoded in brackets + single quotes: tags['name']. Single quote can escaped by placing two single quotes. -// This encoding is chosen over traditional double quotes because when encoded in JSON it does not need to be escaped: -// -// { -// "resources.jobs.foo.tags['cost-center']": {} -// } -func (p *PathNode) String() string { - if p == nil { - return "" - } - components := p.AsSlice() - var result strings.Builder - for i, node := range components { - node.formatNode(&result, i == 0) - } - return result.String() -} - -func EncodeMapKey(s string) string { - escaped := strings.ReplaceAll(s, "'", "''") - return "'" + escaped + "'" + return (*PatternNode)(NewKeyValue((*PathNode)(prev), key, value)) } // Parse parses a string representation of a path using a state machine. @@ -434,8 +381,8 @@ func Parse(s string, wildcardAllowed bool) (*PathNode, *PatternNode, error) { stateEnd ) - // Parse into a slice of PathNodeBase values first - var nodes []PathNodeBase + // Parse into PathNode chain + var result *PathNode state := stateStart var currentToken strings.Builder var keyValueKey string @@ -473,11 +420,11 @@ parseLoop: case stateField: if ch == '.' { - nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagDotString}) + result = &PathNode{prev: result, key: currentToken.String(), index: tagDotString} currentToken.Reset() state = stateFieldStart } else if ch == '[' { - nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagDotString}) + result = &PathNode{prev: result, key: currentToken.String(), index: tagDotString} currentToken.Reset() state = stateBracketOpen } else if !isReservedFieldChar(ch) { @@ -489,10 +436,10 @@ parseLoop: case stateDotStar: switch ch { case '.': - nodes = append(nodes, PathNodeBase{index: tagDotStar}) + result = &PathNode{prev: result, index: tagDotStar} state = stateFieldStart case '[': - nodes = append(nodes, PathNodeBase{index: tagDotStar}) + result = &PathNode{prev: result, index: tagDotStar} state = stateBracketOpen default: return nil, nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) @@ -522,7 +469,7 @@ parseLoop: if err != nil { return nil, nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) } - nodes = append(nodes, PathNodeBase{index: index}) + result = &PathNode{prev: result, index: index} currentToken.Reset() state = stateExpectDotOrEnd } else { @@ -543,7 +490,7 @@ parseLoop: currentToken.WriteByte('\'') state = stateMapKey case ']': - nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagBracketString}) + result = &PathNode{prev: result, key: currentToken.String(), index: tagBracketString} currentToken.Reset() state = stateExpectDotOrEnd default: @@ -552,7 +499,7 @@ parseLoop: case stateWildcard: if ch == ']' { - nodes = append(nodes, PathNodeBase{index: tagBracketStar}) + result = &PathNode{prev: result, index: tagBracketStar} state = stateExpectDotOrEnd } else { return nil, nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) @@ -589,7 +536,7 @@ parseLoop: currentToken.WriteByte(ch) state = stateKeyValueValue case ']': - nodes = append(nodes, PathNodeBase{key: keyValueKey, index: tagKeyValue, value: currentToken.String()}) + result = &PathNode{prev: result, key: keyValueKey, index: tagKeyValue, value: currentToken.String()} currentToken.Reset() keyValueKey = "" state = stateExpectDotOrEnd @@ -622,9 +569,9 @@ parseLoop: case stateStart: // Empty path case stateField: - nodes = append(nodes, PathNodeBase{key: currentToken.String(), index: tagDotString}) + result = &PathNode{prev: result, key: currentToken.String(), index: tagDotString} case stateDotStar: - nodes = append(nodes, PathNodeBase{index: tagDotStar}) + result = &PathNode{prev: result, index: tagDotStar} case stateExpectDotOrEnd: // Already complete case stateFieldStart: @@ -658,30 +605,8 @@ parseLoop: return nil, nil, errors.New("wildcards not allowed in path") } - // Build the appropriate linked list - if len(nodes) == 0 { - return nil, nil, nil - } - if wildcardAllowed { - // Build PatternNode chain - var result *PatternNode - for _, node := range nodes { - result = &PatternNode{ - PathNodeBase: node, - prev: result, - } - } - return nil, result, nil - } - - // Build PathNode chain - var result *PathNode - for _, node := range nodes { - result = &PathNode{ - PathNodeBase: node, - prev: result, - } + return nil, (*PatternNode)(result), nil } return result, nil, nil } @@ -791,9 +716,10 @@ func (p *PathNode) SkipPrefix(n int) *PathNode { current := p for current != startNode { result = &PathNode{ - prev: result, - PathNodeBase: current.PathNodeBase, + prev: result, + key: current.key, value: current.value, + index: current.index, } current = current.Parent() } diff --git a/libs/structs/structpath/path_test.go b/libs/structs/structpath/path_test.go index 3d94961d21..246292b39c 100644 --- a/libs/structs/structpath/path_test.go +++ b/libs/structs/structpath/path_test.go @@ -8,102 +8,119 @@ import ( "go.yaml.in/yaml/v3" ) -func TestPathNode(t *testing.T) { +func TestPathAndPatternNode(t *testing.T) { tests := []struct { - name string - node *PathNode - String string - Index any - StringKey any - KeyValue []string // [key, value] or nil - Root any + name string + pathNode *PathNode // nil for wildcard-only patterns + patternNode *PatternNode // always set + String string + Index any + StringKey any + KeyValue []string // [key, value] or nil + Root any + DotStar bool + BracketStar bool + PathError string // expected error when parsing as path (for wildcards) }{ // Single node tests { - name: "nil path", - node: nil, - String: "", - Root: true, + name: "nil path", + pathNode: nil, + patternNode: nil, + String: "", + Root: true, }, { - name: "array index", - node: NewIndex(nil, 5), - String: "[5]", - Index: 5, + name: "array index", + pathNode: NewIndex(nil, 5), + patternNode: NewPatternIndex(nil, 5), + String: "[5]", + Index: 5, }, { - name: "map key", - node: NewDotString(nil, "mykey"), - String: `mykey`, - StringKey: "mykey", + name: "map key", + pathNode: NewDotString(nil, "mykey"), + patternNode: NewPatternStringKey(nil, "mykey"), + String: `mykey`, + StringKey: "mykey", }, { - name: "key value", - node: NewKeyValue(nil, "name", "foo"), - String: "[name='foo']", - KeyValue: []string{"name", "foo"}, + name: "key value", + pathNode: NewKeyValue(nil, "name", "foo"), + patternNode: NewPatternKeyValue(nil, "name", "foo"), + String: "[name='foo']", + KeyValue: []string{"name", "foo"}, }, // Two node tests { - name: "struct field -> array index", - node: NewIndex(NewDotString(nil, "items"), 3), - String: "items[3]", - Index: 3, + name: "struct field -> array index", + pathNode: NewIndex(NewDotString(nil, "items"), 3), + patternNode: NewPatternIndex(NewPatternStringKey(nil, "items"), 3), + String: "items[3]", + Index: 3, }, { - name: "struct field -> map key", - node: NewBracketString(NewDotString(nil, "config"), "database.name"), - String: `config['database.name']`, - StringKey: "database.name", + name: "struct field -> map key", + pathNode: NewBracketString(NewDotString(nil, "config"), "database.name"), + patternNode: NewPatternBracketString(NewPatternStringKey(nil, "config"), "database.name"), + String: `config['database.name']`, + StringKey: "database.name", }, { - name: "struct field -> struct field", - node: NewDotString(NewDotString(nil, "user"), "name"), - String: "user.name", - StringKey: "name", + name: "struct field -> struct field", + pathNode: NewDotString(NewDotString(nil, "user"), "name"), + patternNode: NewPatternDotString(NewPatternStringKey(nil, "user"), "name"), + String: "user.name", + StringKey: "name", }, { - name: "map key -> array index", - node: NewIndex(NewBracketString(nil, "servers list"), 0), - String: `['servers list'][0]`, - Index: 0, + name: "map key -> array index", + pathNode: NewIndex(NewBracketString(nil, "servers list"), 0), + patternNode: NewPatternIndex(NewPatternBracketString(nil, "servers list"), 0), + String: `['servers list'][0]`, + Index: 0, }, { - name: "array index -> struct field", - node: NewDotString(NewIndex(nil, 2), "id"), - String: "[2].id", - StringKey: "id", + name: "array index -> struct field", + pathNode: NewDotString(NewIndex(nil, 2), "id"), + patternNode: NewPatternDotString(NewPatternIndex(nil, 2), "id"), + String: "[2].id", + StringKey: "id", }, { - name: "array index -> map key", - node: NewStringKey(NewIndex(nil, 1), "status{}"), - String: `[1]['status{}']`, - StringKey: "status{}", + name: "array index -> map key", + pathNode: NewStringKey(NewIndex(nil, 1), "status{}"), + patternNode: NewPatternStringKey(NewPatternIndex(nil, 1), "status{}"), + String: `[1]['status{}']`, + StringKey: "status{}", }, // Edge cases with special characters in map keys { - name: "map key with single quote", - node: NewStringKey(nil, "key's"), - String: `['key''s']`, - StringKey: "key's", + name: "map key with single quote", + pathNode: NewStringKey(nil, "key's"), + patternNode: NewPatternStringKey(nil, "key's"), + String: `['key''s']`, + StringKey: "key's", }, { - name: "map key with multiple single quotes", - node: NewStringKey(nil, "''"), - String: `['''''']`, - StringKey: "''", + name: "map key with multiple single quotes", + pathNode: NewStringKey(nil, "''"), + patternNode: NewPatternStringKey(nil, "''"), + String: `['''''']`, + StringKey: "''", }, { - name: "empty map key", - node: NewStringKey(nil, ""), - String: `['']`, - StringKey: "", + name: "empty map key", + pathNode: NewStringKey(nil, ""), + patternNode: NewPatternStringKey(nil, ""), + String: `['']`, + StringKey: "", }, { name: "complex path", - node: NewStringKey( + pathNode: NewStringKey( NewIndex( NewStringKey( NewStringKey( @@ -112,212 +129,206 @@ func TestPathNode(t *testing.T) { "theme.list"), 0), "color"), + patternNode: NewPatternStringKey( + NewPatternIndex( + NewPatternStringKey( + NewPatternStringKey( + NewPatternStringKey(nil, "user"), + "settings"), + "theme.list"), + 0), + "color"), String: "user.settings['theme.list'][0].color", StringKey: "color", }, { - name: "field with special characters", - node: NewStringKey(nil, "field@name:with#symbols!"), - String: "field@name:with#symbols!", - StringKey: "field@name:with#symbols!", + name: "field with special characters", + pathNode: NewStringKey(nil, "field@name:with#symbols!"), + patternNode: NewPatternStringKey(nil, "field@name:with#symbols!"), + String: "field@name:with#symbols!", + StringKey: "field@name:with#symbols!", }, { - name: "field with spaces", - node: NewStringKey(nil, "field with spaces"), - String: "['field with spaces']", - StringKey: "field with spaces", + name: "field with spaces", + pathNode: NewStringKey(nil, "field with spaces"), + patternNode: NewPatternStringKey(nil, "field with spaces"), + String: "['field with spaces']", + StringKey: "field with spaces", }, { - name: "field starting with digit", - node: NewStringKey(nil, "123field"), - String: "123field", - StringKey: "123field", + name: "field starting with digit", + pathNode: NewStringKey(nil, "123field"), + patternNode: NewPatternStringKey(nil, "123field"), + String: "123field", + StringKey: "123field", }, { - name: "field with unicode", - node: NewStringKey(nil, "名前🙂"), - String: "名前🙂", - StringKey: "名前🙂", + name: "field with unicode", + pathNode: NewStringKey(nil, "名前🙂"), + patternNode: NewPatternStringKey(nil, "名前🙂"), + String: "名前🙂", + StringKey: "名前🙂", }, { - name: "map key with reserved characters", - node: NewStringKey(nil, "key\x00[],`"), - String: "['key\x00[],`']", - StringKey: "key\x00[],`", + name: "map key with reserved characters", + pathNode: NewStringKey(nil, "key\x00[],`"), + patternNode: NewPatternStringKey(nil, "key\x00[],`"), + String: "['key\x00[],`']", + StringKey: "key\x00[],`", }, // Key-value tests { - name: "key value with parent", - node: NewKeyValue(NewStringKey(nil, "tasks"), "task_key", "my_task"), - String: "tasks[task_key='my_task']", - KeyValue: []string{"task_key", "my_task"}, + name: "key value with parent", + pathNode: NewKeyValue(NewStringKey(nil, "tasks"), "task_key", "my_task"), + patternNode: NewPatternKeyValue(NewPatternStringKey(nil, "tasks"), "task_key", "my_task"), + String: "tasks[task_key='my_task']", + KeyValue: []string{"task_key", "my_task"}, }, { - name: "key value then field", - node: NewStringKey(NewKeyValue(nil, "name", "foo"), "id"), - String: "[name='foo'].id", - StringKey: "id", + name: "key value then field", + pathNode: NewStringKey(NewKeyValue(nil, "name", "foo"), "id"), + patternNode: NewPatternStringKey(NewPatternKeyValue(nil, "name", "foo"), "id"), + String: "[name='foo'].id", + StringKey: "id", }, { - name: "key value with quote in value", - node: NewKeyValue(nil, "name", "it's"), - String: "[name='it''s']", - KeyValue: []string{"name", "it's"}, + name: "key value with quote in value", + pathNode: NewKeyValue(nil, "name", "it's"), + patternNode: NewPatternKeyValue(nil, "name", "it's"), + String: "[name='it''s']", + KeyValue: []string{"name", "it's"}, }, { - name: "key value with empty value", - node: NewKeyValue(nil, "key", ""), - String: "[key='']", - KeyValue: []string{"key", ""}, + name: "key value with empty value", + pathNode: NewKeyValue(nil, "key", ""), + patternNode: NewPatternKeyValue(nil, "key", ""), + String: "[key='']", + KeyValue: []string{"key", ""}, }, { - name: "complex path with key value", - node: NewStringKey(NewKeyValue(NewStringKey(NewStringKey(nil, "resources"), "jobs"), "task_key", "my_task"), "notebook_task"), - String: "resources.jobs[task_key='my_task'].notebook_task", - StringKey: "notebook_task", + name: "complex path with key value", + pathNode: NewStringKey(NewKeyValue(NewStringKey(NewStringKey(nil, "resources"), "jobs"), "task_key", "my_task"), "notebook_task"), + patternNode: NewPatternStringKey(NewPatternKeyValue(NewPatternStringKey(NewPatternStringKey(nil, "resources"), "jobs"), "task_key", "my_task"), "notebook_task"), + String: "resources.jobs[task_key='my_task'].notebook_task", + StringKey: "notebook_task", }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Test String() method - result := tt.node.String() - assert.Equal(t, tt.String, result, "String() method") - - // Test roundtrip conversion: String() -> Parse() -> String() - parsed, _, err := Parse(tt.String, false) - if assert.NoError(t, err, "Parse() should not error") { - assert.Equal(t, tt.node, parsed) - roundtripResult := parsed.String() - assert.Equal(t, tt.String, roundtripResult, "Roundtrip conversion should be identical") - } - - // Index - gotIndex, isIndex := tt.node.Index() - if tt.Index == nil { - assert.Equal(t, -1, gotIndex) - assert.False(t, isIndex) - } else { - expectedIndex := tt.Index.(int) - assert.Equal(t, expectedIndex, gotIndex) - assert.True(t, isIndex) - } - - gotStringKey, isStringKey := tt.node.StringKey() - if tt.StringKey == nil { - assert.Equal(t, "", gotStringKey) - assert.False(t, isStringKey) - } else { - expected := tt.StringKey.(string) - assert.Equal(t, expected, gotStringKey) - assert.True(t, isStringKey) - } - - // KeyValue - gotKey, gotValue, isKeyValue := tt.node.KeyValue() - if tt.KeyValue == nil { - assert.Equal(t, "", gotKey) - assert.Equal(t, "", gotValue) - assert.False(t, isKeyValue) - } else { - assert.Equal(t, tt.KeyValue[0], gotKey) - assert.Equal(t, tt.KeyValue[1], gotValue) - assert.True(t, isKeyValue) - } - // IsRoot - isRoot := tt.node.IsRoot() - if tt.Root == nil { - assert.False(t, isRoot) - } else { - assert.True(t, isRoot) - } - }) - } -} - -func TestPatternNode(t *testing.T) { - tests := []struct { - name string - node *PatternNode - String string - DotStar bool - BracketStar bool - }{ + // Wildcard patterns (cannot be parsed as PathNode) { - name: "dot star", - node: NewPatternDotStar(nil), - String: "*", - DotStar: true, + name: "dot star", + patternNode: NewPatternDotStar(nil), + String: "*", + DotStar: true, + PathError: "wildcards not allowed in path", }, { name: "bracket star", - node: NewPatternBracketStar(nil), + patternNode: NewPatternBracketStar(nil), String: "[*]", BracketStar: true, + PathError: "wildcards not allowed in path", }, { - name: "dot star with parent", - node: NewPatternDotStar(NewPatternStringKey(nil, "Parent")), - String: "Parent.*", - DotStar: true, + name: "dot star with parent", + patternNode: NewPatternDotStar(NewPatternStringKey(nil, "Parent")), + String: "Parent.*", + DotStar: true, + PathError: "wildcards not allowed in path", }, { name: "bracket star with parent", - node: NewPatternBracketStar(NewPatternStringKey(nil, "Parent")), + patternNode: NewPatternBracketStar(NewPatternStringKey(nil, "Parent")), String: "Parent[*]", BracketStar: true, + PathError: "wildcards not allowed in path", }, { - name: "field dot star bracket index", - node: NewPatternIndex(NewPatternDotStar(NewPatternStringKey(nil, "bla")), 0), - String: "bla.*[0]", + name: "field dot star bracket index", + patternNode: NewPatternIndex(NewPatternDotStar(NewPatternStringKey(nil, "bla")), 0), + String: "bla.*[0]", + PathError: "wildcards not allowed in path", }, { name: "field dot star bracket star", - node: NewPatternBracketStar(NewPatternDotStar(NewPatternStringKey(nil, "bla"))), + patternNode: NewPatternBracketStar(NewPatternDotStar(NewPatternStringKey(nil, "bla"))), String: "bla.*[*]", BracketStar: true, + PathError: "wildcards not allowed in path", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Test String() method - result := tt.node.String() - assert.Equal(t, tt.String, result, "String() method") + // Test pattern parsing and roundtrip + _, parsedPattern, err := Parse(tt.String, true) + if assert.NoError(t, err, "ParsePattern() should not error") { + assert.Equal(t, tt.patternNode, parsedPattern) + assert.Equal(t, tt.String, parsedPattern.String(), "Pattern roundtrip") + } - // Test roundtrip conversion: String() -> Parse(wildcardAllowed=true) -> String() - _, parsed, err := Parse(tt.String, true) - if assert.NoError(t, err, "Parse() should not error") { - assert.Equal(t, tt.node, parsed) - roundtripResult := parsed.String() - assert.Equal(t, tt.String, roundtripResult, "Roundtrip conversion should be identical") + // Test DotStar and BracketStar on pattern + if tt.patternNode != nil { + assert.Equal(t, tt.DotStar, tt.patternNode.DotStar()) + assert.Equal(t, tt.BracketStar, tt.patternNode.BracketStar()) } - // DotStar and BracketStar - assert.Equal(t, tt.DotStar, tt.node.DotStar()) - assert.Equal(t, tt.BracketStar, tt.node.BracketStar()) - }) - } -} + // Test path parsing + if tt.PathError != "" { + // Wildcard pattern - should fail to parse as path + _, _, err := Parse(tt.String, false) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.PathError) + } + } else { + // Concrete path - should parse successfully as both path and pattern + parsedPath, _, err := Parse(tt.String, false) + if assert.NoError(t, err, "ParsePath() should not error") { + assert.Equal(t, tt.pathNode, parsedPath) + assert.Equal(t, tt.String, parsedPath.String(), "Path roundtrip") + } -func TestParseWildcardNotAllowed(t *testing.T) { - // Test that wildcards are rejected when wildcardAllowed=false - wildcardPaths := []string{ - "*", - "[*]", - "foo.*", - "foo[*]", - "foo.*[*]", - } + // Test PathNode-specific methods + gotIndex, isIndex := tt.pathNode.Index() + if tt.Index == nil { + assert.Equal(t, -1, gotIndex) + assert.False(t, isIndex) + } else { + expectedIndex := tt.Index.(int) + assert.Equal(t, expectedIndex, gotIndex) + assert.True(t, isIndex) + } - for _, path := range wildcardPaths { - t.Run(path, func(t *testing.T) { - _, _, err := Parse(path, false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "wildcards not allowed") + gotStringKey, isStringKey := tt.pathNode.StringKey() + if tt.StringKey == nil { + assert.Equal(t, "", gotStringKey) + assert.False(t, isStringKey) + } else { + expected := tt.StringKey.(string) + assert.Equal(t, expected, gotStringKey) + assert.True(t, isStringKey) + } + + // KeyValue + gotKey, gotValue, isKeyValue := tt.pathNode.KeyValue() + if tt.KeyValue == nil { + assert.Equal(t, "", gotKey) + assert.Equal(t, "", gotValue) + assert.False(t, isKeyValue) + } else { + assert.Equal(t, tt.KeyValue[0], gotKey) + assert.Equal(t, tt.KeyValue[1], gotValue) + assert.True(t, isKeyValue) + } + + // IsRoot + isRoot := tt.pathNode.IsRoot() + if tt.Root == nil { + assert.False(t, isRoot) + } else { + assert.True(t, isRoot) + } + } }) } } From d93288109c467e2627f119c42996855524196a67 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 14:01:16 +0100 Subject: [PATCH 03/11] Simplify ValidatePattern to delegate directly to ValidatePath ValidatePath's internal validateNodeSlice already handles wildcards (BracketStar and DotStar), so no need for the slice copying loop. Co-Authored-By: Claude Opus 4.5 --- libs/structs/structaccess/typecheck.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index bf3f96a549..004aca028b 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -40,16 +40,8 @@ func ValidatePath(t reflect.Type, path *structpath.PathNode) error { // It returns nil if the path resolves fully, or an error indicating where resolution failed. // Patterns may include wildcards ([*] and .*). func ValidatePattern(t reflect.Type, path *structpath.PatternNode) error { - if path.IsRoot() { - return nil - } - // PatternNode is type definition of PathNode, so we can cast the slice - patternNodes := path.AsSlice() - pathNodes := make([]*structpath.PathNode, len(patternNodes)) - for i, n := range patternNodes { - pathNodes[i] = (*structpath.PathNode)(n) - } - return validateNodeSlice(t, pathNodes) + // PatternNode is type definition of PathNode, so we can cast directly + return ValidatePath(t, (*structpath.PathNode)(path)) } // validateNodeSlice is the shared implementation for ValidatePath and ValidatePattern. From 46596ba03cf7303e35edca0f8ee971413fa323bd Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 14:04:56 +0100 Subject: [PATCH 04/11] Reimplement PatternNode.AsSlice and restore Parse transitions comment - Reimplement AsSlice() directly instead of delegating to PathNode - Restore the state machine transitions documentation Co-Authored-By: Claude Opus 4.5 --- libs/structs/structpath/path.go | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index c5b3d9748f..019d30cda9 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -286,12 +286,17 @@ func (p *PatternNode) String() string { // AsSlice returns the pattern as a slice of PatternNodes from root to current. func (p *PatternNode) AsSlice() []*PatternNode { - pathSlice := (*PathNode)(p).AsSlice() - result := make([]*PatternNode, len(pathSlice)) - for i, node := range pathSlice { - result[i] = (*PatternNode)(node) + length := p.Len() + segments := make([]*PatternNode, length) + + // Fill in reverse order + current := p + for i := length - 1; i >= 0; i-- { + segments[i] = current + current = current.Parent() } - return result + + return segments } // PatternNode constructors @@ -357,6 +362,22 @@ func NewPatternKeyValue(prev *PatternNode, key, value string) *PatternNode { // - KEYVALUE_VALUE_QUOTE: Encountered quote in value, expects same quote (escape) or "]" (end) // - EXPECT_DOT_OR_END: After bracket close, expects ".", "[" or end of string // - END: Successfully completed parsing +// +// Transitions: +// - START: [a-zA-Z_-] -> FIELD, "[" -> BRACKET_OPEN, "*" -> DOT_STAR, EOF -> END +// - FIELD_START: [a-zA-Z_-] -> FIELD, "*" -> DOT_STAR, other -> ERROR +// - FIELD: [a-zA-Z0-9_-] -> FIELD, "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END +// - DOT_STAR: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END, other -> ERROR +// - BRACKET_OPEN: [0-9] -> INDEX, "'" -> MAP_KEY, "*" -> WILDCARD, identifier -> KEYVALUE_KEY +// - INDEX: [0-9] -> INDEX, "]" -> EXPECT_DOT_OR_END +// - MAP_KEY: (any except "'") -> MAP_KEY, "'" -> MAP_KEY_QUOTE +// - MAP_KEY_QUOTE: "'" -> MAP_KEY (escape), "]" -> EXPECT_DOT_OR_END (end key) +// - WILDCARD: "]" -> EXPECT_DOT_OR_END +// - KEYVALUE_KEY: identifier -> KEYVALUE_KEY, "=" -> KEYVALUE_EQUALS +// - KEYVALUE_EQUALS: "'" or '"' -> KEYVALUE_VALUE +// - KEYVALUE_VALUE: (any except quote) -> KEYVALUE_VALUE, quote -> KEYVALUE_VALUE_QUOTE +// - KEYVALUE_VALUE_QUOTE: quote -> KEYVALUE_VALUE (escape), "]" -> EXPECT_DOT_OR_END +// - EXPECT_DOT_OR_END: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END func Parse(s string, wildcardAllowed bool) (*PathNode, *PatternNode, error) { if s == "" { return nil, nil, nil From 101b2f2daaa5f7087df8eff4271a84a2cf15acfa Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 14:09:23 +0100 Subject: [PATCH 05/11] Simplify parse function signature to return *PatternNode - Rename Parse to parse (unexported) - Return (*PatternNode, error) instead of (*PathNode, *PatternNode, error) - ParsePattern returns directly, ParsePath casts to *PathNode - Restore state machine transitions documentation Co-Authored-By: Claude Opus 4.5 --- libs/structs/structpath/path.go | 120 +++++++++++++++------------ libs/structs/structpath/path_test.go | 20 ++--- 2 files changed, 74 insertions(+), 66 deletions(-) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 019d30cda9..d8a1c1120a 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -177,6 +177,20 @@ func NewStringKey(prev *PathNode, fieldName string) *PathNode { return NewBracketString(prev, fieldName) } +func NewDotStar(prev *PathNode) *PathNode { + return &PathNode{ + prev: prev, + index: tagDotStar, + } +} + +func NewBracketStar(prev *PathNode) *PathNode { + return &PathNode{ + prev: prev, + index: tagBracketStar, + } +} + func NewKeyValue(prev *PathNode, key, value string) *PathNode { return &PathNode{ prev: prev, @@ -378,9 +392,9 @@ func NewPatternKeyValue(prev *PatternNode, key, value string) *PatternNode { // - KEYVALUE_VALUE: (any except quote) -> KEYVALUE_VALUE, quote -> KEYVALUE_VALUE_QUOTE // - KEYVALUE_VALUE_QUOTE: quote -> KEYVALUE_VALUE (escape), "]" -> EXPECT_DOT_OR_END // - EXPECT_DOT_OR_END: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END -func Parse(s string, wildcardAllowed bool) (*PathNode, *PatternNode, error) { +func parse(s string, wildcardAllowed bool) (*PatternNode, error) { if s == "" { - return nil, nil, nil + return nil, nil } // State machine states @@ -402,15 +416,13 @@ func Parse(s string, wildcardAllowed bool) (*PathNode, *PatternNode, error) { stateEnd ) - // Parse into PathNode chain - var result *PathNode state := stateStart + var result *PathNode var currentToken strings.Builder - var keyValueKey string + var keyValueKey string // Stores the key part of [key='value'] pos := 0 hasWildcard := false -parseLoop: for pos < len(s) { ch := s[pos] @@ -425,7 +437,7 @@ parseLoop: currentToken.WriteByte(ch) state = stateField } else { - return nil, nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) } case stateFieldStart: @@ -436,34 +448,34 @@ parseLoop: currentToken.WriteByte(ch) state = stateField } else { - return nil, nil, fmt.Errorf("expected field name after '.' but got '%c' at position %d", ch, pos) + return nil, fmt.Errorf("expected field name after '.' but got '%c' at position %d", ch, pos) } case stateField: if ch == '.' { - result = &PathNode{prev: result, key: currentToken.String(), index: tagDotString} + result = NewDotString(result, currentToken.String()) currentToken.Reset() state = stateFieldStart } else if ch == '[' { - result = &PathNode{prev: result, key: currentToken.String(), index: tagDotString} + result = NewDotString(result, currentToken.String()) currentToken.Reset() state = stateBracketOpen } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) } else { - return nil, nil, fmt.Errorf("invalid character '%c' in field name at position %d", ch, pos) + return nil, fmt.Errorf("invalid character '%c' in field name at position %d", ch, pos) } case stateDotStar: switch ch { case '.': - result = &PathNode{prev: result, index: tagDotStar} + result = NewDotStar(result) state = stateFieldStart case '[': - result = &PathNode{prev: result, index: tagDotStar} + result = NewDotStar(result) state = stateBracketOpen default: - return nil, nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) } case stateBracketOpen: @@ -479,7 +491,7 @@ parseLoop: currentToken.WriteByte(ch) state = stateKeyValueKey } else { - return nil, nil, fmt.Errorf("unexpected character '%c' after '[' at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' after '[' at position %d", ch, pos) } case stateIndex: @@ -488,13 +500,13 @@ parseLoop: } else if ch == ']' { index, err := strconv.Atoi(currentToken.String()) if err != nil { - return nil, nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) + return nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) } - result = &PathNode{prev: result, index: index} + result = NewIndex(result, index) currentToken.Reset() state = stateExpectDotOrEnd } else { - return nil, nil, fmt.Errorf("unexpected character '%c' in index at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' in index at position %d", ch, pos) } case stateMapKey: @@ -508,22 +520,24 @@ parseLoop: case stateMapKeyQuote: switch ch { case '\'': + // Escaped quote - add single quote to key and continue currentToken.WriteByte('\'') state = stateMapKey case ']': - result = &PathNode{prev: result, key: currentToken.String(), index: tagBracketString} + // End of map key + result = NewBracketString(result, currentToken.String()) currentToken.Reset() state = stateExpectDotOrEnd default: - return nil, nil, fmt.Errorf("unexpected character '%c' after quote in map key at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' after quote in map key at position %d", ch, pos) } case stateWildcard: if ch == ']' { - result = &PathNode{prev: result, index: tagBracketStar} + result = NewBracketStar(result) state = stateExpectDotOrEnd } else { - return nil, nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) } case stateKeyValueKey: @@ -534,14 +548,14 @@ parseLoop: } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) } else { - return nil, nil, fmt.Errorf("unexpected character '%c' in key-value key at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' in key-value key at position %d", ch, pos) } case stateKeyValueEquals: if ch == '\'' { state = stateKeyValueValue } else { - return nil, nil, fmt.Errorf("expected quote after '=' but got '%c' at position %d", ch, pos) + return nil, fmt.Errorf("expected quote after '=' but got '%c' at position %d", ch, pos) } case stateKeyValueValue: @@ -557,12 +571,12 @@ parseLoop: currentToken.WriteByte(ch) state = stateKeyValueValue case ']': - result = &PathNode{prev: result, key: keyValueKey, index: tagKeyValue, value: currentToken.String()} + result = NewKeyValue(result, keyValueKey, currentToken.String()) currentToken.Reset() keyValueKey = "" state = stateExpectDotOrEnd default: - return nil, nil, fmt.Errorf("unexpected character '%c' after quote in key-value at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' after quote in key-value at position %d", ch, pos) } case stateExpectDotOrEnd: @@ -572,14 +586,14 @@ parseLoop: case '[': state = stateBracketOpen default: - return nil, nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) + return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) } case stateEnd: - break parseLoop + break default: - return nil, nil, fmt.Errorf("parser error at position %d", pos) + return nil, fmt.Errorf("parser error at position %d", pos) } pos++ @@ -590,58 +604,54 @@ parseLoop: case stateStart: // Empty path case stateField: - result = &PathNode{prev: result, key: currentToken.String(), index: tagDotString} + result = NewDotString(result, currentToken.String()) case stateDotStar: - result = &PathNode{prev: result, index: tagDotStar} + result = NewDotStar(result) case stateExpectDotOrEnd: // Already complete case stateFieldStart: - return nil, nil, errors.New("unexpected end of input after '.'") + return nil, errors.New("unexpected end of input after '.'") case stateBracketOpen: - return nil, nil, errors.New("unexpected end of input after '['") + return nil, errors.New("unexpected end of input after '['") case stateIndex: - return nil, nil, errors.New("unexpected end of input while parsing index") + return nil, errors.New("unexpected end of input while parsing index") case stateMapKey: - return nil, nil, errors.New("unexpected end of input while parsing map key") + return nil, errors.New("unexpected end of input while parsing map key") case stateMapKeyQuote: - return nil, nil, errors.New("unexpected end of input after quote in map key") + return nil, errors.New("unexpected end of input after quote in map key") case stateWildcard: - return nil, nil, errors.New("unexpected end of input after wildcard '*'") + return nil, errors.New("unexpected end of input after wildcard '*'") case stateKeyValueKey: - return nil, nil, errors.New("unexpected end of input while parsing key-value key") + return nil, errors.New("unexpected end of input while parsing key-value key") case stateKeyValueEquals: - return nil, nil, errors.New("unexpected end of input after '=' in key-value") + return nil, errors.New("unexpected end of input after '=' in key-value") case stateKeyValueValue: - return nil, nil, errors.New("unexpected end of input while parsing key-value value") + return nil, errors.New("unexpected end of input while parsing key-value value") case stateKeyValueValueQuote: - return nil, nil, errors.New("unexpected end of input after quote in key-value value") + return nil, errors.New("unexpected end of input after quote in key-value value") case stateEnd: // Already complete default: - return nil, nil, fmt.Errorf("parser error at position %d", pos) + return nil, fmt.Errorf("parser error at position %d", pos) } // Check wildcard constraint if hasWildcard && !wildcardAllowed { - return nil, nil, errors.New("wildcards not allowed in path") + return nil, errors.New("wildcards not allowed in path") } - if wildcardAllowed { - return nil, (*PatternNode)(result), nil - } - return result, nil, nil + return (*PatternNode)(result), nil } // ParsePath parses a path string. Wildcards are not allowed. func ParsePath(s string) (*PathNode, error) { - path, _, err := Parse(s, false) - return path, err + pattern, err := parse(s, false) + return (*PathNode)(pattern), err } // ParsePattern parses a pattern string. Wildcards are allowed. func ParsePattern(s string) (*PatternNode, error) { - _, pattern, err := Parse(s, true) - return pattern, err + return parse(s, true) } // MustParsePath parses a path string and panics on error. Wildcards are not allowed. @@ -711,12 +721,12 @@ func PureReferenceToPath(s string) (*PathNode, bool) { return nil, false } - pathNode, _, err := Parse(ref.References()[0], false) + pattern, err := parse(ref.References()[0], false) if err != nil { return nil, false } - return pathNode, true + return (*PathNode)(pattern), true } // SkipPrefix returns a new PathNode that skips the first n components of the path. @@ -866,13 +876,13 @@ func (p *PathNode) UnmarshalYAML(unmarshal func(any) error) error { if err := unmarshal(&s); err != nil { return err } - parsed, _, err := Parse(s, false) + parsed, err := parse(s, false) if err != nil { return err } if parsed == nil { return nil } - *p = *parsed + *p = *(*PathNode)(parsed) return nil } diff --git a/libs/structs/structpath/path_test.go b/libs/structs/structpath/path_test.go index 246292b39c..a0b2e74737 100644 --- a/libs/structs/structpath/path_test.go +++ b/libs/structs/structpath/path_test.go @@ -261,7 +261,7 @@ func TestPathAndPatternNode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Test pattern parsing and roundtrip - _, parsedPattern, err := Parse(tt.String, true) + parsedPattern, err := ParsePattern(tt.String) if assert.NoError(t, err, "ParsePattern() should not error") { assert.Equal(t, tt.patternNode, parsedPattern) assert.Equal(t, tt.String, parsedPattern.String(), "Pattern roundtrip") @@ -276,13 +276,13 @@ func TestPathAndPatternNode(t *testing.T) { // Test path parsing if tt.PathError != "" { // Wildcard pattern - should fail to parse as path - _, _, err := Parse(tt.String, false) + _, err := ParsePath(tt.String) if assert.Error(t, err) { assert.Contains(t, err.Error(), tt.PathError) } } else { // Concrete path - should parse successfully as both path and pattern - parsedPath, _, err := Parse(tt.String, false) + parsedPath, err := ParsePath(tt.String) if assert.NoError(t, err, "ParsePath() should not error") { assert.Equal(t, tt.pathNode, parsedPath) assert.Equal(t, tt.String, parsedPath.String(), "Path roundtrip") @@ -537,7 +537,7 @@ func TestParseErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, _, err := Parse(tt.input, true) // Allow wildcards in error tests + _, err := ParsePattern(tt.input) // Allow wildcards in error tests if assert.Error(t, err) { assert.Equal(t, tt.error, err.Error()) } @@ -640,7 +640,7 @@ func TestPrefixAndSkipPrefix(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - path, _, err := Parse(tt.input, false) + path, err := ParsePath(tt.input) assert.NoError(t, err) // Test Prefix @@ -685,9 +685,7 @@ func TestLen(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - var path *PathNode - var err error - path, _, err = Parse(tt.input, false) + path, err := ParsePath(tt.input) assert.NoError(t, err) assert.Equal(t, tt.expected, path.Len()) }) @@ -851,10 +849,10 @@ func TestHasPrefix(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, _, err := Parse(tt.s, false) + path, err := ParsePath(tt.s) require.NoError(t, err) - prefix, _, err := Parse(tt.prefix, false) + prefix, err := ParsePath(tt.prefix) require.NoError(t, err) result := path.HasPrefix(prefix) @@ -1016,7 +1014,7 @@ func TestPathNodeYAMLRoundtrip(t *testing.T) { for _, path := range paths { t.Run(path, func(t *testing.T) { // Parse -> Marshal -> Unmarshal -> compare - original, _, err := Parse(path, false) + original, err := ParsePath(path) require.NoError(t, err) data, err := yaml.Marshal(original) From 5b0f4579ba74053f641bc374a73eb3a972bead53 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 15:49:54 +0100 Subject: [PATCH 06/11] Fail fast on wildcards when not allowed Return error immediately when wildcard is encountered and wildcardAllowed is false, instead of tracking and checking at the end. Co-Authored-By: Claude Opus 4.5 --- libs/structs/structpath/path.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index d8a1c1120a..d41a062c80 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -421,7 +421,6 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { var currentToken strings.Builder var keyValueKey string // Stores the key part of [key='value'] pos := 0 - hasWildcard := false for pos < len(s) { ch := s[pos] @@ -431,7 +430,9 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { if ch == '[' { state = stateBracketOpen } else if ch == '*' { - hasWildcard = true + if !wildcardAllowed { + return nil, errors.New("wildcards not allowed in path") + } state = stateDotStar } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) @@ -442,7 +443,9 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateFieldStart: if ch == '*' { - hasWildcard = true + if !wildcardAllowed { + return nil, errors.New("wildcards not allowed in path") + } state = stateDotStar } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) @@ -485,7 +488,9 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { } else if ch == '\'' { state = stateMapKey } else if ch == '*' { - hasWildcard = true + if !wildcardAllowed { + return nil, errors.New("wildcards not allowed in path") + } state = stateWildcard } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) @@ -635,11 +640,6 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { return nil, fmt.Errorf("parser error at position %d", pos) } - // Check wildcard constraint - if hasWildcard && !wildcardAllowed { - return nil, errors.New("wildcards not allowed in path") - } - return (*PatternNode)(result), nil } From ade869392fefc5e5ddcd5a93a47dafa769040184 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 16:46:40 +0100 Subject: [PATCH 07/11] Restore comments in stateKeyValueValueQuote Co-Authored-By: Claude Opus 4.5 --- libs/structs/structpath/path.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index d41a062c80..d8391584db 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -573,9 +573,11 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateKeyValueValueQuote: switch ch { case '\'': + // Escaped quote - add single quote to value and continue currentToken.WriteByte(ch) state = stateKeyValueValue case ']': + // End of key-value result = NewKeyValue(result, keyValueKey, currentToken.String()) currentToken.Reset() keyValueKey = "" From d15948674f85f0683526a0ea5aea4fe206c0e5e2 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 16:51:59 +0100 Subject: [PATCH 08/11] Remove wildcard methods from PathNode PathNode should not have wildcard-related methods since wildcards can only exist in PatternNode. This enforces type safety - code working with PathNode cannot accidentally handle wildcards. - Remove DotStar() and BracketStar() from PathNode - Remove NewDotStar() and NewBracketStar() constructors - PatternNode now has its own DotStar/BracketStar implementations - validateNodeSlice now accepts []*PatternNode - ValidatePath delegates to ValidatePattern via cast Co-Authored-By: Claude Opus 4.5 --- libs/structs/structaccess/typecheck.go | 16 ++++----- libs/structs/structpath/path.go | 46 +++++++------------------- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index 004aca028b..c94a21617f 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -30,22 +30,22 @@ func ValidateByString(t reflect.Type, path string) error { // It returns nil if the path resolves fully, or an error indicating where resolution failed. // Paths cannot contain wildcards. func ValidatePath(t reflect.Type, path *structpath.PathNode) error { - if path.IsRoot() { - return nil - } - return validateNodeSlice(t, path.AsSlice()) + // PathNode is type definition of PatternNode, so we can cast directly + return ValidatePattern(t, (*structpath.PatternNode)(path)) } // ValidatePattern reports whether the given pattern path is valid for the provided type. // It returns nil if the path resolves fully, or an error indicating where resolution failed. // Patterns may include wildcards ([*] and .*). func ValidatePattern(t reflect.Type, path *structpath.PatternNode) error { - // PatternNode is type definition of PathNode, so we can cast directly - return ValidatePath(t, (*structpath.PathNode)(path)) + if path.IsRoot() { + return nil + } + return validateNodeSlice(t, path.AsSlice()) } -// validateNodeSlice is the shared implementation for ValidatePath and ValidatePattern. -func validateNodeSlice(t reflect.Type, nodes []*structpath.PathNode) error { +// validateNodeSlice is the implementation for ValidatePattern. +func validateNodeSlice(t reflect.Type, nodes []*structpath.PatternNode) error { cur := t for _, node := range nodes { for cur.Kind() == reflect.Pointer { diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index d8391584db..f5205c8c09 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -57,20 +57,6 @@ func (p *PathNode) Index() (int, bool) { return -1, false } -func (p *PathNode) DotStar() bool { - if p == nil { - return false - } - return p.index == tagDotStar -} - -func (p *PathNode) BracketStar() bool { - if p == nil { - return false - } - return p.index == tagBracketStar -} - func (p *PathNode) KeyValue() (key, value string, ok bool) { if p == nil { return "", "", false @@ -177,20 +163,6 @@ func NewStringKey(prev *PathNode, fieldName string) *PathNode { return NewBracketString(prev, fieldName) } -func NewDotStar(prev *PathNode) *PathNode { - return &PathNode{ - prev: prev, - index: tagDotStar, - } -} - -func NewBracketStar(prev *PathNode) *PathNode { - return &PathNode{ - prev: prev, - index: tagBracketStar, - } -} - func NewKeyValue(prev *PathNode, key, value string) *PathNode { return &PathNode{ prev: prev, @@ -271,11 +243,17 @@ func (p *PatternNode) Index() (int, bool) { } func (p *PatternNode) DotStar() bool { - return (*PathNode)(p).DotStar() + if p == nil { + return false + } + return p.index == tagDotStar } func (p *PatternNode) BracketStar() bool { - return (*PathNode)(p).BracketStar() + if p == nil { + return false + } + return p.index == tagBracketStar } func (p *PatternNode) KeyValue() (key, value string, ok bool) { @@ -472,10 +450,10 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateDotStar: switch ch { case '.': - result = NewDotStar(result) + result = &PathNode{prev: result, index: tagDotStar} state = stateFieldStart case '[': - result = NewDotStar(result) + result = &PathNode{prev: result, index: tagDotStar} state = stateBracketOpen default: return nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) @@ -539,7 +517,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateWildcard: if ch == ']' { - result = NewBracketStar(result) + result = &PathNode{prev: result, index: tagBracketStar} state = stateExpectDotOrEnd } else { return nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) @@ -613,7 +591,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateField: result = NewDotString(result, currentToken.String()) case stateDotStar: - result = NewDotStar(result) + result = &PathNode{prev: result, index: tagDotStar} case stateExpectDotOrEnd: // Already complete case stateFieldStart: From 61ef73da32f0a43ca6c3af0df75038e9e0f82e2c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 16:59:16 +0100 Subject: [PATCH 09/11] Use PatternNode constructors in parse function The parse function now builds *PatternNode directly using PatternNode constructors, rather than building *PathNode and casting at the end. This is more consistent since wildcards only exist in PatternNode. Co-Authored-By: Claude Opus 4.5 --- libs/structs/structpath/path.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index f5205c8c09..9b5cba8209 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -395,7 +395,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { ) state := stateStart - var result *PathNode + var result *PatternNode var currentToken strings.Builder var keyValueKey string // Stores the key part of [key='value'] pos := 0 @@ -434,11 +434,11 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateField: if ch == '.' { - result = NewDotString(result, currentToken.String()) + result = NewPatternDotString(result, currentToken.String()) currentToken.Reset() state = stateFieldStart } else if ch == '[' { - result = NewDotString(result, currentToken.String()) + result = NewPatternDotString(result, currentToken.String()) currentToken.Reset() state = stateBracketOpen } else if !isReservedFieldChar(ch) { @@ -450,10 +450,10 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateDotStar: switch ch { case '.': - result = &PathNode{prev: result, index: tagDotStar} + result = NewPatternDotStar(result) state = stateFieldStart case '[': - result = &PathNode{prev: result, index: tagDotStar} + result = NewPatternDotStar(result) state = stateBracketOpen default: return nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) @@ -485,7 +485,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { if err != nil { return nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) } - result = NewIndex(result, index) + result = NewPatternIndex(result, index) currentToken.Reset() state = stateExpectDotOrEnd } else { @@ -508,7 +508,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { state = stateMapKey case ']': // End of map key - result = NewBracketString(result, currentToken.String()) + result = NewPatternBracketString(result, currentToken.String()) currentToken.Reset() state = stateExpectDotOrEnd default: @@ -517,7 +517,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateWildcard: if ch == ']' { - result = &PathNode{prev: result, index: tagBracketStar} + result = NewPatternBracketStar(result) state = stateExpectDotOrEnd } else { return nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) @@ -556,7 +556,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { state = stateKeyValueValue case ']': // End of key-value - result = NewKeyValue(result, keyValueKey, currentToken.String()) + result = NewPatternKeyValue(result, keyValueKey, currentToken.String()) currentToken.Reset() keyValueKey = "" state = stateExpectDotOrEnd @@ -589,9 +589,9 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { case stateStart: // Empty path case stateField: - result = NewDotString(result, currentToken.String()) + result = NewPatternDotString(result, currentToken.String()) case stateDotStar: - result = &PathNode{prev: result, index: tagDotStar} + result = NewPatternDotStar(result) case stateExpectDotOrEnd: // Already complete case stateFieldStart: @@ -620,7 +620,7 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { return nil, fmt.Errorf("parser error at position %d", pos) } - return (*PatternNode)(result), nil + return result, nil } // ParsePath parses a path string. Wildcards are not allowed. From 411163c297797c477ed813b554382ce685a57bec Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 6 Feb 2026 17:05:16 +0100 Subject: [PATCH 10/11] Fix stale comments and restore dropped comments - Fix parse function doc comment to match new signature - Restore comments in validateNodeSlice Co-Authored-By: Claude Opus 4.5 --- libs/structs/structaccess/typecheck.go | 5 +++++ libs/structs/structpath/path.go | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index c94a21617f..d2f8ed2581 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -48,10 +48,12 @@ func ValidatePattern(t reflect.Type, path *structpath.PatternNode) error { func validateNodeSlice(t reflect.Type, nodes []*structpath.PatternNode) error { cur := t for _, node := range nodes { + // Always dereference pointers at the type level. for cur.Kind() == reflect.Pointer { cur = cur.Elem() } + // Index access: slice/array if _, isIndex := node.Index(); isIndex { kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { @@ -61,6 +63,7 @@ func validateNodeSlice(t reflect.Type, nodes []*structpath.PatternNode) error { continue } + // Handle wildcards - treat like index/key access if node.BracketStar() { kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { @@ -77,6 +80,7 @@ func validateNodeSlice(t reflect.Type, nodes []*structpath.PatternNode) error { continue } + // Handle key-value selector: validates that we can index the slice/array if _, _, isKeyValue := node.KeyValue(); isKeyValue { kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { @@ -87,6 +91,7 @@ func validateNodeSlice(t reflect.Type, nodes []*structpath.PatternNode) error { } key, ok := node.StringKey() + if !ok { return errors.New("unsupported path node type") } diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 9b5cba8209..31f846ed16 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -332,9 +332,9 @@ func NewPatternKeyValue(prev *PatternNode, key, value string) *PatternNode { return (*PatternNode)(NewKeyValue((*PathNode)(prev), key, value)) } -// Parse parses a string representation of a path using a state machine. -// If wildcardAllowed is true, returns (nil, *PatternNode, nil) on success. -// If wildcardAllowed is false, returns (*PathNode, nil, nil) on success but errors if wildcards are found. +// parse parses a string representation of a path or pattern using a state machine. +// Returns *PatternNode on success. If wildcardAllowed is false and wildcards are +// encountered, returns an error. // // State Machine for Path Parsing: // From c1c6e173fb03bb0c435d788060d6fbc4fc0ea2ae Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 9 Feb 2026 16:10:45 +0100 Subject: [PATCH 11/11] Remove dead stateEnd code and add missing parse() test coverage stateEnd was unreachable (no transition ever set it) and its break only exited the switch, not the for loop. Remove the constant and both case branches. Add test cases for previously uncovered error paths: invalid char in index, junk after wildcard, junk after dot star, and dot-star-then-dot-field pattern. Co-Authored-By: Claude Opus 4.6 --- libs/structs/structpath/path.go | 10 ---------- libs/structs/structpath/path_test.go | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 31f846ed16..fa5ccf5889 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -391,7 +391,6 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { stateKeyValueValue stateKeyValueValueQuote stateExpectDotOrEnd - stateEnd ) state := stateStart @@ -574,11 +573,6 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { return nil, fmt.Errorf("unexpected character '%c' at position %d", ch, pos) } - case stateEnd: - break - - default: - return nil, fmt.Errorf("parser error at position %d", pos) } pos++ @@ -614,10 +608,6 @@ func parse(s string, wildcardAllowed bool) (*PatternNode, error) { return nil, errors.New("unexpected end of input while parsing key-value value") case stateKeyValueValueQuote: return nil, errors.New("unexpected end of input after quote in key-value value") - case stateEnd: - // Already complete - default: - return nil, fmt.Errorf("parser error at position %d", pos) } return result, nil diff --git a/libs/structs/structpath/path_test.go b/libs/structs/structpath/path_test.go index a0b2e74737..b20832b4e3 100644 --- a/libs/structs/structpath/path_test.go +++ b/libs/structs/structpath/path_test.go @@ -256,6 +256,13 @@ func TestPathAndPatternNode(t *testing.T) { BracketStar: true, PathError: "wildcards not allowed in path", }, + { + name: "dot star then dot field", + patternNode: NewPatternDotString(NewPatternDotStar(nil), "name"), + String: "*.name", + StringKey: "name", + PathError: "wildcards not allowed in path", + }, } for _, tt := range tests { @@ -469,6 +476,11 @@ func TestParseErrors(t *testing.T) { input: "field[123", error: "unexpected end of input while parsing index", }, + { + name: "invalid char in index", + input: "field[1x]", + error: "unexpected character 'x' in index at position 7", + }, { name: "incomplete wildcard", input: "field[*", @@ -523,6 +535,16 @@ func TestParseErrors(t *testing.T) { input: "[name='value'x]", error: "unexpected character 'x' after quote in key-value at position 13", }, + { + name: "junk after wildcard in brackets", + input: "[*x]", + error: "unexpected character 'x' after '*' at position 2", + }, + { + name: "junk after dot star", + input: "a.*x", + error: "unexpected character 'x' after '.*' at position 3", + }, { name: "double quotes are not supported a.t.m", input: "[name=\"value\"]",