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

feat: enhance binary operation diagnostics with detailed operand spans

This commit is contained in:
Tim Voronov
2026-06-08 16:57:45 -04:00
parent 3a82b5de1d
commit a03151c2ee
6 changed files with 260 additions and 28 deletions
@@ -63,7 +63,7 @@ func (c *BindingCompiler) compilePathAssignmentStatement(stmt *fql.AssignmentSta
return bytecode.NoopOperand
}
value = emitBinaryOperation(c.ctx, c.facts, stmt, op, current, right)
value = emitBinaryOperation(c.ctx, c.facts, assignmentOperationSpans(stmt), op, current, right)
}
if value == bytecode.NoopOperand {
+1 -1
View File
@@ -188,7 +188,7 @@ func (c *BindingCompiler) CompileAssignmentStatement(ctx fql.IAssignmentStatemen
left := c.snapshotBindingValue(binding)
right := c.exprs.Compile(stmt.Expression())
src = emitBinaryOperation(c.ctx, c.facts, stmt, op, left, right)
src = emitBinaryOperation(c.ctx, c.facts, assignmentOperationSpans(stmt), op, left, right)
}
srcType := c.facts.OperandType(src)
+25 -2
View File
@@ -40,6 +40,13 @@ type (
regexp bool
}
binaryOperationSpans struct {
expression source.Span
operator source.Span
leftOperand source.Span
rightOperand source.Span
}
matchResultGroup struct {
result fql.IExpressionContext
arms []int
@@ -150,7 +157,13 @@ func (c *ExprCompiler) CompileIncDec(token antlr.Token, target bytecode.Operand)
}
operator := token.GetText()
if !validateKnownNumericOperands(c.ctx, c.facts, parserd.SpanFromToken(token), operator, target) {
if !validateKnownNumericOperands(
c.ctx,
c.facts,
parserd.SpanFromToken(token),
operator,
numericOperandDiagnostic{operand: target},
) {
return bytecode.NoopOperand
}
@@ -189,7 +202,17 @@ func (c *ExprCompiler) compileUnary(ctx fql.IUnaryOperatorContext, parent fql.IE
span = parserd.SpanFromRuleContext(prc)
}
if op != bytecode.OpNot && !validateKnownNumericOperands(c.ctx, c.facts, span, ctx.GetText(), src) {
if op != bytecode.OpNot && !validateKnownNumericOperands(
c.ctx,
c.facts,
parserd.SpanFromRuleContext(ctx),
ctx.GetText(),
numericOperandDiagnostic{
operand: src,
span: parserd.SpanFromRuleContext(parent.GetRight()),
label: "operand",
},
) {
return bytecode.NoopOperand
}
+1 -3
View File
@@ -263,9 +263,7 @@ func (c *ExprCompiler) compileBinaryAtom(ctx fql.IExpressionAtomContext, op atom
}
func (c *ExprCompiler) emitBinaryAtomOperation(ctx fql.IExpressionAtomContext, op atomBinaryOperator, left, right bytecode.Operand) bytecode.Operand {
prc, _ := ctx.(antlr.ParserRuleContext)
return emitBinaryOperation(c.ctx, c.facts, prc, op, left, right)
return emitBinaryOperation(c.ctx, c.facts, binaryAtomOperationSpans(ctx), op, left, right)
}
func (c *ExprCompiler) validateRegexpOperand(ctx fql.IExpressionAtomContext) {
+112 -21
View File
@@ -150,23 +150,30 @@ func resolveArithmeticBinaryOperator(operator string) (atomBinaryOperator, bool)
}
}
func emitBinaryOperation(ctx *CompilationSession, facts *TypeFacts, prc antlr.ParserRuleContext, op atomBinaryOperator, left, right bytecode.Operand) bytecode.Operand {
type numericOperandDiagnostic struct {
operand bytecode.Operand
span source.Span
label string
}
func emitBinaryOperation(ctx *CompilationSession, facts *TypeFacts, spans binaryOperationSpans, op atomBinaryOperator, left, right bytecode.Operand) bytecode.Operand {
if ctx == nil {
return bytecode.NoopOperand
}
span := source.Span{Start: -1, End: -1}
if prc != nil {
span = parserd.SpanFromRuleContext(prc)
}
if isStrictNumericBinaryOpcode(op.opcode) && !validateKnownNumericOperands(ctx, facts, span, op.text, left, right) {
if isStrictNumericBinaryOpcode(op.opcode) && !validateKnownNumericOperands(
ctx,
facts,
spans.operator,
op.text,
numericOperandDiagnostic{operand: left, span: spans.leftOperand, label: "left operand"},
numericOperandDiagnostic{operand: right, span: spans.rightOperand, label: "right operand"},
) {
return bytecode.NoopOperand
}
dst := ctx.Function.Registers.Allocate()
ctx.Program.Emitter.WithSpan(span, func() {
ctx.Program.Emitter.WithSpan(spans.expression, func() {
ctx.Program.Emitter.EmitABC(op.opcode, dst, left, right)
if op.negated {
@@ -197,26 +204,42 @@ func isStrictNumericBinaryOpcode(op bytecode.Opcode) bool {
func validateKnownNumericOperands(
ctx *CompilationSession,
facts *TypeFacts,
span source.Span,
operatorSpan source.Span,
operator string,
operands ...bytecode.Operand,
operands ...numericOperandDiagnostic,
) bool {
spans := []pkgdiagnostics.ErrorSpan{
pkgdiagnostics.NewMainErrorSpan(operatorSpan, ""),
}
invalid := false
for _, operand := range operands {
if knownNumericOperandTypeAllowed(facts.OperandType(operand)) {
typ := facts.OperandType(operand.operand)
if knownNumericOperandTypeAllowed(typ) {
continue
}
ctx.Program.Errors.Add(&pkgdiagnostics.Diagnostic{
Kind: parserd.SemanticError,
Message: fmt.Sprintf("Operator '%s' requires numeric operands", operator),
Hint: "Use Int or Float values with this operator.",
Spans: []pkgdiagnostics.ErrorSpan{pkgdiagnostics.NewMainErrorSpan(span, "")},
})
return false
invalid = true
if operand.label != "" && validDiagnosticSpan(operand.span) {
spans = append(spans, pkgdiagnostics.NewSecondaryErrorSpan(
operand.span,
fmt.Sprintf("%s is %s", operand.label, numericDiagnosticTypeName(typ)),
))
}
}
return true
if !invalid {
return true
}
ctx.Program.Errors.Add(&pkgdiagnostics.Diagnostic{
Kind: parserd.SemanticError,
Message: fmt.Sprintf("Operator '%s' requires numeric operands", operator),
Hint: "Use Int or Float values with this operator.",
Spans: spans,
})
return false
}
func knownNumericOperandTypeAllowed(typ core.ValueType) bool {
@@ -227,3 +250,71 @@ func knownNumericOperandTypeAllowed(typ core.ValueType) bool {
return false
}
}
func binaryAtomOperationSpans(ctx fql.IExpressionAtomContext) binaryOperationSpans {
spans := binaryOperationSpans{
expression: spanFromParserRuleContext(ctx),
leftOperand: spanFromParserRuleContext(ctx.GetLeft()),
rightOperand: spanFromParserRuleContext(ctx.GetRight()),
}
if op := ctx.MultiplicativeOperator(); op != nil {
spans.operator = parserd.SpanFromRuleContext(op)
} else if op := ctx.AdditiveOperator(); op != nil {
spans.operator = parserd.SpanFromRuleContext(op)
}
return spans
}
func assignmentOperationSpans(ctx *fql.AssignmentStatementContext) binaryOperationSpans {
if ctx == nil {
return binaryOperationSpans{}
}
return binaryOperationSpans{
expression: parserd.SpanFromRuleContext(ctx),
operator: spanFromParserRuleContext(ctx.AssignmentOperator()),
leftOperand: spanFromParserRuleContext(ctx.AssignmentTarget()),
rightOperand: spanFromParserRuleContext(ctx.Expression()),
}
}
func spanFromParserRuleContext(ctx antlr.ParserRuleContext) source.Span {
if ctx == nil {
return source.Span{Start: -1, End: -1}
}
return parserd.SpanFromRuleContext(ctx)
}
func validDiagnosticSpan(span source.Span) bool {
return span.Start >= 0 && span.End > span.Start
}
func numericDiagnosticTypeName(typ core.ValueType) string {
switch typ {
case core.TypeNone:
return "None"
case core.TypeInt:
return "Int"
case core.TypeFloat:
return "Float"
case core.TypeString:
return "String"
case core.TypeBool:
return "Bool"
case core.TypeArray:
return "Array"
case core.TypeObject:
return "Object"
case core.TypeList:
return "List"
case core.TypeMap:
return "Map"
case core.TypeAny:
return "Any"
default:
return "Unknown"
}
}
@@ -1,9 +1,13 @@
package compiler_test
import (
"strings"
"testing"
"github.com/MontFerret/ferret/v2/pkg/compiler"
"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"
)
@@ -26,3 +30,119 @@ func TestMathOperatorsRejectKnownNonNumericOperands(t *testing.T) {
Failure(`RETURN +"3"`, expected("+"), "string unary positive"),
})
}
func TestMathOperatorDiagnosticSpansMultiline(t *testing.T) {
src := `RETURN [
120,
45,
300
] * 2`
diag := compileMathOperatorDiagnostic(t, "arithmetic.fql", src)
assertMathOperatorDiagnostic(t, diag, "*", 2)
assertMathOperatorSpan(t, diag.Spans[0], strings.Index(src, "*"), strings.Index(src, "*")+1, "", true)
assertMathOperatorSpan(t, diag.Spans[1], strings.Index(src, "["), strings.Index(src, "]")+1, "left operand is Array", false)
expected := `SemanticError: Operator '*' requires numeric operands
--> arithmetic.fql:1:8
|
1 | RETURN [
| ^ left operand is Array
2 | 120,
--> arithmetic.fql:5:3
|
4 | 300
5 | ] * 2
| ^
Hint: Use Int or Float values with this operator.
`
if actual := diag.Format(); actual != expected {
t.Fatalf("unexpected formatted diagnostic:\n%s\nDiff expected:\n%s", actual, expected)
}
}
func TestMathOperatorDiagnosticIdentifiesInvalidOperands(t *testing.T) {
t.Run("right operand", func(t *testing.T) {
src := `RETURN 2 * "x"`
diag := compileMathOperatorDiagnostic(t, "right_operand.fql", src)
assertMathOperatorDiagnostic(t, diag, "*", 2)
assertMathOperatorSpan(t, diag.Spans[0], strings.Index(src, "*"), strings.Index(src, "*")+1, "", true)
assertMathOperatorSpan(t, diag.Spans[1], strings.Index(src, `"x"`), strings.Index(src, `"x"`)+3, "right operand is String", false)
})
t.Run("both operands", func(t *testing.T) {
src := `RETURN TRUE * {}`
diag := compileMathOperatorDiagnostic(t, "both_operands.fql", src)
assertMathOperatorDiagnostic(t, diag, "*", 3)
assertMathOperatorSpan(t, diag.Spans[0], strings.Index(src, "*"), strings.Index(src, "*")+1, "", true)
assertMathOperatorSpan(t, diag.Spans[1], strings.Index(src, "TRUE"), strings.Index(src, "TRUE")+4, "left operand is Bool", false)
assertMathOperatorSpan(t, diag.Spans[2], strings.Index(src, "{}"), strings.Index(src, "{}")+2, "right operand is Object", false)
})
t.Run("unary operand", func(t *testing.T) {
src := `RETURN -"x"`
diag := compileMathOperatorDiagnostic(t, "unary_operand.fql", src)
assertMathOperatorDiagnostic(t, diag, "-", 2)
assertMathOperatorSpan(t, diag.Spans[0], strings.Index(src, "-"), strings.Index(src, "-")+1, "", true)
assertMathOperatorSpan(t, diag.Spans[1], strings.Index(src, `"x"`), strings.Index(src, `"x"`)+3, "operand is String", false)
})
t.Run("augmented assignment right operand", func(t *testing.T) {
src := "VAR total = 2\ntotal *= \"x\"\nRETURN total"
diag := compileMathOperatorDiagnostic(t, "assignment_operand.fql", src)
assertMathOperatorDiagnostic(t, diag, "*=", 2)
assertMathOperatorSpan(t, diag.Spans[0], strings.Index(src, "*="), strings.Index(src, "*=")+2, "", true)
assertMathOperatorSpan(t, diag.Spans[1], strings.Index(src, `"x"`), strings.Index(src, `"x"`)+3, "right operand is String", false)
})
}
func compileMathOperatorDiagnostic(t *testing.T, name, src string) *diagnostics.Diagnostic {
t.Helper()
_, err := compiler.New().Compile(source.New(name, src))
if err == nil {
t.Fatal("expected compilation error")
}
diag := firstCompilationError(err)
if diag == nil {
t.Fatalf("expected diagnostic, got %T", err)
}
return diag
}
func assertMathOperatorDiagnostic(t *testing.T, diag *diagnostics.Diagnostic, operator string, spanCount int) {
t.Helper()
if diag.Kind != parserd.SemanticError {
t.Fatalf("unexpected diagnostic kind: got %s want %s", diag.Kind, parserd.SemanticError)
}
if diag.Message != "Operator '"+operator+"' requires numeric operands" {
t.Fatalf("unexpected diagnostic message: %q", diag.Message)
}
if diag.Hint != "Use Int or Float values with this operator." {
t.Fatalf("unexpected diagnostic hint: %q", diag.Hint)
}
if len(diag.Spans) != spanCount {
t.Fatalf("unexpected span count: got %d want %d", len(diag.Spans), spanCount)
}
}
func assertMathOperatorSpan(t *testing.T, actual diagnostics.ErrorSpan, start, end int, label string, main bool) {
t.Helper()
if actual.Span.Start != start || actual.Span.End != end {
t.Fatalf("unexpected span: got [%d,%d) want [%d,%d)", actual.Span.Start, actual.Span.End, start, end)
}
if actual.Label != label {
t.Fatalf("unexpected span label: got %q want %q", actual.Label, label)
}
if actual.Main != main {
t.Fatalf("unexpected main flag: got %t want %t", actual.Main, main)
}
}