Compare commits

...

2 Commits

Author SHA1 Message Date
CharlesKWON
a8f6e53785 fix(pp): // line comment containing /* no longer eats subsequent lines
stripBlockComments scanned each line for a /* block-comment opener
while tracking string literals but had no notion of // or && line
comments. A line like `// see app/api/*.prg` would open a block
comment from /*.prg that ran until EOF or the next */, silently
dropping every FUNCTION declaration in between. The compiled file
ended up with an empty symbols slice, and callers in other files
panicked at runtime with "no function symbol for call".

Hit while writing app/lib/text.prg in solmade — its `// build's
\`app/api/*.prg\` glob doesn't pick it up` line dropped all three
of QueryParamRaw / UrlDecodeBytes / IsAllDigits.

Fix: detect // and && line-comment markers before the /* check.
When one is seen, copy the rest of the line through verbatim (the
lexer and #command machinery still need it) and stop scanning so
the embedded /* can't open a block comment.

Two regression tests cover both markers. Full mandatory test suite
(go test ./..., FiveSql2 43/43, compat 56/56, std.ch 17/17, FRB 7/7,
pgserver 11/11) still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 08:47:55 +09:00
CharlesKWON
675eaa4def feat(hbrtl): FV_HTTPGET / FV_HTTPPOST / FV_ZIP* / FV_XML_ROWS
New Five-native HTTP / ZIP / XML primitives so PRG code can do
HTTPS fetch, ZIP container reads, and streaming XML row extraction
without dropping into BEGINDUMP. FV_ prefix marks Five-original
RTL (distinct from Harbour-inherited HB_ surface).

FV_HTTPGET(cUrl [, hOpts]) / FV_HTTPPOST(cUrl, cBody [, hOpts])
  hOpts:   { headers: {=>}, timeout: nSec, tls_legacy: .T./.F. }
  Result:  { status, body, error, headers }
  tls_legacy re-enables TLS_RSA cipher suites for legacy
  endpoints (DART OpenAPI pins them).

FV_ZIPENTRIES(cZipBytes) / FV_ZIPREAD(cZipBytes, cEntryName)
  Read ZIP archives held in memory (e.g. from FV_HTTPGET).

FV_XML_ROWS(cXml, cRowTag)
  Streaming reader for repeating-record XML. Each row becomes a
  flat hash of immediate-child element name -> text. Verified
  against DART corpCode.xml: 30 MB / 118k rows in seconds, no
  full-tree allocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 08:47:34 +09:00
6 changed files with 367 additions and 0 deletions

View File

@@ -659,6 +659,10 @@ func (pp *Preprocessor) applyRules(line string) string {
// stripBlockComments removes /* ... */ comments from a line.
// If a /* is found without closing */, sets inBlock to true.
//
// `//` and `&&` line-comment markers are detected first so a `/*`
// substring inside one of them (e.g. `// see app/api/*.prg`) doesn't
// start a runaway block comment that eats subsequent lines.
func stripBlockComments(line string, inBlock *bool) string {
var out strings.Builder
i := 0
@@ -679,6 +683,20 @@ func stripBlockComments(line string, inBlock *bool) string {
i++
continue
}
// // line comment — copy the rest of the line through verbatim
// (the lexer/`#command` machinery still needs to see it) but
// don't scan it for `/*` so an embedded `/*` substring (e.g.
// `// see app/api/*.prg`) can't open a runaway block comment
// that eats subsequent lines.
if i+1 < len(line) && line[i] == '/' && line[i+1] == '/' {
out.WriteString(line[i:])
return out.String()
}
// && Harbour-style line comment — same rule.
if i+1 < len(line) && line[i] == '&' && line[i+1] == '&' {
out.WriteString(line[i:])
return out.String()
}
// Block comment start
if i+1 < len(line) && line[i] == '/' && line[i+1] == '*' {
// Find closing */

View File

@@ -262,3 +262,34 @@ func TestMissingInclude(t *testing.T) {
t.Error("code after missing include should continue")
}
}
// TestSlashStarInsideLineComment locks in that a `/*` substring inside
// a `//` line comment does NOT open a runaway block comment. Before
// the fix, a comment like "// see app/api/*.prg" would start a block
// comment from `/*.prg` that ate every subsequent line until EOF or
// the next `*/`, silently dropping FUNCTION declarations.
func TestSlashStarInsideLineComment(t *testing.T) {
p := New()
src := `// see app/api/*.prg for the glob
FUNCTION Foo()
RETURN 1
`
result, _ := p.Process("test.prg", src)
if !strings.Contains(result, "FUNCTION Foo") {
t.Errorf("FUNCTION Foo() should survive a // line comment that contains /*; got:\n%s", result)
}
}
// TestDoubleAmpInsideLineComment — same protection for Harbour's `&&`
// line comment marker.
func TestDoubleAmpInsideLineComment(t *testing.T) {
p := New()
src := `&& glob /*.prg
FUNCTION Foo()
RETURN 1
`
result, _ := p.Process("test.prg", src)
if !strings.Contains(result, "FUNCTION Foo") {
t.Errorf("FUNCTION Foo() should survive a && line comment that contains /*; got:\n%s", result)
}
}

148
hbrtl/httpx.go Normal file
View File

@@ -0,0 +1,148 @@
// httpx.go — Five-native HTTP client (FV_HTTPGET / FV_HTTPPOST).
//
// Distinct from the existing JSONHTTPGET/POST helpers in this package:
// - bytes-safe body (ZIP/binary returns survive the round trip)
// - response headers exposed as a hash
// - per-request options (custom headers, timeout, TLS legacy ciphers)
//
// The tls_legacy option re-enables the older TLS_RSA cipher suites
// that some Korean government endpoints (e.g. DART OpenAPI) still
// require. Off by default — only flip it for servers you control or
// know need it.
package hbrtl
import (
"crypto/tls"
"io"
"net/http"
"strings"
"time"
"five/hbrt"
)
// FV_HTTPGET(cUrl [, hOpts]) -> { status, body, error, headers }
func FvHttpGet(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
url := t.Local(1).AsString()
var opts hbrt.Value
if nParams >= 2 {
opts = t.Local(2)
}
t.RetVal(doHttp("GET", url, nil, opts))
}
// FV_HTTPPOST(cUrl, cBody [, hOpts]) -> { status, body, error, headers }
func FvHttpPost(t *hbrt.Thread) {
nParams := t.ParamCount()
t.Frame(nParams, 0)
defer t.EndProc()
url := t.Local(1).AsString()
body := []byte(t.Local(2).AsString())
var opts hbrt.Value
if nParams >= 3 {
opts = t.Local(3)
}
t.RetVal(doHttp("POST", url, body, opts))
}
func doHttp(method, url string, body []byte, opts hbrt.Value) hbrt.Value {
hdrs, timeout, tlsLegacy := readHttpOpts(opts)
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
if tlsLegacy {
client.Transport = legacyTlsTransport()
}
var bodyReader io.Reader
if body != nil {
bodyReader = strings.NewReader(string(body))
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return makeFvHttpResult(0, nil, nil, err.Error())
}
for k, v := range hdrs {
req.Header.Set(k, v)
}
if body != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/octet-stream")
}
resp, err := client.Do(req)
if err != nil {
return makeFvHttpResult(0, nil, nil, err.Error())
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return makeFvHttpResult(resp.StatusCode, nil, nil, err.Error())
}
return makeFvHttpResult(resp.StatusCode, respBody, resp.Header, "")
}
// readHttpOpts parses the optional options hash. Unknown keys are
// ignored so callers can pass extra metadata without us caring.
func readHttpOpts(opts hbrt.Value) (headers map[string]string, timeoutSec int, tlsLegacy bool) {
headers = map[string]string{}
timeoutSec = 30
tlsLegacy = false
if opts.IsNil() || !opts.IsHash() {
return
}
h := opts.AsHash()
if v := h.HashGet(hbrt.MakeString("timeout")); !v.IsNil() {
timeoutSec = v.AsInt()
}
if v := h.HashGet(hbrt.MakeString("tls_legacy")); !v.IsNil() {
tlsLegacy = v.AsBool()
}
if v := h.HashGet(hbrt.MakeString("headers")); !v.IsNil() && v.IsHash() {
hh := v.AsHash()
for i, k := range hh.Keys {
headers[k.AsString()] = hh.Values[i].AsString()
}
}
return
}
// legacyTlsTransport — TLS 1.2 with both modern ECDHE suites and the
// older TLS_RSA fallbacks. Required by some Korean government /
// financial endpoints (notably DART OpenAPI) that pin TLS_RSA.
func legacyTlsTransport() *http.Transport {
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
},
}
return &http.Transport{TLSClientConfig: cfg}
}
// makeFvHttpResult — { status, body, error, headers }. body is byte-safe.
func makeFvHttpResult(status int, body []byte, respHeaders http.Header, errMsg string) hbrt.Value {
hdrHash := &hbrt.HbHash{}
for k, vals := range respHeaders {
hdrHash.Append(hbrt.MakeString(strings.ToLower(k)), hbrt.MakeString(strings.Join(vals, ", ")))
}
out := &hbrt.HbHash{}
out.Append(hbrt.MakeString("status"), hbrt.MakeInt(status))
out.Append(hbrt.MakeString("body"), hbrt.MakeStringBytes(body))
out.Append(hbrt.MakeString("error"), hbrt.MakeString(errMsg))
out.Append(hbrt.MakeString("headers"), hbrt.MakeHashFrom(hdrHash))
return hbrt.MakeHashFrom(out)
}

View File

@@ -606,6 +606,14 @@ func RegisterRTL(vm *hbrt.VM) {
hbrt.Sym("JSONHTTPGET", hbrt.FsPublic, JsonHttpGet),
hbrt.Sym("JSONHTTPPOST", hbrt.FsPublic, JsonHttpPost),
// Five-native HTTP / ZIP / XML (FV_ namespace = Five additions
// distinct from Harbour-inherited HB_ surface).
hbrt.Sym("FV_HTTPGET", hbrt.FsPublic, FvHttpGet),
hbrt.Sym("FV_HTTPPOST", hbrt.FsPublic, FvHttpPost),
hbrt.Sym("FV_ZIPENTRIES", hbrt.FsPublic, FvZipEntries),
hbrt.Sym("FV_ZIPREAD", hbrt.FsPublic, FvZipRead),
hbrt.Sym("FV_XML_ROWS", hbrt.FsPublic, FvXmlRows),
// Random
hbrt.Sym("HB_RANDOM", hbrt.FsPublic, HbRandom),
hbrt.Sym("HB_RANDOMINT", hbrt.FsPublic, HbRandomInt),

91
hbrtl/xmlx.go Normal file
View File

@@ -0,0 +1,91 @@
// xmlx.go — Five-native XML repeating-record reader (FV_XML_ROWS).
//
// Designed for one extremely common shape — a parent element wrapping
// many same-tagged children whose own children are leaf text fields:
//
// <result>
// <list>
// <corp_code>00126380</corp_code>
// <corp_name>삼성전자(주)</corp_name>
// ...
// </list>
// <list>...</list>
// </result>
//
// FV_XML_ROWS(cXml, "list") returns an array where each element is
// a hash { "corp_code" => "00126380", "corp_name" => "삼성전자(주)", ... }.
//
// Streaming — never materialises the full XML tree, so the 30MB DART
// corpCode dump (118k rows) doesn't blow PRG memory.
package hbrtl
import (
"encoding/xml"
"strings"
"five/hbrt"
)
// FV_XML_ROWS(cXml, cRowTag) -> [ { field => text, ... }, ... ]
func FvXmlRows(t *hbrt.Thread) {
t.Frame(2, 0)
defer t.EndProc()
data := t.Local(1).AsString()
rowTag := t.Local(2).AsString()
dec := xml.NewDecoder(strings.NewReader(data))
rows := []hbrt.Value{}
for {
tok, err := dec.Token()
if err != nil {
break
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
if se.Name.Local != rowTag {
continue
}
// Collect the row's immediate child elements as a flat hash.
row := &hbrt.HbHash{}
curField := ""
var curText strings.Builder
depth := 0
for {
t2, err := dec.Token()
if err != nil {
break
}
switch tt := t2.(type) {
case xml.StartElement:
if depth == 0 {
curField = tt.Name.Local
curText.Reset()
}
depth++
case xml.CharData:
if depth == 1 {
curText.Write([]byte(tt))
}
case xml.EndElement:
depth--
if depth == 0 && tt.Name.Local == curField && curField != "" {
row.Append(hbrt.MakeString(curField), hbrt.MakeString(curText.String()))
curField = ""
curText.Reset()
}
if depth < 0 {
// Closing tag for the row itself.
rows = append(rows, hbrt.MakeHashFrom(row))
goto nextRow
}
}
}
nextRow:
}
t.RetVal(hbrt.MakeArrayFrom(rows))
}

71
hbrtl/zipx.go Normal file
View File

@@ -0,0 +1,71 @@
// zipx.go — Five-native ZIP container access (FV_ZIPENTRIES / FV_ZIPREAD).
//
// Reads an in-memory ZIP archive. No filesystem path — callers that
// downloaded the ZIP via FV_HTTPGET hand the raw bytes here directly,
// which is exactly the DART corpCode flow.
package hbrtl
import (
"archive/zip"
"bytes"
"io"
"five/hbrt"
)
// FV_ZIPENTRIES(cZipBytes) -> [ { name, size }, ... ]
// Returns an empty array if the input isn't a valid ZIP.
func FvZipEntries(t *hbrt.Thread) {
t.Frame(1, 0)
defer t.EndProc()
data := []byte(t.Local(1).AsString())
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.RetVal(hbrt.MakeArrayFrom(nil))
return
}
items := make([]hbrt.Value, 0, len(zr.File))
for _, f := range zr.File {
row := &hbrt.HbHash{}
row.Append(hbrt.MakeString("name"), hbrt.MakeString(f.Name))
row.Append(hbrt.MakeString("size"), hbrt.MakeInt(int(f.UncompressedSize64)))
items = append(items, hbrt.MakeHashFrom(row))
}
t.RetVal(hbrt.MakeArrayFrom(items))
}
// FV_ZIPREAD(cZipBytes, cEntryName) -> cContents
// Returns "" if the ZIP is invalid or the entry isn't present.
func FvZipRead(t *hbrt.Thread) {
t.Frame(2, 0)
defer t.EndProc()
data := []byte(t.Local(1).AsString())
name := t.Local(2).AsString()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.RetString("")
return
}
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.RetString("")
return
}
buf, err := io.ReadAll(rc)
rc.Close()
if err != nil {
t.RetString("")
return
}
t.RetVal(hbrt.MakeStringBytes(buf))
return
}
t.RetString("")
}