1
0
mirror of https://github.com/MontFerret/ferret.git synced 2026-06-20 01:17:53 +02:00

feat: add diagnostics for missing commas in array literals (#934)

This commit is contained in:
Tim Voronov
2026-05-14 14:06:19 -04:00
committed by GitHub
parent 8ba0cb6deb
commit dd8af12db1
5 changed files with 516 additions and 1 deletions
@@ -0,0 +1,7 @@
package diagnostics
import "github.com/MontFerret/ferret/v2/pkg/diagnostics"
func isCascadeStoppingSyntaxDiagnostic(err *diagnostics.Diagnostic) bool {
return isFunctionBodySyntaxDiagnostic(err) || isArrayLiteralSeparatorDiagnostic(err)
}
+1
View File
@@ -14,6 +14,7 @@ func AnalyzeSyntaxError(src *source.Source, err *diagnostics.Diagnostic, offendi
matchQueryErrors,
matchWhileLoopErrors,
matchDispatchErrors,
matchArrayLiteralSeparatorErrors,
matchLiteralErrors,
matchWaitForErrors,
matchMissingAssignmentValue,
+1 -1
View File
@@ -62,7 +62,7 @@ func (d *ErrorListener) SyntaxError(_ antlr.Recognizer, offendingSymbol interfac
if !d.handler.HasErrorOnLine(line) {
if err := d.parseError(msg, offending); err != nil {
d.handler.Add(err)
d.stopAfterSyntaxError = isFunctionBodySyntaxDiagnostic(err)
d.stopAfterSyntaxError = isCascadeStoppingSyntaxDiagnostic(err)
}
}
}
@@ -0,0 +1,418 @@
package diagnostics
import (
"github.com/antlr4-go/antlr/v4"
"github.com/MontFerret/ferret/v2/pkg/diagnostics"
"github.com/MontFerret/ferret/v2/pkg/parser/fql"
"github.com/MontFerret/ferret/v2/pkg/source"
)
const (
missingArrayItemCommaMessage = "Expected ',' between array items"
missingArrayItemCommaHint = "Separate array items with commas, e.g. [1, 2, 3]."
)
func matchArrayLiteralSeparatorErrors(src *source.Source, err *diagnostics.Diagnostic, offending *TokenNode) bool {
if src == nil || err == nil || offending == nil || offending.Token() == nil {
return false
}
if !isNoAlternative(err.Message) && !isMissing(err.Message) && !isMismatched(err.Message) {
return false
}
tokens := lexDefaultTokens(src.Content())
offendingIdx := findLexedTokenIndex(tokens, offending.Token())
if offendingIdx < 0 {
offendingIdx = findDiagnosticSpanTokenIndex(tokens, err)
}
if offendingIdx < 0 {
return false
}
openIdx := findEnclosingArrayLiteralOpen(tokens, offendingIdx)
if openIdx < 0 {
return false
}
closeIdx := findMatchingCloseBracket(tokens, openIdx)
if closeIdx < 0 || offendingIdx >= closeIdx {
return false
}
if isComputedPropertyNameOpen(tokens, openIdx, closeIdx) {
return false
}
span, ok := arrayItemSeparatorInsertionSpan(tokens, openIdx, closeIdx, src)
if !ok {
return false
}
err.Message = missingArrayItemCommaMessage
err.Hint = missingArrayItemCommaHint
err.Spans = []diagnostics.ErrorSpan{
diagnostics.NewMainErrorSpan(span, "missing comma"),
}
return true
}
func isArrayLiteralSeparatorDiagnostic(err *diagnostics.Diagnostic) bool {
return err != nil && err.Kind == SyntaxError && err.Message == missingArrayItemCommaMessage
}
func findEnclosingArrayLiteralOpen(tokens []antlr.Token, offendingIdx int) int {
if offendingIdx <= 0 || offendingIdx > len(tokens) {
return -1
}
stack := make([]int, 0, 8)
for i := 0; i < offendingIdx; i++ {
text := tokenText(tokens[i])
switch text {
case "(", "[", "{":
stack = append(stack, i)
case ")", "]", "}":
if len(stack) == 0 {
return -1
}
openIdx := stack[len(stack)-1]
if !delimitersMatch(tokenText(tokens[openIdx]), text) {
return -1
}
stack = stack[:len(stack)-1]
}
}
if len(stack) == 0 {
return -1
}
for i := len(stack) - 1; i >= 0; i-- {
openIdx := stack[i]
if isTokenText(tokens[openIdx], "[") && isArrayLiteralOpen(tokens, openIdx) {
return openIdx
}
}
return -1
}
func isArrayLiteralOpen(tokens []antlr.Token, openIdx int) bool {
if openIdx < 0 || openIdx >= len(tokens) || !isTokenText(tokens[openIdx], "[") {
return false
}
if openIdx == 0 {
return true
}
prev := tokens[openIdx-1]
if isTokenText(prev, ".") {
return false
}
return !tokenCanEndExpression(prev)
}
func findMatchingCloseBracket(tokens []antlr.Token, openIdx int) int {
if openIdx < 0 || openIdx >= len(tokens) || !isTokenText(tokens[openIdx], "[") {
return -1
}
depth := 0
for i := openIdx + 1; i < len(tokens); i++ {
switch tokenText(tokens[i]) {
case "[":
depth++
case "]":
if depth == 0 {
return i
}
depth--
}
}
return -1
}
func isComputedPropertyNameOpen(tokens []antlr.Token, openIdx, closeIdx int) bool {
if openIdx <= 0 || closeIdx <= openIdx || closeIdx+1 >= len(tokens) {
return false
}
prev := tokenText(tokens[openIdx-1])
return (prev == "{" || prev == ",") && isTokenText(tokens[closeIdx+1], ":")
}
func arrayItemSeparatorInsertionSpan(tokens []antlr.Token, openIdx, closeIdx int, src *source.Source) (source.Span, bool) {
previousIdx := missingArrayItemSeparatorPreviousToken(tokens, openIdx, closeIdx)
if previousIdx < 0 || isTokenText(tokens[previousIdx], ",") {
return source.Span{}, false
}
previous := tokens[previousIdx]
offset := previous.GetStop() + 1
if offset < 0 || offset >= len(src.Content()) {
return spanFromTokenSafe(previous, src), true
}
return source.Span{Start: offset, End: offset + 1}, true
}
func missingArrayItemSeparatorPreviousToken(tokens []antlr.Token, openIdx, closeIdx int) int {
depth := 0
previousEndIdx := -1
for i := openIdx + 1; i < closeIdx; i++ {
text := tokenText(tokens[i])
if depth == 0 {
if text == "," {
previousEndIdx = -1
continue
}
if isTopLevelArrayExpressionContinuation(tokens, i) {
previousEndIdx = -1
if text == "(" || text == "[" || text == "{" {
depth++
}
continue
}
if previousEndIdx >= 0 && isArrayItemStartToken(tokens[i]) {
return previousEndIdx
}
}
switch text {
case "(", "[", "{":
depth++
case ")", "]", "}":
if depth == 0 {
return -1
}
depth--
if depth == 0 {
previousEndIdx = i
}
default:
if depth == 0 {
if tokenCanEndExpression(tokens[i]) {
previousEndIdx = i
} else {
previousEndIdx = -1
}
}
}
}
return -1
}
func tokenCanEndExpression(token antlr.Token) bool {
if token == nil {
return false
}
switch token.GetTokenType() {
case fql.FqlLexerIdentifier,
fql.FqlLexerIgnoreIdentifier,
fql.FqlLexerStringLiteral,
fql.FqlLexerBacktickOpen,
fql.FqlLexerDurationLiteral,
fql.FqlLexerIntegerLiteral,
fql.FqlLexerFloatLiteral,
fql.FqlLexerNone,
fql.FqlLexerNull,
fql.FqlLexerBooleanLiteral,
fql.FqlLexerCloseBracket,
fql.FqlLexerCloseParen,
fql.FqlLexerCloseBrace:
return true
default:
return false
}
}
func isArrayItemStartToken(token antlr.Token) bool {
if token == nil {
return false
}
switch token.GetTokenType() {
case fql.FqlLexerOpenBrace,
fql.FqlLexerOpenBracket,
fql.FqlLexerOpenParen,
fql.FqlLexerIdentifier,
fql.FqlLexerIgnoreIdentifier,
fql.FqlLexerStringLiteral,
fql.FqlLexerBacktickOpen,
fql.FqlLexerDurationLiteral,
fql.FqlLexerIntegerLiteral,
fql.FqlLexerFloatLiteral,
fql.FqlLexerParam,
fql.FqlLexerNone,
fql.FqlLexerNull,
fql.FqlLexerBooleanLiteral,
fql.FqlLexerMatch,
fql.FqlLexerQuery,
fql.FqlLexerWaitfor,
fql.FqlLexerDispatch,
fql.FqlLexerFor,
fql.FqlLexerAs,
fql.FqlLexerDistinct,
fql.FqlLexerFilter,
fql.FqlLexerSort,
fql.FqlLexerLimit,
fql.FqlLexerCollect,
fql.FqlLexerCount,
fql.FqlLexerSortDirection,
fql.FqlLexerInto,
fql.FqlLexerKeep,
fql.FqlLexerWith,
fql.FqlLexerAt,
fql.FqlLexerLeast,
fql.FqlLexerAggregate,
fql.FqlLexerEvent,
fql.FqlLexerTimeout,
fql.FqlLexerOptions,
fql.FqlLexerEvery,
fql.FqlLexerBackoff,
fql.FqlLexerJitter,
fql.FqlLexerExists,
fql.FqlLexerValue,
fql.FqlLexerOne:
return true
default:
return false
}
}
func isTopLevelArrayExpressionContinuation(tokens []antlr.Token, idx int) bool {
if idx < 0 || idx >= len(tokens) {
return false
}
token := tokens[idx]
prev := func() antlr.Token {
if idx == 0 {
return nil
}
return tokens[idx-1]
}()
switch token.GetTokenType() {
case fql.FqlLexerOpenParen:
return tokenCanBeFunctionName(prev)
case fql.FqlLexerOpenBracket:
return tokenCanEndExpression(prev)
case fql.FqlLexerDot,
fql.FqlLexerQuestionMark,
fql.FqlLexerPlus,
fql.FqlLexerMinus,
fql.FqlLexerMulti,
fql.FqlLexerDiv,
fql.FqlLexerMod,
fql.FqlLexerGt,
fql.FqlLexerLt,
fql.FqlLexerEq,
fql.FqlLexerGte,
fql.FqlLexerLte,
fql.FqlLexerNeq,
fql.FqlLexerRegexMatch,
fql.FqlLexerRegexNotMatch,
fql.FqlLexerAnd,
fql.FqlLexerOr,
fql.FqlLexerIn,
fql.FqlLexerLike,
fql.FqlLexerNot,
fql.FqlLexerAll,
fql.FqlLexerAny:
return true
default:
return false
}
}
func tokenCanBeFunctionName(token antlr.Token) bool {
if token == nil {
return false
}
switch token.GetTokenType() {
case fql.FqlLexerIdentifier,
fql.FqlLexerIgnoreIdentifier,
fql.FqlLexerAnd,
fql.FqlLexerOr,
fql.FqlLexerAs,
fql.FqlLexerDistinct,
fql.FqlLexerFilter,
fql.FqlLexerSort,
fql.FqlLexerLimit,
fql.FqlLexerCollect,
fql.FqlLexerCount,
fql.FqlLexerSortDirection,
fql.FqlLexerInto,
fql.FqlLexerKeep,
fql.FqlLexerWith,
fql.FqlLexerAll,
fql.FqlLexerAny,
fql.FqlLexerAt,
fql.FqlLexerLeast,
fql.FqlLexerAggregate,
fql.FqlLexerEvent,
fql.FqlLexerTimeout,
fql.FqlLexerOptions,
fql.FqlLexerEvery,
fql.FqlLexerBackoff,
fql.FqlLexerJitter,
fql.FqlLexerExists,
fql.FqlLexerValue,
fql.FqlLexerOne,
fql.FqlLexerReturn,
fql.FqlLexerDispatch,
fql.FqlLexerQuery,
fql.FqlLexerUsing,
fql.FqlLexerNone,
fql.FqlLexerNull,
fql.FqlLexerLet,
fql.FqlLexerVar,
fql.FqlLexerUse,
fql.FqlLexerWaitfor,
fql.FqlLexerWhile,
fql.FqlLexerDo,
fql.FqlLexerIn,
fql.FqlLexerLike,
fql.FqlLexerNot,
fql.FqlLexerFor,
fql.FqlLexerBooleanLiteral,
fql.FqlLexerMatch,
fql.FqlLexerWhen:
return true
default:
return false
}
}
func delimitersMatch(open, close string) bool {
switch open {
case "(":
return close == ")"
case "[":
return close == "]"
case "{":
return close == "}"
default:
return false
}
}
@@ -1,9 +1,13 @@
package compiler_test
import (
"strings"
"testing"
"github.com/MontFerret/ferret/v2/pkg/compiler"
pkgdiagnostics "github.com/MontFerret/ferret/v2/pkg/diagnostics"
parserd "github.com/MontFerret/ferret/v2/pkg/parser/diagnostics"
"github.com/MontFerret/ferret/v2/pkg/source"
"github.com/MontFerret/ferret/v2/test/spec"
. "github.com/MontFerret/ferret/v2/test/spec/compile"
)
@@ -261,6 +265,16 @@ func TestLiteralsSyntaxErrors(t *testing.T) {
Hint: "Did you forget to provide a value?",
}, "Incomplete array literal 3"),
Failure(
`RETURN [1 2]`,
E{
Kind: parserd.SyntaxError,
Message: "Expected ',' between array items",
Hint: "Separate array items with commas, e.g. [1, 2, 3].",
},
"Missing comma between array items",
),
Failure(
`
LET i = {
@@ -324,3 +338,78 @@ func TestLiteralsSyntaxErrors(t *testing.T) {
}, "Invalid computed property expression"),
})
}
func TestArrayMissingCommaDiagnosticSpanDoesNotCascade(t *testing.T) {
query := `LET products = [
{ name: "Widget", price: 19.99 },
{ name: "Gadget", price: 149.99 },
{ name: "Thingamajig", price: 49.99 },
{ name: "Doodad", price: 9.99 },
{ name: "Doohickey", price: 199.99 },
{ name: "Whatchamacallit", price: 129.99 }
{ name: "Contraption", price: 89.99 },
{ name: "Gizmo", price: 179.99 }
]
RETURN products[*
FILTER TO_FLOAT(.price) > 100
LIMIT 3
RETURN {
title: .name,
price: TO_FLOAT(.price)
}
]`
_, err := compiler.New().Compile(source.NewAnonymous(query))
if err == nil {
t.Fatal("expected compilation error")
}
diag := firstCompilationError(err)
if diag == nil {
t.Fatalf("expected diagnostic, got %T", err)
}
if diag.Kind != parserd.SyntaxError {
t.Fatalf("unexpected diagnostic kind: %s", diag.Kind)
}
if diag.Message != "Expected ',' between array items" {
t.Fatalf("unexpected diagnostic message: %q", diag.Message)
}
if diag.Hint != "Separate array items with commas, e.g. [1, 2, 3]." {
t.Fatalf("unexpected diagnostic hint: %q", diag.Hint)
}
if len(diag.Spans) == 0 {
t.Fatal("expected diagnostic span")
}
if diag.Spans[0].Label != "missing comma" {
t.Fatalf("unexpected span label: %q", diag.Spans[0].Label)
}
line, col := diag.Source.LocationAt(diag.Spans[0].Span)
if line != 7 || col != 47 {
t.Fatalf("unexpected span location: got %d:%d, want 7:47", line, col)
}
formatted := pkgdiagnostics.Format(err)
if got := strings.Count(formatted, "SyntaxError:"); got != 1 {
t.Fatalf("expected one syntax diagnostic, got %d:\n%s", got, formatted)
}
if !strings.Contains(formatted, "7 | { name: \"Whatchamacallit\", price: 129.99 }\n | ^ missing comma\n8 | { name: \"Contraption\", price: 89.99 },") {
t.Fatalf("diagnostic should point after previous array item, got:\n%s", formatted)
}
for _, unexpected := range []string{
"no viable alternative at input",
"mismatched input ']'",
} {
if strings.Contains(formatted, unexpected) {
t.Fatalf("formatted diagnostic contains cascade %q:\n%s", unexpected, formatted)
}
}
}