feat(oop): OPERATOR overloading — + - * / == != < > <= >=

Harbour lets a class define custom behaviour for arithmetic and
comparison operators via `OPERATOR "<sym>" ARG <name> INLINE <expr>`.
Five already had the runtime slot infrastructure (ClassDef.Operators
+ AddOperator + parent-chain copy) but parser skipped the form and
the VM ops never consulted the slots.

Parser: parseOperatorDecl captures the symbol, ARG binding, and
INLINE body into a MethodDecl with IsOperator=true and OperatorOp
set to the hbrt.Op* slot. Synthesised method name is __OP_<idx>
to keep the regular method namespace clean.

Codegen: emitClassDecl routes IsOperator members through
_def.AddOperator instead of AddMethod. Inline body generation is
shared with the MESSAGE/INLINE path (34485cd).

VM: Thread.tryBinaryOp walks the LHS object's class operator slot,
pushes args with Self bound to LHS, and returns true if the slot
is populated. Wired into Plus/Minus/Mult/Divide and Equal/NotEqual/
Less/Greater/LessEqual/GreaterEqual. Falls through to built-in
behaviour when no overload exists — non-object LHS costs one tag
check per op.

Operator symbol→slot mapping keeps `=` and `==` on the same slot
(OpEqual=8) because Five's gengo routes both to t.Equal() and the
VM doesn't distinguish strict vs non-strict equality today.

Tested (/tmp/test_operator.prg): Vec2 + - == < with per-field
results all correct.

FiveSql2 43/43, Harbour compat 56/56, Go test ALL PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:54:44 +09:00
parent 34485cd6c8
commit 66f045b97e
6 changed files with 159 additions and 7 deletions

View File

@@ -564,8 +564,13 @@ func (p *Parser) parseClassDecl() *ast.ClassDecl {
} else {
p.skipToEndOfLine()
}
case token.INLINE_KW, token.ON, token.DESTRUCTOR, token.OPERATOR_KW:
// Stray INLINE, ON ERROR, DESTRUCTOR, OPERATOR — skip to EOL
case token.OPERATOR_KW:
// OPERATOR "<sym>" ARG <name> INLINE <expr>
if md := p.parseOperatorDecl(); md != nil {
members = append(members, md)
}
case token.INLINE_KW, token.ON, token.DESTRUCTOR:
// Stray INLINE, ON ERROR, DESTRUCTOR — skip to EOL
p.skipToEndOfLine()
p.skipNewlines()
continue
@@ -616,7 +621,12 @@ func (p *Parser) parseClassDecl() *ast.ClassDecl {
// emitClassDecl routes it through AddMethod and the inline
// body emitter generates the HB_<CLASS>_<NAME> function.
members = append(members, p.parseMessageDecl())
} else if upper == "ON" || upper == "OPERATOR" || upper == "DESTRUCTOR" ||
} else if upper == "OPERATOR" {
// OPERATOR "<sym>" ARG <name> INLINE <expr>
if md := p.parseOperatorDecl(); md != nil {
members = append(members, md)
}
} else if upper == "ON" || upper == "DESTRUCTOR" ||
upper == "DELEGATE" || upper == "ERROR" ||
upper == "VIRTUAL" || upper == "DEFERRED" {
// ON ERROR, OPERATOR "+" ARG, DESTRUCTOR, DELEGATE — skip to EOL
@@ -771,6 +781,79 @@ func (p *Parser) parseClassMethodDecl() *ast.MethodDecl {
}
}
// operatorNameIndex maps a Harbour operator symbol to its slot in
// hbrt.ClassDef.Operators. Values mirror hbrt.Op* constants
// (hbrt/class.go:50-73).
//
// `=` and `==` both route through VM Thread.Equal(), which dispatches
// on OpEqual (8) — Five doesn't distinguish them at the VM level, so
// `==` maps to the same slot. `!=`, `<>`, `#` are all OpNotEqual (10).
var operatorNameIndex = map[string]int{
"+": 0, "-": 1, "*": 2, "/": 3, "%": 4, "^": 5,
"++": 6, "--": 7,
"=": 8, "==": 8, "!=": 10, "<>": 10, "#": 10,
"<": 11, "<=": 12, ">": 13, ">=": 14,
":=": 15, "$": 16,
}
// parseOperatorDecl parses:
// OPERATOR "<sym>" [ARG <name>] INLINE <expr>
//
// The ARG binds the RHS operand to a local when the operator is
// dispatched via the VM's binary op. INLINE <expr> is the body.
// Harbour also allows `OPERATOR "<sym>" ... METHOD foo CLASS X`
// (non-inline) but that form is rare; we only support INLINE for
// now and skip the line otherwise.
func (p *Parser) parseOperatorDecl() *ast.MethodDecl {
opPos := p.current.Pos
p.advance() // OPERATOR
if p.current.Kind != token.STRING {
p.skipToEndOfLine()
return nil
}
symbol := p.current.Literal
p.advance()
opIdx, ok := operatorNameIndex[symbol]
if !ok {
p.skipToEndOfLine()
return nil
}
var params []*ast.ParamDecl
if p.current.Kind == token.IDENT && p.currentUpper() == "ARG" {
p.advance()
argName := p.expectMethodName().Literal
params = []*ast.ParamDecl{{Name: argName}}
}
if !(p.current.Kind == token.INLINE_KW ||
(p.current.Kind == token.IDENT && p.currentUpper() == "INLINE")) {
// Non-inline operator — body arrives as a separate METHOD decl.
// Unsupported for now; skip.
p.skipToEndOfLine()
return nil
}
p.advance() // INLINE
body := p.parseExpr()
p.expectEndOfStmt()
// Synthesise a method named __OP_<idx> so the vtable doesn't collide
// with user-declared methods. emitClassDecl sees OperatorOp >= 0 and
// routes registration through AddOperator instead of AddMethod.
return &ast.MethodDecl{
MethodPos: opPos,
Name: fmt.Sprintf("__OP_%d", opIdx),
Params: params,
IsInline: true,
InlineBody: body,
IsOperator: true,
OperatorOp: opIdx,
EndPos: opPos,
}
}
// parseMessageDecl parses `MESSAGE <name> [(params)] INLINE <expr>` in
// a CLASS body and returns a MethodDecl. Harbour semantics: a MESSAGE
// handler is invoked like a method and behaves identically — the form