Skip to content

Commit 1a85b83

Browse files
committed
Achieve full feature parity across JS and Go implementations
JavaScript: - execute() now routes to HTTP (sync via curl) and MCP backends - Output parsers: json, jsonl, csv (auto-delimiter, type inference), xml (attributes, text, arrays), text - executeHttpSync() with curl for synchronous HTTP execution Go: - Output parsers: jsonl, csv (auto-delimiter, quoted fields, type inference), xml (stack-based parser with attributes and arrays) - Replaces previous raw_output fallback for csv/jsonl/xml formats All 4 languages now have full feature parity.
1 parent 39a1814 commit 1a85b83

2 files changed

Lines changed: 562 additions & 8 deletions

File tree

go/pkg/executor/executor.go

Lines changed: 241 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"os"
1313
"os/exec"
1414
"regexp"
15+
"strconv"
1516
"strings"
1617
"syscall"
1718
"time"
@@ -255,16 +256,249 @@ func parseOutput(format string, data []byte) (map[string]any, error) {
255256
return nil, fmt.Errorf("parsing JSON output: %w", err)
256257
}
257258
return result, nil
259+
260+
case "jsonl":
261+
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
262+
var parsed []any
263+
for _, line := range lines {
264+
line = strings.TrimSpace(line)
265+
if line == "" {
266+
continue
267+
}
268+
var obj any
269+
if err := json.Unmarshal([]byte(line), &obj); err != nil {
270+
return nil, fmt.Errorf("parsing JSONL line: %w", err)
271+
}
272+
parsed = append(parsed, obj)
273+
}
274+
return map[string]any{"parsed_output": parsed}, nil
275+
276+
case "csv":
277+
return parseCsvOutput(data), nil
278+
279+
case "xml":
280+
return parseXmlOutput(data), nil
281+
258282
case "text", "":
259-
return map[string]any{
260-
"raw_output": string(data),
261-
}, nil
283+
return map[string]any{"raw_output": string(data)}, nil
284+
262285
default:
263-
// For xml, csv, jsonl -- fall back to raw text in the reference impl.
264-
return map[string]any{
265-
"raw_output": string(data),
266-
}, nil
286+
return map[string]any{"raw_output": string(data)}, nil
287+
}
288+
}
289+
290+
// parseCsvOutput parses CSV data with auto-delimiter detection and type inference.
291+
func parseCsvOutput(data []byte) map[string]any {
292+
raw := strings.TrimSpace(string(data))
293+
if raw == "" {
294+
return map[string]any{"parsed_output": []any{}}
295+
}
296+
297+
lines := strings.Split(raw, "\n")
298+
if len(lines) == 0 {
299+
return map[string]any{"parsed_output": []any{}}
300+
}
301+
302+
// Auto-detect delimiter
303+
firstLine := lines[0]
304+
delimiter := ','
305+
if strings.Contains(firstLine, "\t") {
306+
delimiter = '\t'
307+
} else if strings.Contains(firstLine, "|") && !strings.Contains(firstLine, ",") {
308+
delimiter = '|'
309+
}
310+
311+
headers := splitCsvLine(firstLine, delimiter)
312+
for i := range headers {
313+
headers[i] = strings.TrimSpace(headers[i])
314+
}
315+
316+
var rows []any
317+
for _, line := range lines[1:] {
318+
line = strings.TrimSpace(line)
319+
if line == "" {
320+
continue
321+
}
322+
fields := splitCsvLine(line, delimiter)
323+
row := make(map[string]any)
324+
for i, h := range headers {
325+
val := ""
326+
if i < len(fields) {
327+
val = strings.TrimSpace(fields[i])
328+
}
329+
// Type inference
330+
if strings.ToLower(val) == "true" || strings.ToLower(val) == "false" {
331+
row[h] = strings.ToLower(val) == "true"
332+
} else if n, err := strconv.Atoi(val); err == nil {
333+
row[h] = n
334+
} else if f, err := strconv.ParseFloat(val, 64); err == nil {
335+
row[h] = f
336+
} else {
337+
row[h] = val
338+
}
339+
}
340+
rows = append(rows, row)
341+
}
342+
343+
return map[string]any{"parsed_output": rows}
344+
}
345+
346+
// splitCsvLine splits a CSV line respecting quoted fields and escaped quotes.
347+
func splitCsvLine(line string, delimiter rune) []string {
348+
var fields []string
349+
var current strings.Builder
350+
inQuotes := false
351+
runes := []rune(line)
352+
353+
for i := 0; i < len(runes); i++ {
354+
c := runes[i]
355+
if c == '"' {
356+
if inQuotes && i+1 < len(runes) && runes[i+1] == '"' {
357+
current.WriteRune('"')
358+
i++ // skip escaped quote
359+
} else {
360+
inQuotes = !inQuotes
361+
}
362+
} else if c == delimiter && !inQuotes {
363+
fields = append(fields, current.String())
364+
current.Reset()
365+
} else {
366+
current.WriteRune(c)
367+
}
368+
}
369+
fields = append(fields, current.String())
370+
return fields
371+
}
372+
373+
// parseXmlOutput parses XML data into a nested map structure.
374+
func parseXmlOutput(data []byte) map[string]any {
375+
raw := strings.TrimSpace(string(data))
376+
if raw == "" {
377+
return map[string]any{"raw_output": ""}
378+
}
379+
380+
// Strip XML declaration
381+
if strings.HasPrefix(raw, "<?xml") {
382+
if idx := strings.Index(raw, "?>"); idx != -1 {
383+
raw = strings.TrimSpace(raw[idx+2:])
384+
}
385+
}
386+
387+
type stackEntry struct {
388+
name string
389+
obj map[string]any
390+
}
391+
392+
var stack []stackEntry
393+
currentName := ""
394+
currentObj := make(map[string]any)
395+
var textBuf strings.Builder
396+
pos := 0
397+
398+
attrRe := regexp.MustCompile(`(\w+)\s*=\s*["']([^"']*)["']`)
399+
400+
for pos < len(raw) {
401+
if raw[pos] == '<' {
402+
// Flush text
403+
text := strings.TrimSpace(textBuf.String())
404+
if text != "" && currentName != "" {
405+
currentObj["#text"] = text
406+
}
407+
textBuf.Reset()
408+
pos++
409+
if pos >= len(raw) {
410+
break
411+
}
412+
413+
if raw[pos] == '/' {
414+
// Closing tag
415+
pos++
416+
tagEnd := strings.Index(raw[pos:], ">")
417+
if tagEnd == -1 {
418+
break
419+
}
420+
pos += tagEnd + 1
421+
422+
finishedObj := currentObj
423+
if len(stack) > 0 {
424+
parent := stack[len(stack)-1]
425+
stack = stack[:len(stack)-1]
426+
427+
if existing, ok := parent.obj[currentName]; ok {
428+
if arr, ok := existing.([]any); ok {
429+
parent.obj[currentName] = append(arr, finishedObj)
430+
} else {
431+
parent.obj[currentName] = []any{existing, finishedObj}
432+
}
433+
} else {
434+
parent.obj[currentName] = finishedObj
435+
}
436+
currentName = parent.name
437+
currentObj = parent.obj
438+
}
439+
} else if raw[pos] == '!' || raw[pos] == '?' {
440+
// Comment or PI — skip
441+
tagEnd := strings.Index(raw[pos:], ">")
442+
if tagEnd == -1 {
443+
break
444+
}
445+
pos += tagEnd + 1
446+
} else {
447+
// Opening tag
448+
tagEnd := strings.Index(raw[pos:], ">")
449+
if tagEnd == -1 {
450+
break
451+
}
452+
tagContent := raw[pos : pos+tagEnd]
453+
selfClosing := strings.HasSuffix(tagContent, "/")
454+
if selfClosing {
455+
tagContent = tagContent[:len(tagContent)-1]
456+
}
457+
458+
parts := strings.Fields(tagContent)
459+
tagName := ""
460+
if len(parts) > 0 {
461+
tagName = parts[0]
462+
}
463+
464+
attrs := make(map[string]any)
465+
attrStr := ""
466+
if len(tagContent) > len(tagName) {
467+
attrStr = tagContent[len(tagName):]
468+
}
469+
for _, match := range attrRe.FindAllStringSubmatch(attrStr, -1) {
470+
attrs["@"+match[1]] = match[2]
471+
}
472+
473+
if selfClosing {
474+
if existing, ok := currentObj[tagName]; ok {
475+
if arr, ok := existing.([]any); ok {
476+
currentObj[tagName] = append(arr, attrs)
477+
} else {
478+
currentObj[tagName] = []any{existing, attrs}
479+
}
480+
} else {
481+
currentObj[tagName] = attrs
482+
}
483+
} else {
484+
stack = append(stack, stackEntry{name: currentName, obj: currentObj})
485+
currentName = tagName
486+
currentObj = attrs
487+
}
488+
pos += tagEnd + 1
489+
}
490+
} else {
491+
textBuf.WriteByte(raw[pos])
492+
pos++
493+
}
494+
}
495+
496+
if currentName != "" {
497+
result := make(map[string]any)
498+
result[currentName] = currentObj
499+
return result
267500
}
501+
return currentObj
268502
}
269503

270504
// secretPattern matches {_secret:name} placeholders in templates.

0 commit comments

Comments
 (0)