Skip to content

Commit 6a03a3d

Browse files
committed
feat: add warning for mixed operators without parentheses
1 parent 21d76e0 commit 6a03a3d

File tree

5 files changed

+643
-2
lines changed

5 files changed

+643
-2
lines changed

pkg/development/devcontext.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ type DevContext struct {
4848
Revision datastore.Revision
4949
CompiledSchema *compiler.CompiledSchema
5050
Dispatcher dispatch.Dispatcher
51+
52+
// SchemaString is the original schema source text, used for source-scanning warnings.
53+
SchemaString string
5154
}
5255

5356
// NewDevContext creates a new DevContext from the specified request context, parsing and populating
@@ -154,6 +157,7 @@ func newDevContextWithDatastore(ctx context.Context, requestContext *devinterfac
154157
CompiledSchema: compiled,
155158
Revision: currentRevision,
156159
Dispatcher: graph.MustNewLocalOnlyDispatcher(params),
160+
SchemaString: requestContext.Schema,
157161
}, nil, nil
158162
}
159163

pkg/development/warningdefs.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,120 @@ var lintArrowReferencingRelation = ttuCheck{
230230
return nil, nil
231231
},
232232
}
233+
234+
// CheckExpressionForMixedOperators checks a permission expression for mixed operators
235+
// at the same parenthetical scope. This is a source-scanning check that detects
236+
// cases where users mix +, -, and & operators without explicit parentheses.
237+
//
238+
// For example:
239+
// - "foo + bar" - OK (single operator type)
240+
// - "foo - bar & baz" - WARN (mixed - and & at same scope)
241+
// - "(foo - bar) & baz" - OK (operators in different scopes)
242+
// - "(foo + bar) - (baz & qux)" - OK (operators in different scopes)
243+
// - "(foo + bar - baz)" - WARN (mixed inside parentheses)
244+
func CheckExpressionForMixedOperators(
245+
permissionName string,
246+
expressionText string,
247+
sourcePosition *corev1.SourcePosition,
248+
) *devinterface.DeveloperWarning {
249+
// Use a stack-based approach to track operators in each parenthetical scope.
250+
// Each scope is a map of operators seen at that level.
251+
type scope struct {
252+
operators map[rune]bool
253+
}
254+
255+
// Helper to check if a scope has mixed operators and return warning if so
256+
checkScope := func(s scope) *devinterface.DeveloperWarning {
257+
if len(s.operators) > 1 {
258+
// Build a human-readable list of the mixed operators
259+
var opList []string
260+
if s.operators['+'] {
261+
opList = append(opList, "union (+)")
262+
}
263+
if s.operators['-'] {
264+
opList = append(opList, "exclusion (-)")
265+
}
266+
if s.operators['&'] {
267+
opList = append(opList, "intersection (&)")
268+
}
269+
270+
return warningForPosition(
271+
"mixed-operators-without-parentheses",
272+
fmt.Sprintf(
273+
"Permission %q mixes %s at the same level of nesting; consider adding parentheses to clarify precedence",
274+
permissionName,
275+
strings.Join(opList, " and "),
276+
),
277+
permissionName,
278+
sourcePosition,
279+
)
280+
}
281+
return nil
282+
}
283+
284+
// Stack of scopes - start with the root scope
285+
scopeStack := []scope{{operators: make(map[rune]bool)}}
286+
287+
runes := []rune(expressionText)
288+
for i := 0; i < len(runes); i++ {
289+
r := runes[i]
290+
291+
// Skip whitespace
292+
if isWhitespace(r) {
293+
continue
294+
}
295+
296+
// Enter a new scope on open parenthesis
297+
if r == '(' {
298+
scopeStack = append(scopeStack, scope{operators: make(map[rune]bool)})
299+
continue
300+
}
301+
302+
// Exit scope on close parenthesis - check for mixed operators before popping
303+
if r == ')' {
304+
if len(scopeStack) > 1 {
305+
// Check the scope we're about to pop for mixed operators
306+
if warning := checkScope(scopeStack[len(scopeStack)-1]); warning != nil {
307+
return warning
308+
}
309+
scopeStack = scopeStack[:len(scopeStack)-1]
310+
}
311+
continue
312+
}
313+
314+
// Skip arrow operator (->)
315+
if r == '-' && i+1 < len(runes) && runes[i+1] == '>' {
316+
i++ // Skip the '>'
317+
continue
318+
}
319+
320+
// Skip dot notation for function calls (e.g., parent.all)
321+
if r == '.' {
322+
continue
323+
}
324+
325+
// Check for operators: +, -, &
326+
// Only count operators that are not part of an identifier
327+
if r == '+' || r == '-' || r == '&' {
328+
// Look back to see if the previous non-whitespace char was an identifier char
329+
// If so, this might be part of a different construct, but in SpiceDB schema
330+
// these operators should always be surrounded by whitespace or parens
331+
currentScope := &scopeStack[len(scopeStack)-1]
332+
currentScope.operators[r] = true
333+
continue
334+
}
335+
}
336+
337+
// Check remaining scopes (including root) for mixed operators
338+
for _, s := range scopeStack {
339+
if warning := checkScope(s); warning != nil {
340+
return warning
341+
}
342+
}
343+
344+
return nil
345+
}
346+
347+
func isWhitespace(r rune) bool {
348+
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
349+
}

pkg/development/warnings.go

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package development
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/ccoveille/go-safecast/v2"
89

@@ -64,8 +65,11 @@ func GetWarnings(ctx context.Context, devCtx *DevContext) ([]*devinterface.Devel
6465
res := schema.ResolverForCompiledSchema(*devCtx.CompiledSchema)
6566
ts := schema.NewTypeSystem(res)
6667

68+
// Pre-split schema string once for performance when checking multiple permissions
69+
schemaLines := strings.Split(devCtx.SchemaString, "\n")
70+
6771
for _, def := range devCtx.CompiledSchema.ObjectDefinitions {
68-
found, err := addDefinitionWarnings(ctx, def, ts)
72+
found, err := addDefinitionWarnings(ctx, def, ts, schemaLines)
6973
if err != nil {
7074
return nil, err
7175
}
@@ -79,7 +83,7 @@ type contextKey string
7983

8084
var relationKey = contextKey("relation")
8185

82-
func addDefinitionWarnings(ctx context.Context, nsDef *corev1.NamespaceDefinition, ts *schema.TypeSystem) ([]*devinterface.DeveloperWarning, error) {
86+
func addDefinitionWarnings(ctx context.Context, nsDef *corev1.NamespaceDefinition, ts *schema.TypeSystem, schemaLines []string) ([]*devinterface.DeveloperWarning, error) {
8387
def, err := schema.NewDefinition(ts, nsDef)
8488
if err != nil {
8589
return nil, err
@@ -111,12 +115,125 @@ func addDefinitionWarnings(ctx context.Context, nsDef *corev1.NamespaceDefinitio
111115
}
112116

113117
warnings = append(warnings, found...)
118+
119+
// Check for mixed operators without parentheses using source scanning
120+
if !shouldSkipCheck(rel.Metadata, "mixed-operators-without-parentheses") {
121+
expressionText := extractPermissionExpression(schemaLines, rel.Name, rel.GetSourcePosition())
122+
if expressionText != "" {
123+
if warning := CheckExpressionForMixedOperators(rel.Name, expressionText, rel.GetSourcePosition()); warning != nil {
124+
warnings = append(warnings, warning)
125+
}
126+
}
127+
}
114128
}
115129
}
116130

117131
return warnings, nil
118132
}
119133

134+
// extractPermissionExpression extracts the expression text for a permission from the schema source.
135+
// It uses the source position to locate the permission and extracts the text after the '=' sign.
136+
// The schemaLines parameter should be pre-split for performance when checking multiple permissions.
137+
func extractPermissionExpression(schemaLines []string, _ string, sourcePosition *corev1.SourcePosition) string {
138+
if sourcePosition == nil || len(schemaLines) == 0 {
139+
return ""
140+
}
141+
142+
lineIdx, err := safecast.Convert[int](sourcePosition.ZeroIndexedLineNumber)
143+
if err != nil || lineIdx < 0 || lineIdx >= len(schemaLines) {
144+
return ""
145+
}
146+
147+
// Start from the permission's line and look for the expression
148+
// The expression is everything after '=' until end of statement
149+
var expressionBuilder strings.Builder
150+
foundEquals := false
151+
parenDepth := 0
152+
inBlockComment := false
153+
lastNonWhitespaceWasOperator := false
154+
155+
for i := lineIdx; i < len(schemaLines); i++ {
156+
line := schemaLines[i]
157+
158+
// If this is the first line, start from the column position
159+
startCol := 0
160+
if i == lineIdx {
161+
colPos, err := safecast.Convert[int](sourcePosition.ZeroIndexedColumnPosition)
162+
if err == nil && colPos < len(line) {
163+
startCol = colPos
164+
}
165+
}
166+
167+
lineHasContent := false
168+
for j := startCol; j < len(line); j++ {
169+
ch := line[j]
170+
171+
// Handle block comment state
172+
if inBlockComment {
173+
if ch == '*' && j+1 < len(line) && line[j+1] == '/' {
174+
inBlockComment = false
175+
j++ // Skip the '/'
176+
}
177+
continue
178+
}
179+
180+
// Check for start of block comment
181+
if ch == '/' && j+1 < len(line) && line[j+1] == '*' {
182+
inBlockComment = true
183+
j++ // Skip the '*'
184+
continue
185+
}
186+
187+
// Check for line comment - skip rest of line
188+
if ch == '/' && j+1 < len(line) && line[j+1] == '/' {
189+
break
190+
}
191+
192+
if !foundEquals {
193+
if ch == '=' {
194+
foundEquals = true
195+
}
196+
continue
197+
}
198+
199+
// Track parenthesis depth for multi-line expressions
200+
switch ch {
201+
case '(':
202+
parenDepth++
203+
case ')':
204+
parenDepth--
205+
}
206+
207+
// Track if this is an operator (for multi-line continuation detection)
208+
isWhitespaceChar := ch == ' ' || ch == '\t'
209+
if !isWhitespaceChar {
210+
lineHasContent = true
211+
lastNonWhitespaceWasOperator = (ch == '+' || ch == '-' || ch == '&')
212+
}
213+
214+
expressionBuilder.WriteByte(ch)
215+
}
216+
217+
// Determine if expression continues on next line:
218+
// 1. We're inside parentheses (parenDepth > 0)
219+
// 2. We're inside a block comment
220+
// 3. The line ended with an operator (suggesting continuation)
221+
// 4. The line had no content yet after '=' (e.g., '= \n foo + bar')
222+
if foundEquals {
223+
continueToNextLine := parenDepth > 0 || inBlockComment || lastNonWhitespaceWasOperator || !lineHasContent
224+
if !continueToNextLine {
225+
break
226+
}
227+
// Add space for multi-line expressions
228+
if i < len(schemaLines)-1 {
229+
expressionBuilder.WriteByte(' ')
230+
}
231+
}
232+
}
233+
234+
return strings.TrimSpace(expressionBuilder.String())
235+
}
236+
120237
func shouldSkipCheck(metadata *corev1.Metadata, name string) bool {
121238
if metadata == nil {
122239
return false

0 commit comments

Comments
 (0)