Skip to content

Commit 790c615

Browse files
committed
feat: add semantic grammar checks for control flow
Implement three semantic checks that tree-sitter cannot detect: 1. break/continue outside loop: Tracks loop depth and reports errors when break/continue appear at top level 2. return/yield outside context: return requires function context; yield is valid in both functions and switch expressions 3. Invalid assignment targets: Detects attempts to assign to literals, function calls, or expressions (e.g., "1 = 2" or "foo() = bar") Add error codes: ErrReturnOutsideFunction, ErrYieldOutsideFunction, ErrInvalidAssignmentTarget
1 parent 9b4d2e8 commit 790c615

File tree

2 files changed

+197
-0
lines changed

2 files changed

+197
-0
lines changed

rts/check/check.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ func (c *RadCheckerImpl) Check(opts Opts) (Result, error) {
6060
c.addFunctionNameShadowingErrors(&diagnostics)
6161
c.addUnknownFunctionHints(&diagnostics)
6262
c.addUnknownCommandCallbackWarnings(&diagnostics)
63+
// Semantic grammar checks
64+
c.addBreakContinueOutsideLoopErrors(&diagnostics)
65+
c.addReturnOutsideFunctionErrors(&diagnostics)
66+
c.addInvalidAssignmentLHSErrors(&diagnostics)
6367
return Result{
6468
Diagnostics: diagnostics,
6569
}, nil
@@ -319,3 +323,193 @@ func (c *RadCheckerImpl) addUnknownCommandCallbackWarnings(d *[]Diagnostic) {
319323
*d = append(*d, NewDiagnosticWarn(identifierNode, c.src, msg, rl.ErrUnknownFunction))
320324
}
321325
}
326+
327+
// addBreakContinueOutsideLoopErrors checks for break/continue statements outside of loops.
328+
func (c *RadCheckerImpl) addBreakContinueOutsideLoopErrors(d *[]Diagnostic) {
329+
root := c.tree.Root()
330+
c.walkForBreakContinue(root, d, 0)
331+
}
332+
333+
func (c *RadCheckerImpl) walkForBreakContinue(node *ts.Node, d *[]Diagnostic, loopDepth int) {
334+
if node == nil {
335+
return
336+
}
337+
338+
kind := node.Kind()
339+
340+
// Track loop entry
341+
newLoopDepth := loopDepth
342+
if kind == rl.K_FOR_LOOP || kind == rl.K_WHILE_LOOP {
343+
newLoopDepth++
344+
}
345+
346+
// Check for break/continue outside loop
347+
if kind == rl.K_BREAK_STMT {
348+
if loopDepth == 0 {
349+
msg := "'break' can only be used inside a loop"
350+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrBreakOutsideLoop))
351+
}
352+
} else if kind == rl.K_CONTINUE_STMT {
353+
if loopDepth == 0 {
354+
msg := "'continue' can only be used inside a loop"
355+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrContinueOutsideLoop))
356+
}
357+
}
358+
359+
// Recursively walk children
360+
for i := uint(0); i < node.ChildCount(); i++ {
361+
c.walkForBreakContinue(node.Child(i), d, newLoopDepth)
362+
}
363+
}
364+
365+
// addReturnOutsideFunctionErrors checks for return statements outside of functions.
366+
func (c *RadCheckerImpl) addReturnOutsideFunctionErrors(d *[]Diagnostic) {
367+
root := c.tree.Root()
368+
c.walkForReturn(root, d, false, false)
369+
}
370+
371+
// walkForReturn tracks both function context and switch/yield context.
372+
// yieldContext is true when inside a switch expression (where yield is valid).
373+
func (c *RadCheckerImpl) walkForReturn(node *ts.Node, d *[]Diagnostic, inFunction, inYieldContext bool) {
374+
if node == nil {
375+
return
376+
}
377+
378+
kind := node.Kind()
379+
380+
// Track context changes
381+
newInFunction := inFunction
382+
newInYieldContext := inYieldContext
383+
384+
if kind == rl.K_FN_NAMED || kind == rl.K_FN_LAMBDA {
385+
newInFunction = true
386+
}
387+
388+
// Switch expressions create a yield context (yield produces the switch result)
389+
if kind == rl.K_SWITCH_STMT {
390+
newInYieldContext = true
391+
}
392+
393+
// Check for return outside function
394+
if kind == rl.K_RETURN_STMT {
395+
if !inFunction {
396+
msg := "'return' can only be used inside a function"
397+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrReturnOutsideFunction))
398+
}
399+
}
400+
401+
// Check yield - valid inside functions OR switch expressions
402+
if kind == rl.K_YIELD_STMT {
403+
if !inFunction && !inYieldContext {
404+
msg := "'yield' can only be used inside a function or switch expression"
405+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrYieldOutsideFunction))
406+
}
407+
}
408+
409+
// Recursively walk children
410+
for i := uint(0); i < node.ChildCount(); i++ {
411+
c.walkForReturn(node.Child(i), d, newInFunction, newInYieldContext)
412+
}
413+
}
414+
415+
// addInvalidAssignmentLHSErrors checks for invalid assignment targets.
416+
func (c *RadCheckerImpl) addInvalidAssignmentLHSErrors(d *[]Diagnostic) {
417+
root := c.tree.Root()
418+
c.walkForAssignments(root, d)
419+
}
420+
421+
func (c *RadCheckerImpl) walkForAssignments(node *ts.Node, d *[]Diagnostic) {
422+
if node == nil {
423+
return
424+
}
425+
426+
if node.Kind() == rl.K_ASSIGN {
427+
c.checkAssignmentLHS(node, d)
428+
}
429+
430+
// Recursively walk children
431+
for i := uint(0); i < node.ChildCount(); i++ {
432+
c.walkForAssignments(node.Child(i), d)
433+
}
434+
}
435+
436+
func (c *RadCheckerImpl) checkAssignmentLHS(assignNode *ts.Node, d *[]Diagnostic) {
437+
// Get the left-hand side(s) of the assignment
438+
lefts := assignNode.ChildrenByFieldName(rl.F_LEFTS, assignNode.Walk())
439+
if len(lefts) == 0 {
440+
// Single left
441+
left := assignNode.ChildByFieldName(rl.F_LEFT)
442+
if left != nil {
443+
c.validateAssignmentTarget(left, d)
444+
}
445+
return
446+
}
447+
448+
// Multiple lefts (unpacking)
449+
for i := range lefts {
450+
c.validateAssignmentTarget(&lefts[i], d)
451+
}
452+
}
453+
454+
func (c *RadCheckerImpl) validateAssignmentTarget(node *ts.Node, d *[]Diagnostic) {
455+
if node == nil {
456+
return
457+
}
458+
459+
kind := node.Kind()
460+
461+
// Valid assignment targets:
462+
// - identifier (x = 1)
463+
// - var_path (x.y = 1 or x[0] = 1)
464+
// - indexed_expr (x[0] = 1)
465+
466+
switch kind {
467+
case rl.K_IDENTIFIER, rl.K_VAR_PATH, rl.K_INDEXED_EXPR:
468+
// These are valid assignment targets
469+
return
470+
case rl.K_INT, rl.K_FLOAT, rl.K_STRING, rl.K_BOOL, rl.K_NULL:
471+
// Literals cannot be assigned to
472+
content := c.src[node.StartByte():node.EndByte()]
473+
msg := "Cannot assign to literal '" + truncate(content, 20) + "'"
474+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrInvalidAssignmentTarget))
475+
case rl.K_CALL:
476+
// Function calls cannot be assigned to
477+
msg := "Cannot assign to function call result"
478+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrInvalidAssignmentTarget))
479+
case rl.K_ADD_EXPR, rl.K_MULT_EXPR, rl.K_COMPARE_EXPR, rl.K_OR_EXPR, rl.K_AND_EXPR, rl.K_TERNARY_EXPR:
480+
// Expressions cannot be assigned to
481+
msg := "Cannot assign to expression"
482+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrInvalidAssignmentTarget))
483+
case rl.K_PARENTHESIZED_EXPR:
484+
// Check inside parentheses
485+
inner := node.ChildByFieldName(rl.F_EXPR)
486+
if inner != nil {
487+
c.validateAssignmentTarget(inner, d)
488+
}
489+
default:
490+
// For any other unexpected node type
491+
if !isValidAssignmentTarget(kind) {
492+
content := c.src[node.StartByte():node.EndByte()]
493+
msg := "Cannot assign to '" + truncate(content, 20) + "'"
494+
*d = append(*d, NewDiagnosticError(node, c.src, msg, rl.ErrInvalidAssignmentTarget))
495+
}
496+
}
497+
}
498+
499+
// isValidAssignmentTarget returns true if the node kind is a valid LHS for assignment.
500+
func isValidAssignmentTarget(kind string) bool {
501+
switch kind {
502+
case rl.K_IDENTIFIER, rl.K_VAR_PATH, rl.K_INDEXED_EXPR:
503+
return true
504+
default:
505+
return false
506+
}
507+
}
508+
509+
// truncate shortens a string to maxLen, adding "..." if truncated.
510+
func truncate(s string, maxLen int) string {
511+
if len(s) <= maxLen {
512+
return s
513+
}
514+
return s[:maxLen-3] + "..."
515+
}

rts/rl/errors.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ const (
9494
ErrScientificNotationNotWholeNumber Error = "40001"
9595
ErrHoistedFunctionShadowsArgument Error = "40002"
9696
ErrUnknownFunction Error = "40003"
97+
ErrReturnOutsideFunction Error = "40004"
98+
ErrYieldOutsideFunction Error = "40005"
99+
ErrInvalidAssignmentTarget Error = "40006"
97100
)
98101

99102
func (e Error) String() string {

0 commit comments

Comments
 (0)