Files
five/hbrtl/datetime.go
CharlesKWON f4ed42556b checkpoint: season-wide bug fix campaign + infra
Cumulative season's silent-bug hunting (~62 fixes) across the FiveSql2
SQL engine, the Five compiler/runtime, and the hbrdd RDD layer. Saved
as a single checkpoint before refactoring the parser to delegate xBase
command translation to the preprocessor.

Highlights:

FiveSql2 engine (_FiveSql2/src/)
- prefix-glob index attach -> explicit convention (<table>_pk.ntx,
  <table>_uq.ntx, <table>.cdx) — fixes silent multi-row INSERT row-drop
- DROP/CREATE TABLE FErase chain extended (.cdx, .fsc, .fsv, .dbt, .fpt)
- COUNT(DISTINCT col) parsed + aggregated via hSeen hash
- UNION column-count mismatch returns SQL_ERR_GRAMMAR (was silent)
- DISTINCT + ORDER BY hidden-col leak fixed (trim before DISTINCT)
- Derived table FROM (SELECT...) + JOIN right-side derived
- Self-FK CASCADE depth 2+ via SqlGetSingleColPK pre-collect
- LAG/LEAD default arg uses SqlEvalRowExpr (handles -N const exprs)
- DATE literal round-trip validation (Feb 29 non-leap rejected)
- CREATE OR REPLACE VIEW; CREATE VIEW errors on already-exists
- AlterTable type dispatcher comma-wrapped (1-char type "A" no longer
  matches CHARACTER)

Compiler / runtime
- gengo: HB_ -> FV_ prefix on emitted Go function names (Five identity)
- gengo split: emit_block.go, emit_stmt.go, folding.go extracted
- parser/stmtreg.go nudges
- hbrt: debug TUI/CLI restructure (debugcmd, debugkey, termios_*),
  windows debug stubs collapsed
- thread/vm/value/class/pcinterp tightening from panic traces

RDD layer (hbrdd/)
- dbf: null bitmap support (null.go + null_test.go), mmap split
  (mmap_posix.go / mmap_windows.go), byte-level numeric parse
- ntx/cdx: windows mmap parity
- workarea + mem RDD: cross-area state-bleed fixes

RTL (hbrtl/)
- errorlog rewrite with platform-specific FD (errorlog_fd_unix /
  errorlog_fd_other)
- sqlscan, sqlhelpers, indexrtl, datetime extensions

Gates green at checkpoint:
- go test ./...        : PASS
- FiveSql2 SQL:1999    : 43/43
- Harbour compat       : 56/56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:26:25 +09:00

495 lines
11 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// Date and time functions for the Five runtime library.
// Complete Harbour date system: SET DATE, SET EPOCH, SET CENTURY,
// and all date conversion/extraction functions.
//
// Harbour date defaults:
// SET DATE FORMAT "MM/DD/YY" (American)
// SET EPOCH 1900 (2-digit year base)
// SET CENTURY OFF (show 2-digit year)
//
// Reference: /mnt/d/harbour-core/src/rtl/dates.c, dateshb.c, set.c
package hbrtl
import (
"five/hbrt"
"fmt"
"strconv"
"strings"
"time"
)
// --- Date SET system ---
var (
setDateFormat = "MM/DD/YY" // Harbour default: American
setEpoch = 1900 // Harbour default: 2-digit year → 1900-1999
setCentury = false // Harbour default: OFF (2-digit year display)
)
// SetDateFormat changes date display format.
// Harbour: SET DATE FORMAT TO "YYYY-MM-DD" etc.
// Predefined: AMERICAN="MM/DD/YY", ANSI="YY.MM.DD", BRITISH="DD/MM/YY",
// FRENCH="DD/MM/YY", GERMAN="DD.MM.YY", ITALIAN="DD-MM-YY",
// JAPANESE="YY/MM/DD", USA="MM-DD-YY"
func SetDateFormat(format string) {
setDateFormat = format
// Auto-detect century
setCentury = strings.Contains(format, "YYYY")
}
// SetDateFormatRTL is the RTL wrapper.
func rtlSetDateFormat(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
SetDateFormat(t.Local(1).AsString())
t.RetNil()
}
// SetDatePreset sets date format by name.
func rtlSetDate(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
name := strings.ToUpper(t.Local(1).AsString())
switch name {
case "AMERICAN", "USA":
setDateFormat = "MM/DD/YY"
case "ANSI":
setDateFormat = "YY.MM.DD"
case "BRITISH", "FRENCH":
setDateFormat = "DD/MM/YY"
case "GERMAN":
setDateFormat = "DD.MM.YY"
case "ITALIAN":
setDateFormat = "DD-MM-YY"
case "JAPANESE":
setDateFormat = "YY/MM/DD"
}
setCentury = strings.Contains(setDateFormat, "YYYY")
t.RetNil()
}
// SET EPOCH nYear
func rtlSetEpoch(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
setEpoch = int(t.Local(1).AsNumInt())
t.RetNil()
}
// SET CENTURY ON/OFF
func rtlSetCentury(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
if nParams > 0 {
v := t.Local(1)
if v.IsLogical() {
setCentury = v.AsBool()
} else if v.IsString() {
setCentury = strings.ToUpper(v.AsString()) == "ON"
}
}
// Update format to match century setting
if setCentury && !strings.Contains(setDateFormat, "YYYY") {
setDateFormat = strings.Replace(setDateFormat, "YY", "YYYY", 1)
} else if !setCentury && strings.Contains(setDateFormat, "YYYY") {
setDateFormat = strings.Replace(setDateFormat, "YYYY", "YY", 1)
}
t.RetNil()
}
// --- Date display ---
// julianToDateStr converts Julian day to formatted date string using current SET DATE.
func julianToDateStr(julian int64) string {
if julian <= 0 {
// Empty date: return blank in current format width
w := len(formatDate(2000, 1, 1, setDateFormat))
return strings.Repeat(" ", w)
}
y, m, d := julianToDate(julian)
return formatDate(y, m, d, setDateFormat)
}
// formatDate formats a date according to format string.
func formatDate(y, m, d int, format string) string {
result := format
if strings.Contains(result, "YYYY") {
result = strings.Replace(result, "YYYY", fmt.Sprintf("%04d", y), 1)
} else if strings.Contains(result, "YY") {
result = strings.Replace(result, "YY", fmt.Sprintf("%02d", y%100), 1)
}
if strings.Contains(result, "MM") {
result = strings.Replace(result, "MM", fmt.Sprintf("%02d", m), 1)
}
if strings.Contains(result, "DD") {
result = strings.Replace(result, "DD", fmt.Sprintf("%02d", d), 1)
}
return result
}
// --- Date functions ---
// Date returns current system date.
func Date(t *hbrt.Thread) {
t.Frame(0, 0)
defer t.EndProc()
now := time.Now()
t.PushValue(hbrt.MakeDate(dateToJulian(now.Year(), int(now.Month()), now.Day())))
t.RetValue()
}
// Time returns current time as "HH:MM:SS".
func Time(t *hbrt.Thread) {
t.Frame(0, 0)
defer t.EndProc()
t.PushString(time.Now().Format("15:04:05"))
t.RetValue()
}
// Year extracts year from date.
func Year(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
y, _, _ := julianToDate(v.AsJulian())
t.RetInt(int64(y))
} else {
t.RetInt(0)
}
}
// Month extracts month from date.
func Month(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
_, m, _ := julianToDate(v.AsJulian())
t.RetInt(int64(m))
} else {
t.RetInt(0)
}
}
// Day extracts day from date.
func Day(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
_, _, d := julianToDate(v.AsJulian())
t.RetInt(int64(d))
} else {
t.RetInt(0)
}
}
// DOW returns day of week (1=Sunday .. 7=Saturday).
func DOW(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
dow := (v.AsJulian() + 2) % 7 // Julian day 0 = Monday
if dow <= 0 {
dow += 7
}
t.RetInt(dow)
} else {
t.RetInt(0)
}
}
// Seconds returns seconds since midnight.
func Seconds(t *hbrt.Thread) {
t.Frame(0, 0)
defer t.EndProc()
now := time.Now()
secs := float64(now.Hour()*3600+now.Minute()*60+now.Second()) + float64(now.Nanosecond())/1e9
t.PushValue(hbrt.MakeDoubleAuto(secs))
t.RetValue()
}
// DToC converts date to character using current SET DATE FORMAT.
func DToC(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
t.PushString(julianToDateStr(v.AsJulian()))
} else {
t.PushString("")
}
t.RetValue()
}
// DToS converts date to YYYYMMDD string (fixed format, always 8 chars).
func DToS(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
y, m, d := julianToDate(v.AsJulian())
t.PushString(fmt.Sprintf("%04d%02d%02d", y, m, d))
} else {
t.PushString(" ")
}
t.RetValue()
}
// SToD converts YYYYMMDD string to date.
func SToD(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
s := t.Local(1).AsString()
if len(s) >= 8 {
y := parseInt(s[0:4])
m := parseInt(s[4:6])
d := parseInt(s[6:8])
if y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31 {
t.PushValue(hbrt.MakeDate(dateToJulian(y, m, d)))
t.RetValue()
return
}
}
t.PushValue(hbrt.MakeDate(0))
t.RetValue()
}
// CToD converts character to date using current SET DATE FORMAT.
// Harbour: CToD("09/18/92") with SET DATE AMERICAN
//
// Pre-pass: unambiguous ISO forms (YYYY-MM-DD, YYYY.MM.DD, YYYY/MM/DD,
// and bare YYYYMMDD) parse directly regardless of SET DATE. Those
// formats have a fixed component order, so skipping the setDateFormat
// lookup lets code that uses ISO dates work without a SET DATE ANSI
// call up front. Non-ISO strings fall through to the original
// format-aware path so American / European users keep their semantics.
func CToD(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
s := strings.TrimSpace(t.Local(1).AsString())
if len(s) == 0 {
t.PushValue(hbrt.MakeDate(0))
t.RetValue()
return
}
if y, m, d, ok := parseIsoDate(s); ok {
t.PushValue(hbrt.MakeDate(dateToJulian(y, m, d)))
t.RetValue()
return
}
// Parse according to current date format
y, m, d := parseDateByFormat(s, setDateFormat)
// Apply epoch for 2-digit years
if y >= 0 && y < 100 {
if y+setEpoch/100*100 < setEpoch {
y += (setEpoch/100 + 1) * 100
} else {
y += setEpoch / 100 * 100
}
}
if y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31 {
t.PushValue(hbrt.MakeDate(dateToJulian(y, m, d)))
} else {
t.PushValue(hbrt.MakeDate(0))
}
t.RetValue()
}
// parseIsoDate accepts YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, and bare
// YYYYMMDD. Returns ok=false for anything else so the caller can fall
// back to the format-aware parse. The 4-digit year anchor disambiguates
// from 2-digit-year American / European layouts.
func parseIsoDate(s string) (y, m, d int, ok bool) {
switch len(s) {
case 10:
if s[4] != '-' && s[4] != '/' && s[4] != '.' {
return
}
if s[7] != s[4] {
return
}
if yy, e := strconv.Atoi(s[0:4]); e == nil {
y = yy
} else {
return
}
if mm, e := strconv.Atoi(s[5:7]); e == nil {
m = mm
} else {
return
}
if dd, e := strconv.Atoi(s[8:10]); e == nil {
d = dd
} else {
return
}
case 8:
if yy, e := strconv.Atoi(s[0:4]); e == nil {
y = yy
} else {
return
}
if mm, e := strconv.Atoi(s[4:6]); e == nil {
m = mm
} else {
return
}
if dd, e := strconv.Atoi(s[6:8]); e == nil {
d = dd
} else {
return
}
default:
return
}
if y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31 {
ok = true
}
return
}
// parseDateByFormat parses a date string according to format.
func parseDateByFormat(s, format string) (y, m, d int) {
// Find positions of Y, M, D in format
yPos := strings.Index(format, "YY")
mPos := strings.Index(format, "MM")
dPos := strings.Index(format, "DD")
yLen := 2
if strings.Contains(format, "YYYY") {
yLen = 4
}
// Extract numeric parts from input (skip separators)
digits := extractDigits(s)
// Map digits to y/m/d based on format order
type part struct {
pos int
len int
val *int
}
parts := []part{
{yPos, yLen, &y},
{mPos, 2, &m},
{dPos, 2, &d},
}
// Sort by position
for i := 0; i < len(parts)-1; i++ {
for j := i + 1; j < len(parts); j++ {
if parts[j].pos < parts[i].pos {
parts[i], parts[j] = parts[j], parts[i]
}
}
}
off := 0
for _, p := range parts {
if off+p.len <= len(digits) {
*p.val = parseInt(digits[off : off+p.len])
off += p.len
}
}
return
}
func extractDigits(s string) string {
var buf []byte
for _, c := range s {
if c >= '0' && c <= '9' {
buf = append(buf, byte(c))
}
}
return string(buf)
}
// CDoW returns day name.
func CDoW(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
dow := (v.AsJulian() + 2) % 7
if dow <= 0 {
dow += 7
}
days := []string{"", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
if dow >= 1 && dow <= 7 {
t.PushString(days[dow])
t.RetValue()
return
}
}
t.PushString("")
t.RetValue()
}
// CMonth returns month name.
func CMonth(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
v := t.Local(1)
if v.IsDateTime() {
_, m, _ := julianToDate(v.AsJulian())
months := []string{"", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"}
if m >= 1 && m <= 12 {
t.PushString(months[m])
t.RetValue()
return
}
}
t.PushString("")
t.RetValue()
}
// --- Julian date helpers ---
func dateToJulian(y, m, d int) int64 {
if m <= 2 {
y--
m += 12
}
a := y / 100
b := 2 - a + a/4
return int64(365.25*float64(y+4716)) + int64(30.6001*float64(m+1)) + int64(d+b) - 1524
}
func julianToDate(julian int64) (y, m, d int) {
if julian <= 0 {
return 0, 0, 0
}
l := julian + 68569
n := 4 * l / 146097
l = l - (146097*n+3)/4
i := 4000 * (l + 1) / 1461001
l = l - 1461*i/4 + 31
j := 80 * l / 2447
d = int(l - 2447*j/80)
l = j / 11
m = int(j + 2 - 12*l)
y = int(100*(n-49) + i + l)
return
}
func parseInt(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}