feat: Harbour RDD parity — NTX/CDX 100% compatible, FIELD-> works

Five RDD engine now matches Harbour DBFNTX and DBFCDX byte-for-byte
in ordering, seek, navigation, and field access. Verified against
Harbour 3.2.0dev with a 281-line comparison test covering:
  - Natural/NAME/CITY/AGE/SALARY/UPPER ordering
  - SEEK (exact/not-found), GoTop/GoBottom per order
  - DELETE/RECALL with SET DELETED
  - CDX compound index read with 5 tags (BYNAME, BYCITY, BYAGE, BYSAL, BYUNAME)
  - Reverse traversal

Fixes:

1. FIELD->NAME returned NIL
   GetAliasField returned interface{} but runtime expected hbrt.Value,
   so the type assertion in PushAliasField failed and pushed NIL.
   - workarea.go: change return type to hbrt.Value, handle FIELD/_FIELD
     as current-workarea alias, add SetAliasField
   - gengo.go: emit SetAliasField() for alias->field := value in both
     statement and expression contexts

2. OrdSetFocus(n) silently switched to natural order
   v.AsString() returns "" for a numeric Value, so OrderListFocus("")
   set current=-1.
   - indexrtl.go: convert numeric param via fmt.Sprintf("%d", ...)

3. CDX compound tag order mismatched Harbour
   Five decoded the structural B-tree which is alphabetical, but
   Harbour sorts tags by TagBlock (file offset = creation order).
   - cdx/cdx.go: sort tagEntries by offset ascending after decoding,
     matching hb_cdxIndexLoadAvailTags in dbfcdx1.c

4. OutStd()/OutErr() not registered — caused panic on call
   - hbrtl/console.go: add rtlOutStd/rtlOutErr implementations
   - hbrtl/register.go: register OUTSTD and OUTERR
   - analyzer.go: add OUTSTD/OUTERR to RTL known-functions

5. FIELD keyword triggered "undeclared variable" warnings
   - analyzer.go: add FIELD, _FIELD, M, MEMVAR as builtin constants

Tests:
  go test ./...        — ALL PASS (17 packages)
  FiveSql2 43/43       — 100%
  compat_harbour 51/51 — 100%
  Harbour diff         — 0 lines differ (281-line comparison)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:37:47 +09:00
parent 02026a1966
commit e95afad4ee
7 changed files with 100 additions and 8 deletions

View File

@@ -467,7 +467,7 @@ var rtlFunctions = map[string]bool{
"TYPE": true, "PCOUNT": true, "BREAK": true, "ARRAY": true, "FCOUNT": true,
"FIELDNAME": true, "SELECT": true, "FILE": true, "INKEY": true, "TRANSFORM": true,
"SETDATEFORMAT": true, "SETEPOCH": true, "SETCENTURY": true,
"IIF": true, "IF": true, "STRZERO": true, "OUTSTD": true,
"IIF": true, "IF": true, "STRZERO": true, "OUTSTD": true, "OUTERR": true,
"CENTER": true, "SOUNDEX": true, "TONE": true,
// Terminal
"SETPOS": true, "ROW": true, "COL": true, "DEVPOS": true, "DEVOUT": true,
@@ -634,6 +634,8 @@ func (a *Analyzer) isBuiltinConstant(name string) bool {
"SELF": true, "SUPER": true,
// Harbour commands treated as identifiers
"QUIT": true, "ERRORLEVEL": true,
// Field/Memvar alias prefixes
"FIELD": true, "_FIELD": true, "M": true, "MEMVAR": true,
// Keyboard constants
"K_ESC": true, "K_ENTER": true, "K_UP": true, "K_DOWN": true,
"K_LEFT": true, "K_RIGHT": true, "K_PGUP": true, "K_PGDN": true,

View File

@@ -852,6 +852,17 @@ func (g *Generator) emitAssign(a *ast.AssignExpr, locals localMap) {
}
}
// Check for alias->field := value (FIELD->NAME := value)
if aliasExpr, ok := a.Left.(*ast.AliasExpr); ok {
if aliasIdent, ok2 := aliasExpr.Alias.(*ast.IdentExpr); ok2 {
if fieldIdent, ok3 := aliasExpr.Field.(*ast.IdentExpr); ok3 {
g.emitExpr(a.Right)
g.writeln(fmt.Sprintf(`{ _wa := t.WA.(*hbrdd.WorkAreaManager); _wa.SetAliasField(%q, %q, t.Pop2()) }`, aliasIdent.Name, fieldIdent.Name))
return
}
}
}
if ident, ok := a.Left.(*ast.IdentExpr); ok {
if idx, found := locals[strings.ToUpper(ident.Name)]; found {
switch a.Op {
@@ -1749,6 +1760,17 @@ func (g *Generator) emitCall(e *ast.CallExpr) {
// emitAssignExpr handles := / += / -= in expression context (e.g. code block body).
func (g *Generator) emitAssignExpr(e *ast.AssignExpr) {
// alias->field := value in expression context
if aliasExpr, ok := e.Left.(*ast.AliasExpr); ok {
if aliasIdent, ok2 := aliasExpr.Alias.(*ast.IdentExpr); ok2 {
if fieldIdent, ok3 := aliasExpr.Field.(*ast.IdentExpr); ok3 {
g.emitExpr(e.Right)
g.writeln("t.Dup()")
g.writeln(fmt.Sprintf(`{ _wa := t.WA.(*hbrdd.WorkAreaManager); _wa.SetAliasField(%q, %q, t.Pop2()) }`, aliasIdent.Name, fieldIdent.Name))
return
}
}
}
if ident, ok := e.Left.(*ast.IdentExpr); ok {
if idx, found := g.curLocals[strings.ToUpper(ident.Name)]; found {
switch e.Op {

View File

@@ -22,6 +22,7 @@ import (
"encoding/binary"
"fmt"
"os"
"sort"
"strings"
"syscall"
)
@@ -376,6 +377,14 @@ func OpenIndex(path string) (*Index, error) {
// points to the tag header at a specific file offset.
tagEntries := readCompoundTagList(idx, rootHdr)
// Harbour orders tags by file offset (TagBlock) ascending, which
// corresponds to creation order. The compound B-tree stores entries
// alphabetically, so we must re-sort by offset to match Harbour.
// See: hb_cdxIndexLoadAvailTags in dbfcdx1.c
sort.Slice(tagEntries, func(i, j int) bool {
return tagEntries[i].offset < tagEntries[j].offset
})
for _, entry := range tagEntries {
tagHdr, err := ReadTagHeader(f, entry.offset)
if err != nil {

View File

@@ -9,6 +9,7 @@
package hbrdd
import (
"five/hbrt"
"fmt"
"strings"
)
@@ -213,11 +214,18 @@ func (wm *WorkAreaManager) CloseAll() {
}
// GetAliasField returns a field value from a named alias.
// Used by alias->field syntax.
func (wm *WorkAreaManager) GetAliasField(alias, field string) interface{} {
area := wm.ByAlias(alias)
// Used by alias->field syntax (e.g., CUSTOMERS->NAME, FIELD->AGE).
func (wm *WorkAreaManager) GetAliasField(alias, field string) hbrt.Value {
var area Area
// FIELD-> is a special alias meaning "current workarea"
if strings.EqualFold(alias, "FIELD") || strings.EqualFold(alias, "_FIELD") {
area = wm.Current()
} else {
area = wm.ByAlias(alias)
}
if area == nil {
return nil
return hbrt.MakeNil()
}
// Find field by name
for i := 0; i < area.FieldCount(); i++ {
@@ -227,7 +235,28 @@ func (wm *WorkAreaManager) GetAliasField(alias, field string) interface{} {
return val
}
}
return nil
return hbrt.MakeNil()
}
// SetAliasField sets a field value by alias->field syntax.
func (wm *WorkAreaManager) SetAliasField(alias, field string, val hbrt.Value) {
var area Area
if strings.EqualFold(alias, "FIELD") || strings.EqualFold(alias, "_FIELD") {
area = wm.Current()
} else {
area = wm.ByAlias(alias)
}
if area == nil {
return
}
for i := 0; i < area.FieldCount(); i++ {
fi := area.GetFieldInfo(i)
if strings.EqualFold(fi.Name, field) {
area.PutValue(i, val)
return
}
}
}
// --- Helpers ---

View File

@@ -8,6 +8,7 @@ package hbrtl
import (
"five/hbrt"
"fmt"
"os"
"strings"
)
@@ -83,4 +84,30 @@ func valueToDisplay(v hbrt.Value) string {
}
}
// rtlOutStd writes values to stdout without newline. Harbour: OutStd()
func rtlOutStd(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProcFast()
parts := make([]string, nParams)
for i := 0; i < nParams; i++ {
parts[i] = valueToDisplay(t.Local(i + 1))
}
fmt.Print(strings.Join(parts, ""))
t.RetNil()
}
// rtlOutErr writes values to stderr without newline. Harbour: OutErr()
func rtlOutErr(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProcFast()
parts := make([]string, nParams)
for i := 0; i < nParams; i++ {
parts[i] = valueToDisplay(t.Local(i + 1))
}
fmt.Fprint(os.Stderr, strings.Join(parts, ""))
t.RetNil()
}
// julianToDateStr and date formatting moved to datetime.go

View File

@@ -11,6 +11,7 @@ import (
"five/hbrt"
"five/hbrdd"
"five/hbrdd/dbf"
"fmt"
)
// INDEXORD() → nCurrentOrder (1-based, 0 = natural)
@@ -74,8 +75,8 @@ func OrdSetFocus(t *hbrt.Thread) {
if idx, ok := area.(hbrdd.Indexer); ok {
v := t.Local(1)
if v.IsNumeric() {
// SET ORDER TO n
idx.OrderListFocus(v.AsString())
// SET ORDER TO n — convert number to digit string for OrderListFocus
idx.OrderListFocus(fmt.Sprintf("%d", v.AsNumInt()))
} else {
idx.OrderListFocus(v.AsString())
}

View File

@@ -24,6 +24,8 @@ func RegisterRTL(vm *hbrt.VM) {
// Console
hbrt.Sym("QOUT", hbrt.FsPublic, rtlQOut),
hbrt.Sym("QQOUT", hbrt.FsPublic, rtlQQOut),
hbrt.Sym("OUTSTD", hbrt.FsPublic, rtlOutStd),
hbrt.Sym("OUTERR", hbrt.FsPublic, rtlOutErr),
// Strings / Conversion
hbrt.Sym("STR", hbrt.FsPublic, Str),