@@ -437,6 +437,9 @@ func translatePermission(tctx *translationContext, permissionNode *dslNode) (*co
437437 return nil , permissionNode .Errorf ("invalid permission expression: %w" , err )
438438 }
439439
440+ // Detect mixed operators without parentheses before translation (which may flatten the AST).
441+ mixedOpsPosition := detectMixedOperatorsWithoutParens (tctx , expressionNode )
442+
440443 rewrite , err := translateExpression (tctx , expressionNode )
441444 if err != nil {
442445 return nil , err
@@ -447,6 +450,14 @@ func translatePermission(tctx *translationContext, permissionNode *dslNode) (*co
447450 return nil , err
448451 }
449452
453+ // Store mixed operators flag in metadata
454+ if mixedOpsPosition != nil {
455+ err = namespace .SetMixedOperatorsWithoutParens (permission , true , mixedOpsPosition )
456+ if err != nil {
457+ return nil , permissionNode .Errorf ("error adding mixed operators flag to metadata: %w" , err )
458+ }
459+ }
460+
450461 if ! tctx .skipValidate {
451462 if err := permission .Validate (); err != nil {
452463 return nil , permissionNode .Errorf ("error in permission %s: %w" , permissionName , err )
@@ -577,6 +588,14 @@ func translateExpressionOperationDirect(tctx *translationContext, expressionOpNo
577588 case dslshape .NodeTypeNilExpression :
578589 return namespace .Nil (), nil
579590
591+ case dslshape .NodeTypeParenthesizedExpression :
592+ // Unwrap the parenthesized expression and translate its inner expression.
593+ innerExprNode , err := expressionOpNode .Lookup (dslshape .NodeParenthesizedExpressionPredicateInnerExpr )
594+ if err != nil {
595+ return nil , err
596+ }
597+ return translateExpressionOperation (tctx , innerExprNode )
598+
580599 case dslshape .NodeTypeArrowExpression :
581600 leftChild , err := expressionOpNode .Lookup (dslshape .NodeExpressionPredicateLeftExpr )
582601 if err != nil {
@@ -952,3 +971,91 @@ func translateUseFlag(tctx *translationContext, useFlagNode *dslNode) error {
952971 tctx .enabledFlags = append (tctx .enabledFlags , flagName )
953972 return nil
954973}
974+
975+ // operatorType represents the type of set operator in an expression.
976+ type operatorType int
977+
978+ const (
979+ operatorTypeUnknown operatorType = iota
980+ operatorTypeUnion
981+ operatorTypeIntersection
982+ operatorTypeExclusion
983+ )
984+
985+ // getOperatorType returns the operator type for a given node type, or operatorTypeUnknown if not a set operator.
986+ func getOperatorType (nodeType dslshape.NodeType ) operatorType {
987+ switch nodeType {
988+ case dslshape .NodeTypeUnionExpression :
989+ return operatorTypeUnion
990+ case dslshape .NodeTypeIntersectExpression :
991+ return operatorTypeIntersection
992+ case dslshape .NodeTypeExclusionExpression :
993+ return operatorTypeExclusion
994+ default :
995+ return operatorTypeUnknown
996+ }
997+ }
998+
999+ // detectMixedOperatorsWithoutParens walks the expression AST and detects if there are mixed
1000+ // operators (union, intersection, exclusion) at the same scope level without explicit parentheses.
1001+ // Returns the source position of the first mixed operator found, or nil if none.
1002+ // Parenthesized expressions act as boundaries - mixing inside parens does not trigger a warning
1003+ // since the user explicitly grouped the expression. However, top-level parentheses are unwrapped
1004+ // since they don't clarify internal operator precedence.
1005+ func detectMixedOperatorsWithoutParens (tctx * translationContext , node * dslNode ) * core.SourcePosition {
1006+ // Unwrap top-level parenthesized expressions - they don't clarify internal precedence.
1007+ // e.g., (a + b - c) should still warn about mixed operators.
1008+ for node .GetType () == dslshape .NodeTypeParenthesizedExpression {
1009+ innerNode , err := node .Lookup (dslshape .NodeParenthesizedExpressionPredicateInnerExpr )
1010+ if err != nil {
1011+ break
1012+ }
1013+ node = innerNode
1014+ }
1015+ return detectMixedOperatorsInScope (tctx , node , operatorTypeUnknown )
1016+ }
1017+
1018+ // detectMixedOperatorsInScope recursively checks for mixed operators within a scope.
1019+ // parentOp is the operator type seen so far at this scope level.
1020+ func detectMixedOperatorsInScope (tctx * translationContext , node * dslNode , parentOp operatorType ) * core.SourcePosition {
1021+ nodeType := node .GetType ()
1022+
1023+ // Parenthesized expressions act as a boundary - don't propagate operator checking into them.
1024+ // The user explicitly grouped the expression, so we don't warn about mixing inside.
1025+ if nodeType == dslshape .NodeTypeParenthesizedExpression {
1026+ return nil
1027+ }
1028+
1029+ currentOp := getOperatorType (nodeType )
1030+
1031+ // If this is a set operator and we've seen a different operator at this scope, it's mixed.
1032+ if currentOp != operatorTypeUnknown && parentOp != operatorTypeUnknown && currentOp != parentOp {
1033+ return getSourcePosition (node , tctx .mapper )
1034+ }
1035+
1036+ // If this is a set operator, check children with this operator as the scope's operator.
1037+ if currentOp != operatorTypeUnknown {
1038+ effectiveOp := currentOp
1039+ if parentOp != operatorTypeUnknown {
1040+ effectiveOp = parentOp // Keep the first operator seen at this scope
1041+ }
1042+
1043+ // Check left child
1044+ leftChild , err := node .Lookup (dslshape .NodeExpressionPredicateLeftExpr )
1045+ if err == nil {
1046+ if pos := detectMixedOperatorsInScope (tctx , leftChild , effectiveOp ); pos != nil {
1047+ return pos
1048+ }
1049+ }
1050+
1051+ // Check right child
1052+ rightChild , err := node .Lookup (dslshape .NodeExpressionPredicateRightExpr )
1053+ if err == nil {
1054+ if pos := detectMixedOperatorsInScope (tctx , rightChild , effectiveOp ); pos != nil {
1055+ return pos
1056+ }
1057+ }
1058+ }
1059+
1060+ return nil
1061+ }
0 commit comments