1
0
mirror of https://github.com/MontFerret/ferret.git synced 2025-08-15 20:02:56 +02:00

Add syntax error tests for FOR loops and COLLECT statements: enhance diagnostics for missing values, incomplete clauses, and unexpected syntax to improve error handling and clarity.

This commit is contained in:
Tim Voronov
2025-08-10 12:30:07 -04:00
parent f1dc2b12a1
commit b69f8af716
10 changed files with 355 additions and 137 deletions

View File

@@ -110,10 +110,26 @@ func is(node *TokenNode, expected string) bool {
return strings.ToUpper(node.GetText()) == expected return strings.ToUpper(node.GetText()) == expected
} }
func anyIs(first, second *TokenNode, expected string) *TokenNode {
if is(first, expected) {
return first
}
if is(second, expected) {
return second
}
return nil
}
func has(msg string, substr string) bool { func has(msg string, substr string) bool {
return strings.Contains(strings.ToLower(msg), strings.ToLower(substr)) return strings.Contains(strings.ToLower(msg), strings.ToLower(substr))
} }
func isMismatched(msg string) bool {
return has(msg, "mismatched input")
}
func isNoAlternative(msg string) bool { func isNoAlternative(msg string) bool {
return has(msg, "no viable alternative at input") return has(msg, "no viable alternative at input")
} }

View File

@@ -13,11 +13,11 @@ func matchMissingAssignmentValue(src *file.Source, err *CompilationError, offend
prev := offending.Prev() prev := offending.Prev()
if is(offending, "LET") || is(prev, "=") { if node := anyIs(offending, prev, "="); node != nil {
span := spanFromTokenSafe(prev.Token(), src) span := spanFromTokenSafe(node.Token(), src)
span.Start++ span.Start++
span.End++ span.End++
err.Message = fmt.Sprintf("Expected expression after '=' for variable '%s'", prev.Prev()) err.Message = fmt.Sprintf("Expected expression after '=' for variable '%s'", node.Prev())
err.Hint = "Did you forget to provide a value?" err.Hint = "Did you forget to provide a value?"
err.Spans = []ErrorSpan{ err.Spans = []ErrorSpan{
NewMainErrorSpan(span, "missing value"), NewMainErrorSpan(span, "missing value"),
@@ -26,5 +26,18 @@ func matchMissingAssignmentValue(src *file.Source, err *CompilationError, offend
return true return true
} }
if is(offending, "LET") {
span := spanFromTokenSafe(offending.Token(), src)
span.Start = span.End
span.End = span.Start + 1
err.Message = "Expected variable name"
err.Hint = "Did you forget to provide a variable name?"
err.Spans = []ErrorSpan{
NewMainErrorSpan(span, "missing name"),
}
return true
}
return false return false
} }

View File

@@ -19,5 +19,9 @@ func matchCommonErrors(src *file.Source, err *CompilationError, offending *Token
} }
} }
if isMismatched(err.Message) {
}
return false return false
} }

View File

@@ -62,6 +62,48 @@ func matchForLoopErrors(src *file.Source, err *CompilationError, offending *Toke
NewMainErrorSpan(span, "missing variable"), NewMainErrorSpan(span, "missing variable"),
} }
return true
} else if isNoAlternative(msg) {
span := spanFromTokenSafe(offending.Token(), src)
span.Start = span.End
span.End = span.Start + 1
err.Message = "Incomplete COLLECT clause"
err.Hint = "COLLECT must specify a grouping key, an AGGREGATE clause, or WITH COUNT."
err.Spans = []ErrorSpan{
NewMainErrorSpan(span, "missing grouping or aggregation"),
}
return true
}
}
if is(offending, "INTO") {
span := spanFromTokenSafe(offending.Token(), src)
span.Start = span.End + 1
span.End = span.Start + 1
err.Message = "Expected variable name after INTO"
err.Hint = "Provide a variable name to store grouped values, e.g. INTO groups."
err.Spans = []ErrorSpan{
NewMainErrorSpan(span, "missing variable name"),
}
return true
}
if is(offending, "AGGREGATE") {
if isNoAlternative(err.Message) {
span := spanFromTokenSafe(offending.Token(), src)
span.Start = span.End + 1
span.End = span.Start + 1
err.Message = "Expected variable assignment after AGGREGATE"
err.Hint = "Provide at least one variable assignment, e.g. AGGREGATE total = COUNT(x)."
err.Spans = []ErrorSpan{
NewMainErrorSpan(span, "missing variable assignment"),
}
return true return true
} }
} }
@@ -71,7 +113,7 @@ func matchForLoopErrors(src *file.Source, err *CompilationError, offending *Toke
span.Start = span.End span.Start = span.End
span.End = span.Start + 1 span.End = span.Start + 1
err.Message = "Expected condition after 'FILTER'" err.Message = "Incomplete FILTER clause"
err.Hint = "FILTER requires a boolean expression." err.Hint = "FILTER requires a boolean expression."
err.Spans = []ErrorSpan{ err.Spans = []ErrorSpan{
NewMainErrorSpan(span, "missing expression"), NewMainErrorSpan(span, "missing expression"),

View File

@@ -26,6 +26,8 @@ func matchMissingReturnValue(src *file.Source, err *CompilationError, offending
} }
span := spanFromTokenSafe(offending.Token(), src) span := spanFromTokenSafe(offending.Token(), src)
span.Start = span.End
span.End = span.Start + 1
err.Message = fmt.Sprintf("Expected expression after '%s'", offending) err.Message = fmt.Sprintf("Expected expression after '%s'", offending)
err.Hint = "Did you forget to provide a value to return?" err.Hint = "Did you forget to provide a value to return?"
err.Spans = []ErrorSpan{ err.Spans = []ErrorSpan{

View File

@@ -13,3 +13,15 @@ func SkipWhitespaceForward(content string, offset int) int {
return offset return offset
} }
func SkipHorizontalWhitespaceForward(content string, offset int) int {
for offset < len(content) {
ch := content[offset]
// Skip spaces and tabs only; do NOT cross line breaks
if ch != ' ' && ch != '\t' {
break
}
offset++
}
return offset
}

View File

@@ -34,7 +34,6 @@ func NewSnippetWithCaret(lines []string, span Span, line int) Snippet {
endCol := computeVisualOffset(srcLine, span.End-lineStartOffset+1) endCol := computeVisualOffset(srcLine, span.End-lineStartOffset+1)
caret := "" caret := ""
if endCol <= startCol+1 { if endCol <= startCol+1 {
caret = strings.Repeat(" ", startCol) + "^" caret = strings.Repeat(" ", startCol) + "^"
} else { } else {

View File

@@ -43,20 +43,36 @@ func (s *Source) LocationAt(span Span) (line, column int) {
return 0, 0 return 0, 0
} }
total := 0
offset := span.Start offset := span.Start
total := 0
for i, l := range s.lines { for i, l := range s.lines {
lineLen := len(l) + 1 // +1 for newline lineLen := len(l) + 1 // +1 for '\n'
lineStart := total
lineEndWithNL := total + lineLen
if total+lineLen > offset { // If offset is exactly at the start of this line (not the very first line),
// treat it as the end of the previous line.
if offset == lineStart && i > 0 {
prev := s.lines[i-1]
return i, len(prev) + 1
}
if lineEndWithNL > offset {
// Normal case: offset lives on this line
return i + 1, offset - total + 1 return i + 1, offset - total + 1
} }
total += lineLen total = lineEndWithNL
} }
return total, 1 // If we somehow fell through, clamp to last line end
if len(s.lines) > 0 {
last := s.lines[len(s.lines)-1]
return len(s.lines), len(last) + 1
}
return 0, 0
} }
func (s *Source) Snippet(span Span) []Snippet { func (s *Source) Snippet(span Span) []Snippet {

View File

@@ -0,0 +1,197 @@
package compiler_test
import (
"testing"
"github.com/MontFerret/ferret/pkg/compiler"
)
func TestForLoopSyntaxErrors(t *testing.T) {
RunUseCases(t, []UseCase{
ErrorCase(
`
FOR i IN [1, 2, 3]
RETURN
`, E{
Kind: compiler.SyntaxError,
Message: "Expected expression after 'RETURN'",
Hint: "Did you forget to provide a value to return?",
}, "Missing return value in for loop"),
ErrorCase(
`
FOR i IN
RETURN i
`, E{
Kind: compiler.SyntaxError,
Message: "Expected expression after 'IN'",
Hint: "Each FOR loop must iterate over a collection or range.",
}, "Missing iterable in FOR"),
ErrorCase(
`
FOR i [1, 2, 3]
RETURN i
`, E{
Kind: compiler.SyntaxError,
Message: "Expected 'IN' after loop variable",
Hint: "Use 'FOR x IN [iterable]' syntax.",
}, "Missing IN in FOR"),
ErrorCase(
`
FOR IN [1, 2, 3]
RETURN i
`, E{
Kind: compiler.SyntaxError,
Message: "Expected loop variable before 'IN'",
Hint: "FOR must declare a variable.",
}, "FOR without variable"),
ErrorCase(
`
LET users = []
FOR x IN users
FILTER
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Incomplete FILTER clause",
Hint: "FILTER requires a boolean expression.",
}, "FILTER with no expression"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Expected number after 'LIMIT'",
Hint: "LIMIT requires a numeric value.",
}, "LIMIT with no value"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT 1, 2, 3
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Too many arguments provided to LIMIT clause",
Hint: "LIMIT accepts at most two arguments: offset and count.",
}, "LIMIT with too many values"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT 1, 2,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Too many arguments provided to LIMIT clause",
Hint: "LIMIT accepts at most two arguments: offset and count.",
}, "LIMIT unexpected comma"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT 1,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Dangling comma in LIMIT clause",
Hint: "LIMIT accepts one or two arguments. Did you forget to add a value?",
}, "LIMIT unexpected comma 2"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT ,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Dangling comma in LIMIT clause",
Hint: "LIMIT accepts one or two arguments. Did you forget to add a value?",
}, "LIMIT unexpected comma 3"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Dangling comma in LIMIT clause",
Hint: "LIMIT accepts one or two arguments. Did you forget to add a value?",
}, "LIMIT unexpected comma 4"),
ErrorCase(
`
LET users = []
FOR x IN users
COLLECT =
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Expected variable before '=' in COLLECT",
Hint: "COLLECT must group by a variable.",
}, "COLLECT with no variable"),
ErrorCase(
`
LET users = []
FOR x IN users
COLLECT
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Incomplete COLLECT clause",
Hint: "COLLECT must specify a grouping key, an AGGREGATE clause, or WITH COUNT.",
}, "COLLECT with no variables"),
ErrorCase(
`
LET users = []
FOR i IN users
COLLECT gender = i.gender INTO
RETURN {
gender,
values
}`, E{
Kind: compiler.SyntaxError,
Message: "Expected variable name after INTO",
Hint: "Provide a variable name to store grouped values, e.g. INTO groups.",
}, "COLLECT INTO with no variable"),
ErrorCase(
`
LET users = []
FOR x IN users
COLLECT AGGREGATE total =
RETURN total
`, E{
Kind: compiler.SyntaxError,
Message: "Expected expression after '=' for variable 'total'",
Hint: "Did you forget to provide a value?",
}, "COLLECT AGGREGATE without expression"),
ErrorCase(
`
LET users = []
FOR x IN users
COLLECT AGGREGATE
RETURN total
`, E{
Kind: compiler.SyntaxError,
Message: "Expected variable assignment after AGGREGATE",
Hint: "Provide at least one variable assignment, e.g. AGGREGATE total = COUNT(x).",
}, "COLLECT AGGREGATE without expression 2"),
})
}

View File

@@ -8,6 +8,25 @@ import (
func TestSyntaxErrors(t *testing.T) { func TestSyntaxErrors(t *testing.T) {
RunUseCases(t, []UseCase{ RunUseCases(t, []UseCase{
ErrorCase(
`
LET
`, E{
Kind: compiler.SyntaxError,
Message: "Expected variable name",
Hint: "Did you forget to provide a variable name?",
}, "Missing variable name"),
ErrorCase(
`
LET
RETURN 5
`, E{
Kind: compiler.SyntaxError,
Message: "Expected variable name",
Hint: "Did you forget to provide a variable name?",
}, "Missing variable name 2"),
ErrorCase( ErrorCase(
` `
LET i = NONE LET i = NONE
@@ -27,16 +46,6 @@ func TestSyntaxErrors(t *testing.T) {
Hint: "Did you forget to provide a value to return?", Hint: "Did you forget to provide a value to return?",
}, "Missing return value"), }, "Missing return value"),
ErrorCase(
`
FOR i IN [1, 2, 3]
RETURN
`, E{
Kind: compiler.SyntaxError,
Message: "Expected expression after 'RETURN'",
Hint: "Did you forget to provide a value to return?",
}, "Missing return value in for loop"),
ErrorCase( ErrorCase(
` `
LET i = LET i =
@@ -49,138 +58,46 @@ func TestSyntaxErrors(t *testing.T) {
ErrorCase( ErrorCase(
` `
FOR i IN LET i =
RETURN i LET j = 5
RETURN i
`, E{ `, E{
Kind: compiler.SyntaxError, Kind: compiler.SyntaxError,
Message: "Expected expression after 'IN'", Message: "Expected expression after '=' for variable 'i'",
Hint: "Each FOR loop must iterate over a collection or range.",
}, "Missing iterable in FOR"),
ErrorCase(
`
FOR i [1, 2, 3]
RETURN i
`, E{
Kind: compiler.SyntaxError,
Message: "Expected 'IN' after loop variable",
Hint: "Use 'FOR x IN [iterable]' syntax.",
}, "Missing IN in FOR"),
ErrorCase(
`
FOR IN [1, 2, 3]
RETURN i
`, E{
Kind: compiler.SyntaxError,
Message: "Expected loop variable before 'IN'",
Hint: "FOR must declare a variable.",
}, "FOR without variable"),
ErrorCase(
`
LET users = []
FOR x IN users
COLLECT =
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Expected variable before '=' in COLLECT",
Hint: "COLLECT must group by a variable.",
}, "COLLECT with no variable"),
ErrorCase(
`
LET users = []
FOR x IN users
COLLECT AGGREGATE total =
RETURN total
`, E{
Kind: compiler.SyntaxError,
Message: "Expected expression after '=' for variable 'total'",
Hint: "Did you forget to provide a value?", Hint: "Did you forget to provide a value?",
}, "COLLECT AGGREGATE without expression"), }, "Missing variable assignment value 2"),
ErrorCase( ErrorCase(
` `
LET users = [] LET i =
FOR x IN users FOR j IN [1, 2, 3] RETURN j
FILTER
RETURN x
`, E{ `, E{
Kind: compiler.SyntaxError, Kind: compiler.SyntaxError,
Message: "Expected condition after 'FILTER'", Message: "Expected expression after '=' for variable 'i'",
Hint: "FILTER requires a boolean expression.", Hint: "Did you forget to provide a value?",
}, "FILTER with no expression"), }, "Missing variable assignment value 3"),
ErrorCase( SkipErrorCase(
` `
LET users = [] LET o = { foo: "bar" }
FOR x IN users LET i = o.
LIMIT RETURN i
RETURN x
`, E{ `, E{
Kind: compiler.SyntaxError, Kind: compiler.SyntaxError,
Message: "Expected number after 'LIMIT'", Message: "Expected expression after '=' for variable 'i'",
Hint: "LIMIT requires a numeric value.", Hint: "Did you forget to provide a value?",
}, "LIMIT with no value"), }, "Incomplete member access"),
ErrorCase( SkipErrorCase(
` `
LET users = [] LET o = { foo: "bar" }
FOR x IN users LET i = o.
LIMIT 1, 2, 3 FUNC(i)
RETURN x RETURN i
`, E{ `, E{
Kind: compiler.SyntaxError, Kind: compiler.SyntaxError,
Message: "Too many arguments provided to LIMIT clause", Message: "Expected expression after '=' for variable 'i'",
Hint: "LIMIT accepts at most two arguments: offset and count.", Hint: "Did you forget to provide a value?",
}, "LIMIT with too many values"), }, "Incomplete member access 2"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT 1, 2,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Too many arguments provided to LIMIT clause",
Hint: "LIMIT accepts at most two arguments: offset and count.",
}, "LIMIT unexpected comma"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT 1,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Dangling comma in LIMIT clause",
Hint: "LIMIT accepts one or two arguments. Did you forget to add a value?",
}, "LIMIT unexpected comma 2"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT ,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Dangling comma in LIMIT clause",
Hint: "LIMIT accepts one or two arguments. Did you forget to add a value?",
}, "LIMIT unexpected comma 3"),
ErrorCase(
`
LET users = []
FOR x IN users
LIMIT,
RETURN x
`, E{
Kind: compiler.SyntaxError,
Message: "Dangling comma in LIMIT clause",
Hint: "LIMIT accepts one or two arguments. Did you forget to add a value?",
}, "LIMIT unexpected comma 4"),
}) })
} }