This commit is contained in:
2026-01-27 10:23:07 -05:00
parent a275364cd1
commit 60fb12e52c
10 changed files with 302 additions and 238 deletions

367
internal/builtin/builtin.go Normal file
View File

@@ -0,0 +1,367 @@
package builtin
import (
"fmt"
"io"
"net/http"
"reflect"
"strings"
"sync"
"github.com/dop251/goja"
)
type Builtin struct {
Name string
Function any
Definition string
}
var (
builtinRegistry = make(map[string]Builtin)
registryMutex sync.RWMutex
)
func RegisterBuiltin(name string, fn any) {
fnValue := reflect.ValueOf(fn)
fnType := fnValue.Type()
wrapper := createGenericWrapper(fnValue, fnType)
definition := generateTypeScriptDefinition(name, fnType)
builtinRegistry[name] = Builtin{
Name: name,
Function: wrapper,
Definition: definition,
}
}
func createGenericWrapper(fnValue reflect.Value, fnType reflect.Type) any {
return func(vm *goja.Runtime) any {
return func(call goja.FunctionCall) goja.Value {
args := make([]reflect.Value, fnType.NumIn())
for i := 0; i < fnType.NumIn(); i++ {
argType := fnType.In(i)
var jsArg goja.Value
if i < len(call.Arguments) {
jsArg = call.Arguments[i]
} else {
jsArg = goja.Undefined()
}
if goja.IsUndefined(jsArg) || goja.IsNull(jsArg) {
if argType.Kind() == reflect.Map {
args[i] = reflect.MakeMap(argType)
continue
}
if argType.Kind() == reflect.Interface {
args[i] = reflect.Zero(argType)
continue
}
}
converted, err := convertJSValueToGo(vm, jsArg, argType)
if err != nil {
panic(fmt.Sprintf("argument %d: %v", i, err))
}
args[i] = reflect.ValueOf(converted)
}
results := fnValue.Call(args)
if len(results) == 0 {
return goja.Undefined()
}
lastResult := results[len(results)-1]
if lastResult.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if !lastResult.IsNil() {
panic(fmt.Sprintf("error: %v", lastResult.Interface()))
}
if len(results) == 1 {
return goja.Undefined()
}
return convertGoValueToJS(vm, results[0])
}
return convertGoValueToJS(vm, results[0])
}
}
}
func convertJSValueToGo(vm *goja.Runtime, jsValue goja.Value, targetType reflect.Type) (any, error) {
if goja.IsUndefined(jsValue) || goja.IsNull(jsValue) {
if targetType.Kind() == reflect.Interface || targetType.Kind() == reflect.Pointer {
return nil, nil
}
return nil, fmt.Errorf("cannot convert null/undefined to %v", targetType)
}
switch targetType.Kind() {
case reflect.String:
return jsValue.String(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, ok := jsValue.Export().(int64)
if !ok {
return nil, fmt.Errorf("expected int, got %T", jsValue.Export())
}
return reflect.ValueOf(n).Convert(targetType).Interface(), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, ok := jsValue.Export().(int64)
if !ok {
return nil, fmt.Errorf("expected uint, got %T", jsValue.Export())
}
return reflect.ValueOf(uint(n)).Convert(targetType).Interface(), nil
case reflect.Float32, reflect.Float64:
n, ok := jsValue.Export().(float64)
if !ok {
return nil, fmt.Errorf("expected float, got %T", jsValue.Export())
}
return reflect.ValueOf(n).Convert(targetType).Interface(), nil
case reflect.Bool:
return jsValue.ToBoolean(), nil
case reflect.Interface:
return jsValue.Export(), nil
case reflect.Map:
if targetType.Key().Kind() == reflect.String && targetType.Elem().Kind() == reflect.Interface {
obj := jsValue.ToObject(vm)
if obj == nil {
return nil, fmt.Errorf("not an object")
}
result := make(map[string]any)
for _, key := range obj.Keys() {
result[key] = obj.Get(key).Export()
}
return result, nil
}
return nil, fmt.Errorf("unsupported map type: %v", targetType)
default:
return nil, fmt.Errorf("unsupported type: %v", targetType)
}
}
func convertGoValueToJS(vm *goja.Runtime, goValue reflect.Value) goja.Value {
value := goValue.Interface()
switch v := value.(type) {
case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool:
return vm.ToValue(v)
case error:
return vm.ToValue(v.Error())
case map[string]string:
obj := vm.NewObject()
for key, val := range v {
_ = obj.Set(key, val)
}
return obj
case map[string]any:
obj := vm.NewObject()
for key, val := range v {
_ = obj.Set(key, val)
}
return obj
case []any:
arr := make([]goja.Value, len(v))
for i, item := range v {
arr[i] = convertGoValueToJS(vm, reflect.ValueOf(item))
}
return vm.ToValue(arr)
case FetchResult:
obj := vm.NewObject()
_ = obj.Set("ok", v.OK)
_ = obj.Set("status", v.Status)
_ = obj.Set("text", func() string {
return v.Body
})
headersObj := vm.NewObject()
headers := v.Headers
_ = headersObj.Set("get", func(c goja.FunctionCall) goja.Value {
if len(c.Arguments) < 1 {
return goja.Undefined()
}
key := c.Arguments[0].String()
return vm.ToValue(headers[key])
})
_ = obj.Set("headers", headersObj)
return obj
case *FetchResult:
if v == nil {
return goja.Null()
}
return convertGoValueToJS(vm, reflect.ValueOf(*v))
default:
return vm.ToValue(v)
}
}
func generateTypeScriptDefinition(name string, fnType reflect.Type) string {
if fnType.Kind() != reflect.Func {
return ""
}
var params []string
for i := 0; i < fnType.NumIn(); i++ {
paramName := fmt.Sprintf("arg%d", i)
if fnType.In(i).Kind() == reflect.Pointer {
ptrType := fnType.In(i).Elem()
if ptrType.Kind() == reflect.Struct {
if s, ok := extractStructParamName(ptrType); ok {
paramName = s
}
}
}
params = append(params, fmt.Sprintf("%s: %s", paramName, goTypeToTSType(fnType.In(i))))
}
returnSignature := "void"
if fnType.NumOut() > 0 {
lastIndex := fnType.NumOut() - 1
lastType := fnType.Out(lastIndex)
if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) {
if fnType.NumOut() > 1 {
returnSignature = goTypeToTSType(fnType.Out(0))
} else {
returnSignature = "void"
}
} else {
returnSignature = goTypeToTSType(lastType)
}
}
return fmt.Sprintf("declare function %s(%s): %s;", name, strings.Join(params, ", "), returnSignature)
}
func extractStructParamName(structType reflect.Type) (string, bool) {
if structType.Name() != "" {
return strings.ToLower(structType.Name()), true
}
return "", false
}
func goTypeToTSType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "number"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "number"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Interface, reflect.Pointer:
return "any"
case reflect.Slice:
return fmt.Sprintf("%s[]", goTypeToTSType(t.Elem()))
case reflect.Map:
if t.Key().Kind() == reflect.String && t.Elem().Kind() == reflect.Interface {
return "Record<string, any>"
}
return "Record<string, any>"
case reflect.Struct:
if t.Name() == "FetchResult" {
return "Response"
}
return "any"
default:
return "any"
}
}
func GetBuiltinsDeclarations() string {
registryMutex.RLock()
defer registryMutex.RUnlock()
var decls []string
for _, builtin := range builtinRegistry {
decls = append(decls, builtin.Definition)
}
return strings.Join(decls, "\n")
}
func RegisterBuiltins(vm *goja.Runtime) {
registryMutex.RLock()
defer registryMutex.RUnlock()
for name, builtin := range builtinRegistry {
if wrapperFactory, ok := builtin.Function.(func(*goja.Runtime) any); ok {
_ = vm.Set(name, wrapperFactory(vm))
} else {
_ = vm.Set(name, builtin.Function)
}
}
}
type FetchResult struct {
OK bool
Status int
Body string
Headers map[string]string
}
func Fetch(url string, options map[string]any) (*FetchResult, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
headers := make(map[string]string)
for key, values := range resp.Header {
if len(values) > 0 {
val := values[0]
headers[key] = val
headers[strings.ToLower(key)] = val
}
}
return &FetchResult{
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
Status: resp.StatusCode,
Body: string(body),
Headers: headers,
}, nil
}
func init() {
RegisterBuiltin("fetch", Fetch)
RegisterBuiltin("add", func(a, b int) int {
return a + b
})
RegisterBuiltin("greet", func(name string) string {
return fmt.Sprintf("Hello, %s!", name)
})
}

View File

@@ -0,0 +1,54 @@
package builtin
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFetch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom-Header", "test-value")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","message":"Hello from httptest"}`))
}))
defer server.Close()
result, err := Fetch(server.URL, nil)
require.NoError(t, err)
assert.True(t, result.OK)
assert.Equal(t, http.StatusOK, result.Status)
assert.Contains(t, result.Body, "Hello from httptest")
assert.Contains(t, result.Body, `"status":"ok"`)
assert.Equal(t, "application/json", result.Headers["Content-Type"])
assert.Equal(t, "test-value", result.Headers["X-Custom-Header"])
}
func TestFetchHTTPBin(t *testing.T) {
result, err := Fetch("https://httpbin.org/get", nil)
require.NoError(t, err)
assert.True(t, result.OK)
assert.Equal(t, http.StatusOK, result.Status)
assert.Contains(t, result.Body, `"args"`)
assert.Equal(t, "application/json", result.Headers["Content-Type"])
}
func TestFetchWith404(t *testing.T) {
result, err := Fetch("https://httpbin.org/status/404", nil)
require.NoError(t, err)
assert.False(t, result.OK)
assert.Equal(t, http.StatusNotFound, result.Status)
}
func TestFetchWithInvalidURL(t *testing.T) {
_, err := Fetch("http://this-domain-does-not-exist-12345.com", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to fetch")
}

137
internal/runtime/runtime.go Normal file
View File

@@ -0,0 +1,137 @@
package runtime
import (
"fmt"
"io"
"os"
"github.com/dop251/goja"
"github.com/evanw/esbuild/pkg/api"
"reichard.io/poiesis/internal/builtin"
)
type Runtime struct {
vm *goja.Runtime
stdout io.Writer
stderr io.Writer
}
func New() *Runtime {
vm := goja.New()
r := &Runtime{vm: vm, stdout: os.Stdout, stderr: os.Stderr}
r.setupConsole()
builtin.RegisterBuiltins(vm)
return r
}
func (r *Runtime) setupConsole() {
console := r.vm.NewObject()
_ = r.vm.Set("console", console)
_ = console.Set("log", func(call goja.FunctionCall) goja.Value {
args := call.Arguments
for i, arg := range args {
if i > 0 {
_, _ = fmt.Fprint(r.stdout, " ")
}
_, _ = fmt.Fprint(r.stdout, arg.String())
}
_, _ = fmt.Fprintln(r.stdout)
return goja.Undefined()
})
}
func (r *Runtime) SetOutput(stdout, stderr io.Writer) {
r.stdout = stdout
r.stderr = stderr
consoleObj := r.vm.Get("console")
if consoleObj != nil {
console := consoleObj.ToObject(r.vm)
if console != nil {
r.setupConsole()
}
}
}
func (r *Runtime) RunFile(filePath string, stdout, stderr io.Writer) error {
r.stdout = stdout
r.stderr = stderr
r.setupConsole()
content, err := r.transformFile(filePath)
if err != nil {
_, _ = fmt.Fprintf(stderr, "Error: %v\n", err)
return err
}
if len(content.errors) > 0 {
_, _ = fmt.Fprintf(stderr, "Transpilation errors:\n")
for _, err := range content.errors {
_, _ = fmt.Fprintf(stderr, " %s\n", err.Text)
}
return fmt.Errorf("transpilation failed")
}
_, err = r.vm.RunString(content.code)
if err != nil {
_, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err)
return err
}
return nil
}
func (r *Runtime) RunCode(tsCode string, stdout, stderr io.Writer) error {
r.stdout = stdout
r.stderr = stderr
r.setupConsole()
content := r.transformCode(tsCode)
if len(content.errors) > 0 {
_, _ = fmt.Fprintf(stderr, "Transpilation errors:\n")
for _, err := range content.errors {
_, _ = fmt.Fprintf(stderr, " %s\n", err.Text)
}
return fmt.Errorf("transpilation failed")
}
_, err := r.vm.RunString(content.code)
if err != nil {
_, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err)
return err
}
return nil
}
type transformResult struct {
code string
errors []api.Message
}
func (r *Runtime) transformFile(filePath string) (*transformResult, error) {
tsFileContent, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
return r.transformCode(string(tsFileContent)), nil
}
func (r *Runtime) transformCode(tsCode string) *transformResult {
result := api.Transform(tsCode, api.TransformOptions{
Loader: api.LoaderTS,
Target: api.ES2020,
Format: api.FormatIIFE,
Sourcemap: api.SourceMapNone,
TreeShaking: api.TreeShakingFalse,
})
return &transformResult{
code: string(result.Code),
errors: result.Errors,
}
}

View File

@@ -0,0 +1,44 @@
package runtime
import (
"bytes"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExecuteTypeScript(t *testing.T) {
var stdout, stderr bytes.Buffer
rt := New()
err := rt.RunFile("../../test_data/test.ts", &stdout, &stderr)
assert.NoError(t, err, "Expected no error")
assert.Empty(t, stderr.String(), "Expected no error output")
output := stdout.String()
assert.Contains(t, output, "Hello, Alice!", "Should greet Alice")
assert.Contains(t, output, "You are 30 years old", "Should show age")
assert.Contains(t, output, "Email: alice@example.com", "Should show email")
assert.Contains(t, output, "Sum of 5 and 10 is: 15", "Should calculate sum correctly")
lines := strings.Split(strings.TrimSpace(output), "\n")
assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 output lines")
}
func TestFetchBuiltinIntegration(t *testing.T) {
rt := New()
tsContent := `
const result = add(5, 10);
console.log("Result:", result);
`
var stdout, stderr bytes.Buffer
err := rt.RunCode(tsContent, &stdout, &stderr)
require.NoError(t, err)
assert.Contains(t, stdout.String(), "Result: 15")
}