diff --git a/AGENTS.md b/AGENTS.md index c6c2517..d7dfd25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,14 +25,17 @@ reichard.io/poiesis/ ├── internal/ │ ├── runtime/ # Runtime management, transpilation, execution │ │ ├── runtime.go -│ │ └── runtime_test.go +│ │ ├── runtime_test.go +│ │ └── options.go │ ├── functions/ # Function registration framework -│ │ ├── collector.go │ │ ├── registry.go │ │ ├── types.go -│ │ ├── typescript.go │ │ ├── typescript_test.go │ │ └── functions_test.go +│ ├── tsconvert/ # Go-to-TypeScript type conversion utilities +│ │ ├── convert.go +│ │ ├── types.go +│ │ └── convert_test.go │ └── stdlib/ # Standard library implementations │ ├── fetch.go │ └── fetch_test.go @@ -40,8 +43,9 @@ reichard.io/poiesis/ ## Key Packages -- `reichard.io/poiesis/internal/runtime` - Runtime management, TypeScript transpilation, JavaScript execution -- `reichard.io/poiesis/internal/functions` - Generic function registration framework (sync/async wrappers, automatic JS/Go conversion via JSON, type definition generation) +- `reichard.io/poiesis/internal/runtime` - Runtime management, TypeScript transpilation, JavaScript execution, per-runtime type management +- `reichard.io/poiesis/internal/functions` - Generic function registration framework (sync/async wrappers, automatic JS/Go conversion via JSON) +- `reichard.io/poiesis/internal/tsconvert` - Go-to-TypeScript type conversion utilities and type declaration generation - `reichard.io/poiesis/internal/stdlib` - Standard library implementations (fetch) ## Function System @@ -49,6 +53,7 @@ reichard.io/poiesis/ ### Registration Two types of functions: + - **Sync**: `RegisterFunction[T, R](name, fn)` - executes synchronously, returns value - **Async**: `RegisterAsyncFunction[T, R](name, fn)` - runs in goroutine, returns Promise @@ -108,3 +113,26 @@ functions.RegisterAsyncFunction[FetchArgs, *FetchResult]("fetch", Fetch) - Use `os` package instead of deprecated `io/ioutil` - Error logging uses `_, _ = fmt.Fprintf(stderr, ...)` pattern - Package structure follows standard Go project layout with internal packages + +### Comment Style + +Code blocks (even within functions) should be separated with title-cased comments describing what the block does: + +```go +// Create Runtime +r := &Runtime{opts: qjs.Option{Context: ctx}} + +// Create QuickJS Context +rt, err := qjs.New(r.opts) + +// Populate Globals +if err := r.populateGlobals(); err != nil { + return nil, err +} +``` + +For more complex blocks, use a hyphen to add elaboration: + +```go +// Does Thing - We do this here because we need to do xyz... +``` diff --git a/cmd/poiesis/main.go b/cmd/poiesis/main.go index 4ee3ccc..a4adca0 100644 --- a/cmd/poiesis/main.go +++ b/cmd/poiesis/main.go @@ -5,11 +5,11 @@ import ( "fmt" "os" - "reichard.io/poiesis/internal/functions" "reichard.io/poiesis/internal/runtime" ) func main() { + // Validate Arguments if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "Usage: poiesis ") fmt.Fprintln(os.Stderr, " poiesis -print-types") @@ -18,7 +18,11 @@ func main() { // Print Types if os.Args[1] == "-print-types" { - fmt.Println(functions.GetFunctionDeclarations()) + rt, err := runtime.New(context.Background()) + if err != nil { + panic(err) + } + fmt.Println(rt.GetTypeDeclarations()) return } diff --git a/internal/functions/collector.go b/internal/functions/collector.go deleted file mode 100644 index 5352698..0000000 --- a/internal/functions/collector.go +++ /dev/null @@ -1,170 +0,0 @@ -package functions - -import ( - "fmt" - "reflect" - "strings" - "sync" -) - -type typeCollector struct { - mu sync.Mutex - types map[string]string - paramTypes map[string]bool -} - -func newTypeCollector() *typeCollector { - return &typeCollector{ - types: make(map[string]string), - paramTypes: make(map[string]bool), - } -} - -func (tc *typeCollector) collectTypes(argsType reflect.Type, fnType reflect.Type) []string { - tc.mu.Lock() - defer tc.mu.Unlock() - tc.types = make(map[string]string) - tc.paramTypes = make(map[string]bool) - - var result []string - - tc.collectStruct(argsType, argsType.Name()) - - for i := 0; i < argsType.NumField(); i++ { - field := argsType.Field(i) - if field.Type.Kind() == reflect.Pointer || strings.Contains(field.Tag.Get("json"), ",omitempty") { - tc.collectParamType(field.Type) - } - } - - if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { - lastIndex := fnType.NumOut() - 1 - lastType := fnType.Out(lastIndex) - - if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if fnType.NumOut() > 1 { - tc.collectType(fnType.Out(0)) - } - } else { - tc.collectType(lastType) - } - } - - for _, t := range tc.types { - result = append(result, t) - } - - return result -} - -func (tc *typeCollector) collectParamType(t reflect.Type) { - if t.Kind() == reflect.Pointer { - tc.collectParamType(t.Elem()) - return - } - - if t.Kind() == reflect.Struct && t.Name() != "" { - tc.paramTypes[t.Name()+" | null"] = true - } -} - -func (tc *typeCollector) getParamTypes() map[string]bool { - return tc.paramTypes -} - -func (tc *typeCollector) collectType(t reflect.Type) { - if t.Kind() == reflect.Pointer { - tc.collectType(t.Elem()) - return - } - - if t.Kind() == reflect.Struct { - name := t.Name() - if _, exists := tc.types[name]; !exists { - tc.collectStruct(t, name) - } - } -} - -func (tc *typeCollector) collectStruct(t reflect.Type, name string) { - if t.Kind() != reflect.Struct { - return - } - - var fields []string - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - - if field.Anonymous || !field.IsExported() { - continue - } - - fieldName := getFieldName(field) - - var tsType string - var isOptional bool - isPointer := field.Type.Kind() == reflect.Pointer - - if isPointer { - isOptional = true - tsType = goTypeToTSType(field.Type, false) - } else { - isOptional = strings.Contains(field.Tag.Get("json"), ",omitempty") - tsType = goTypeToTSType(field.Type, false) - } - - if isOptional { - fieldName += "?" - } - - fields = append(fields, fmt.Sprintf("%s: %s", fieldName, tsType)) - - tc.collectType(field.Type) - } - - tc.types[name] = fmt.Sprintf("interface %s {%s}", name, strings.Join(fields, "; ")) -} - -func goTypeToTSType(t reflect.Type, isPointer bool) string { - if t.Kind() == reflect.Pointer { - return goTypeToTSType(t.Elem(), true) - } - - baseType := "" - switch t.Kind() { - case reflect.String: - baseType = "string" - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - baseType = "number" - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - baseType = "number" - case reflect.Float32, reflect.Float64: - baseType = "number" - case reflect.Bool: - baseType = "boolean" - case reflect.Interface: - baseType = "any" - case reflect.Slice: - baseType = fmt.Sprintf("%s[]", goTypeToTSType(t.Elem(), false)) - case reflect.Map: - if t.Key().Kind() == reflect.String && t.Elem().Kind() == reflect.Interface { - baseType = "Record" - } else { - baseType = "Record" - } - case reflect.Struct: - name := t.Name() - if name == "" { - baseType = "{}" - } else { - baseType = name - } - default: - baseType = "any" - } - - if isPointer { - baseType += " | null" - } - return baseType -} diff --git a/internal/functions/registry.go b/internal/functions/registry.go index 02f005a..21c77e4 100644 --- a/internal/functions/registry.go +++ b/internal/functions/registry.go @@ -5,44 +5,47 @@ import ( "reflect" "strings" "sync" + + "reichard.io/poiesis/internal/tsconvert" ) var ( functionRegistry = make(map[string]Function) registryMutex sync.RWMutex - collector *typeCollector ) func registerFunction[A Args, R any](name string, isAsync bool, fn GoFunc[A, R]) { + // Lock Registry registryMutex.Lock() defer registryMutex.Unlock() - if collector == nil { - collector = newTypeCollector() - } - + // Validate Args Type tType := reflect.TypeFor[A]() if tType.Kind() != reflect.Struct { panic(fmt.Sprintf("function %s: argument must be a struct type, got %v", name, tType)) } + // Collect Types and Generate Definition fnType := reflect.TypeOf(fn) - types := collector.collectTypes(tType, fnType) - paramTypes := collector.getParamTypes() + types := tsconvert.CollectTypes(tType, fnType) + definition := tsconvert.GenerateFunctionDecl(name, tType, fnType, isAsync) + // Register Function functionRegistry[name] = &functionImpl[A, R]{ name: name, fn: fn, - types: types, - definition: generateTypeScriptDefinition(name, tType, fnType, isAsync, paramTypes), + types: types.All(), + definition: definition, isAsync: isAsync, } } func GetFunctionDeclarations() string { + // Lock Registry registryMutex.RLock() defer registryMutex.RUnlock() + // Collect Type Definitions typeDefinitions := make(map[string]bool) var typeDefs []string var functionDecls []string @@ -57,6 +60,7 @@ func GetFunctionDeclarations() string { functionDecls = append(functionDecls, fn.Definition()) } + // Build Result result := strings.Join(typeDefs, "\n\n") if len(result) > 0 && len(functionDecls) > 0 { result += "\n\n" @@ -66,10 +70,33 @@ func GetFunctionDeclarations() string { return result } -func GetRegisteredFunctions() map[string]Function { +// GetTypeDeclarations returns all type declarations from all registered functions. +// This is used for aggregating types across multiple functions. +func GetTypeDeclarations() map[string]string { + // Lock Registry registryMutex.RLock() defer registryMutex.RUnlock() + // Collect All Types + allTypes := make(map[string]string) + for _, fn := range functionRegistry { + for name, def := range fn.Types() { + if existing, ok := allTypes[name]; ok && existing != def { + // Type Conflict Detected - Skip + continue + } + allTypes[name] = def + } + } + return allTypes +} + +func GetRegisteredFunctions() map[string]Function { + // Lock Registry + registryMutex.RLock() + defer registryMutex.RUnlock() + + // Copy Registry result := make(map[string]Function, len(functionRegistry)) for k, v := range functionRegistry { result[k] = v @@ -78,9 +105,11 @@ func GetRegisteredFunctions() map[string]Function { } func RegisterFunction[T Args, R any](name string, fn GoFunc[T, R]) { + // Register Sync Function registerFunction(name, false, fn) } func RegisterAsyncFunction[T Args, R any](name string, fn GoFunc[T, R]) { + // Register Async Function registerFunction(name, true, fn) } diff --git a/internal/functions/types.go b/internal/functions/types.go index 0f54d9f..040c295 100644 --- a/internal/functions/types.go +++ b/internal/functions/types.go @@ -8,7 +8,7 @@ import ( type Function interface { Name() string - Types() []string + Types() map[string]string Definition() string IsAsync() bool Arguments() []reflect.Type @@ -25,7 +25,7 @@ type functionImpl[A Args, R any] struct { name string fn GoFunc[A, R] definition string - types []string + types map[string]string isAsync bool } @@ -33,7 +33,7 @@ func (b *functionImpl[A, R]) Name() string { return b.name } -func (b *functionImpl[A, R]) Types() []string { +func (b *functionImpl[A, R]) Types() map[string]string { return b.types } @@ -50,6 +50,7 @@ func (b *functionImpl[A, R]) Function() any { } func (b *functionImpl[A, R]) Arguments() []reflect.Type { + // Collect Argument Types var allTypes []reflect.Type rType := reflect.TypeFor[A]() @@ -73,17 +74,19 @@ func (b *functionImpl[A, R]) CallGeneric(ctx context.Context, allArgs []any) (ze for i := range min(aVal.NumField(), len(allArgs)) { field := aVal.Field(i) + // Validate Field is Settable if !field.CanSet() { return zeroR, errors.New("cannot set field") } + // Validate and Set Field Value argVal := reflect.ValueOf(allArgs[i]) if !argVal.Type().AssignableTo(field.Type()) { return zeroR, errors.New("cannot assign field") } - field.Set(argVal) } + // Execute Function return b.fn(ctx, fnArgs) } diff --git a/internal/functions/typescript.go b/internal/functions/typescript.go deleted file mode 100644 index 966677a..0000000 --- a/internal/functions/typescript.go +++ /dev/null @@ -1,73 +0,0 @@ -package functions - -import ( - "fmt" - "reflect" - "strings" -) - -func getFieldName(field reflect.StructField) string { - jsonTag := field.Tag.Get("json") - if jsonTag != "" && jsonTag != "-" { - name, _, _ := strings.Cut(jsonTag, ",") - return name - } - return field.Name -} - -func generateTypeScriptDefinition(name string, argsType reflect.Type, fnType reflect.Type, isPromise bool, paramTypes map[string]bool) string { - if argsType.Kind() != reflect.Struct { - return "" - } - - var params []string - for i := 0; i < argsType.NumField(); i++ { - field := argsType.Field(i) - fieldName := getFieldName(field) - goType := field.Type - - var tsType string - var isOptional bool - isPointer := goType.Kind() == reflect.Pointer - - if isPointer { - isOptional = true - tsType = goTypeToTSType(goType, true) - if !strings.Contains(tsType, " | null") { - tsType += " | null" - } - } else { - isOptional = strings.Contains(field.Tag.Get("json"), ",omitempty") - tsType = goTypeToTSType(goType, false) - if isOptional && paramTypes[tsType+" | null"] { - tsType += " | null" - } - } - - if isOptional { - fieldName += "?" - } - params = append(params, fmt.Sprintf("%s: %s", fieldName, tsType)) - } - - returnSignature := "any" - if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { - lastIndex := fnType.NumOut() - 1 - lastType := fnType.Out(lastIndex) - - if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if fnType.NumOut() > 1 { - returnType := fnType.Out(0) - returnSignature = goTypeToTSType(returnType, returnType.Kind() == reflect.Pointer) - } - } else { - returnSignature = goTypeToTSType(lastType, lastType.Kind() == reflect.Pointer) - } - } - - if isPromise { - returnSignature = fmt.Sprintf("Promise<%s>", returnSignature) - } - - return fmt.Sprintf("declare function %s(%s): %s;", name, strings.Join(params, ", "), returnSignature) -} diff --git a/internal/functions/typescript_test.go b/internal/functions/typescript_test.go index ae9eb2b..491e47c 100644 --- a/internal/functions/typescript_test.go +++ b/internal/functions/typescript_test.go @@ -2,7 +2,6 @@ package functions import ( "context" - "reflect" "sync" "testing" @@ -17,19 +16,26 @@ type TestBasicArgs struct { func (t TestBasicArgs) Validate() error { return nil } func TestBasicType(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction("basic", func(ctx context.Context, args TestBasicArgs) (string, error) { return args.Name, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function basic(name: string, age: number): string;") assert.Contains(t, defs, "interface TestBasicArgs") } func resetRegistry() { + // Lock Registry registryLock.Lock() defer registryLock.Unlock() + + // Clear Registry functionRegistry = make(map[string]Function) } @@ -46,11 +52,15 @@ type TestComplexArgs struct { func (t TestComplexArgs) Validate() error { return nil } func TestComplexTypes(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction("complex", func(ctx context.Context, args TestComplexArgs) (bool, error) { return args.Flag, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function complex(items: number[], data: Record, flag: boolean): boolean;") } @@ -65,11 +75,15 @@ type TestNestedArgs struct { func (t TestNestedArgs) Validate() error { return nil } func TestNestedStruct(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction("nested", func(ctx context.Context, args TestNestedArgs) (string, error) { return args.User.FirstName, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function nested(user: {}): string;") } @@ -83,11 +97,15 @@ type TestOptionalArgs struct { func (t TestOptionalArgs) Validate() error { return nil } func TestOptionalFields(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestOptionalArgs, string]("optional", func(ctx context.Context, args TestOptionalArgs) (string, error) { return args.Name, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function optional(name: string, age?: number | null, score?: number | null): string;") } @@ -104,11 +122,15 @@ type TestResultArgs struct { func (t TestResultArgs) Validate() error { return nil } func TestResultStruct(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestResultArgs, TestResult]("result", func(ctx context.Context, args TestResultArgs) (TestResult, error) { return TestResult{ID: 1}, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function result(input: string): TestResult;") assert.Contains(t, defs, "interface TestResult {id: number; data: number[]}") @@ -125,11 +147,15 @@ type TestAsyncResult struct { } func TestAsyncPromise(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Async Function RegisterAsyncFunction[TestAsyncArgs, *TestAsyncStatus]("async", func(ctx context.Context, args TestAsyncArgs) (*TestAsyncStatus, error) { return &TestAsyncStatus{Code: 200}, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function async(url: string): Promise;") assert.Contains(t, defs, "interface TestAsyncStatus") @@ -150,11 +176,15 @@ type TestNestedPointerArgs struct { func (t TestNestedPointerArgs) Validate() error { return nil } func TestNestedPointerInResult(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestNestedPointerArgs, *TestNestedPointerResult]("pointerResult", func(ctx context.Context, args TestNestedPointerArgs) (*TestNestedPointerResult, error) { return &TestNestedPointerResult{Value: "test"}, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function pointerResult(id: number): TestNestedPointerResult | null;") } @@ -166,11 +196,15 @@ type TestUintArgs struct { func (t TestUintArgs) Validate() error { return nil } func TestUintType(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestUintArgs, uint]("uint", func(ctx context.Context, args TestUintArgs) (uint, error) { return args.Value, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function uint(value: number): number;") } @@ -182,11 +216,15 @@ type TestFloatArgs struct { func (t TestFloatArgs) Validate() error { return nil } func TestFloatType(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestFloatArgs, float32]("float", func(ctx context.Context, args TestFloatArgs) (float32, error) { return float32(args.Amount), nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function float(amount: number): number;") } @@ -200,11 +238,15 @@ type TestPointerInArgs struct { func (t TestPointerInArgs) Validate() error { return nil } func TestNestedPointerStruct(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestPointerInArgs, string]("nestedPointer", func(ctx context.Context, args TestPointerInArgs) (string, error) { return "test", nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function nestedPointer(user?: {} | null): string;") } @@ -216,58 +258,15 @@ type TestErrorOnlyArgs struct { func (t TestErrorOnlyArgs) Validate() error { return nil } func TestErrorOnlyReturn(t *testing.T) { + // Reset Registry resetRegistry() + + // Register Function RegisterFunction[TestErrorOnlyArgs, struct{}]("errorOnly", func(ctx context.Context, args TestErrorOnlyArgs) (struct{}, error) { return struct{}{}, nil }) + // Verify Declarations defs := GetFunctionDeclarations() assert.Contains(t, defs, "declare function errorOnly(input: string): {};") } - -func TestGoTypeToTSTypeBasic(t *testing.T) { - tests := []struct { - input reflect.Type - inputPtr bool - expected string - }{ - {reflect.TypeOf(""), false, "string"}, - {reflect.TypeOf(0), false, "number"}, - {reflect.TypeOf(int64(0)), false, "number"}, - {reflect.TypeOf(uint(0)), false, "number"}, - {reflect.TypeOf(3.14), false, "number"}, - {reflect.TypeOf(float32(0.0)), false, "number"}, - {reflect.TypeOf(true), false, "boolean"}, - {reflect.TypeOf([]string{}), false, "string[]"}, - {reflect.TypeOf([]int{}), false, "number[]"}, - {reflect.TypeOf(map[string]any{}), false, "Record"}, - {reflect.TypeOf(map[string]int{}), false, "Record"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - result := goTypeToTSType(tt.input, tt.inputPtr) - assert.Equal(t, tt.expected, result) - }) - } -} - -type TestNestedStructField struct { - Inner struct { - Name string `json:"name"` - } `json:"inner"` -} - -func TestGoTypeToTSTypeNestedStruct(t *testing.T) { - result := goTypeToTSType(reflect.TypeOf(TestNestedStructField{}), false) - assert.Equal(t, "TestNestedStructField", result) -} - -type TestArrayField struct { - Items []string `json:"items"` -} - -func TestGoTypeToTSTypeArray(t *testing.T) { - result := goTypeToTSType(reflect.TypeOf(TestArrayField{}), false) - assert.Equal(t, "TestArrayField", result) -} diff --git a/internal/runtime/options.go b/internal/runtime/options.go index 23f7a32..9213669 100644 --- a/internal/runtime/options.go +++ b/internal/runtime/options.go @@ -6,12 +6,14 @@ type RuntimeOption func(*Runtime) func WithStdout(stdout io.Writer) RuntimeOption { return func(r *Runtime) { + // Set Stdout r.opts.Stdout = stdout } } func WithStderr(stderr io.Writer) RuntimeOption { return func(r *Runtime) { + // Set Stderr r.opts.Stderr = stderr } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index b11dbf3..e068fb6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -14,8 +14,10 @@ import ( ) type Runtime struct { - ctx *qjs.Context - opts qjs.Option + ctx *qjs.Context + opts qjs.Option + funcs map[string]functions.Function + typeDecls map[string]string } func New(ctx context.Context, opts ...RuntimeOption) (*Runtime, error) { @@ -41,8 +43,21 @@ func New(ctx context.Context, opts ...RuntimeOption) (*Runtime, error) { } func (r *Runtime) populateGlobals() error { - for name, fn := range functions.GetRegisteredFunctions() { - // Register Main Function + // Initialize Maps + r.funcs = make(map[string]functions.Function) + r.typeDecls = make(map[string]string) + + // Load Requested Functions + allFuncs := functions.GetRegisteredFunctions() + for name, fn := range allFuncs { + r.funcs[name] = fn + if err := r.addFunctionTypes(fn); err != nil { + return fmt.Errorf("failed to add types for function %s: %w", name, err) + } + } + + // Register Functions with QuickJS + for name, fn := range r.funcs { if fn.IsAsync() { r.ctx.SetAsyncFunc(name, func(this *qjs.This) { qjsVal, err := callFunc(this, fn) @@ -62,6 +77,37 @@ func (r *Runtime) populateGlobals() error { return nil } +// addFunctionTypes adds types from a function to the runtime's type declarations. +// Returns an error if there's a type conflict (same name, different definition). +func (r *Runtime) addFunctionTypes(fn functions.Function) error { + for name, def := range fn.Types() { + if existing, ok := r.typeDecls[name]; ok && existing != def { + return fmt.Errorf("type conflict: %s has conflicting definitions (existing: %s, new: %s)", + name, existing, def) + } + r.typeDecls[name] = def + } + return nil +} + +// GetTypeDeclarations returns all TypeScript type declarations for this runtime. +// Includes both type definitions and function declarations. +func (r *Runtime) GetTypeDeclarations() string { + var decls []string + + // Add Type Definitions + for _, def := range r.typeDecls { + decls = append(decls, def) + } + + // Add Function Declarations + for _, fn := range r.funcs { + decls = append(decls, fn.Definition()) + } + + return strings.Join(decls, "\n\n") +} + func (r *Runtime) RunFile(filePath string) error { tsFileContent, err := os.ReadFile(filePath) if err != nil { @@ -86,9 +132,9 @@ func (r *Runtime) RunCode(tsCode string) error { func (r *Runtime) transformCode(tsCode string) ([]byte, error) { result := api.Transform(tsCode, api.TransformOptions{ - Loader: api.LoaderTS, - Target: api.ES2022, - // Format: api.FormatIIFE, + Loader: api.LoaderTS, + Target: api.ES2022, + Format: api.FormatIIFE, Sourcemap: api.SourceMapNone, TreeShaking: api.TreeShakingFalse, }) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f78b045..7e79576 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -23,11 +23,14 @@ func (t TestArgs) Validate() error { } func TestExecuteTypeScript(t *testing.T) { + // Create Buffers var stdout, stderr bytes.Buffer + // Create Runtime rt, err := New(context.Background(), WithStderr(&stderr), WithStdout(&stdout)) assert.NoError(t, err, "Expected no error") + // Create TypeScript Code tsCode := `interface Person { name: string; age: number; @@ -54,73 +57,107 @@ function calculateSum(a: number, b: number): number { console.log("Sum of 5 and 10 is: " + calculateSum(5, 10)); ` + // Create Temp File tmpFile, err := os.CreateTemp("", "*.ts") assert.NoError(t, err, "Failed to create temp file") t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) }) + // Write Code to File _, err = tmpFile.WriteString(tsCode) assert.NoError(t, err, "Failed to write to temp file") err = tmpFile.Close() assert.NoError(t, err, "Failed to close temp file") + // Run File err = rt.RunFile(tmpFile.Name()) + // Verify Execution assert.NoError(t, err, "Expected no error") assert.Empty(t, stderr.String(), "Expected no error output") + // Verify 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") + // Verify Line Count lines := strings.Split(strings.TrimSpace(output), "\n") assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 output lines") } func TestAsyncFunctionResolution(t *testing.T) { + // Register Async Function functions.RegisterAsyncFunction("resolveTest", func(_ context.Context, args TestArgs) (string, error) { return "test-result", nil }) + // Create Runtime r, err := New(context.Background()) require.NoError(t, err) - // Async functions need to be awaited in an async context + // Execute Async Function - Must be awaited in async context result, err := r.ctx.Eval("test.js", qjs.Code(`async function run() { return await resolveTest("hello"); }; run()`)) require.NoError(t, err) require.NotNil(t, result) defer result.Free() + + // Verify Result assert.Equal(t, "test-result", result.String()) } func TestAsyncFunctionRejection(t *testing.T) { + // Register Async Function that Returns Error functions.RegisterAsyncFunction("rejectTest", func(_ context.Context, args TestArgs) (string, error) { return "", assert.AnError }) + // Create Runtime r, err := New(context.Background()) require.NoError(t, err) - // Rejected promises throw when awaited + // Execute Async Function - Rejected Promises Throw When Awaited _, err = r.ctx.Eval("test.js", qjs.Code(`async function run() { return await rejectTest("hello"); }; run()`)) assert.Error(t, err) } func TestNonPromise(t *testing.T) { + // Register Sync Function functions.RegisterFunction("nonPromiseTest", func(_ context.Context, args TestArgs) (string, error) { return "sync-result", nil }) + // Create Runtime r, err := New(context.Background()) assert.NoError(t, err) + // Execute Sync Function result, err := r.ctx.Eval("test.js", qjs.Code(`nonPromiseTest("hello")`)) assert.NoError(t, err) defer result.Free() + // Verify Result assert.Equal(t, "sync-result", result.String()) } + +func TestGetTypeDeclarations(t *testing.T) { + // Register Function + functions.RegisterFunction("testFunc", func(_ context.Context, args TestArgs) (string, error) { + return "result", nil + }) + + // Create Runtime + r, err := New(context.Background()) + require.NoError(t, err) + + // Get Type Declarations + decls := r.GetTypeDeclarations() + + // Verify Declarations + assert.Contains(t, decls, "interface TestArgs") + assert.Contains(t, decls, "declare function testFunc") + assert.Contains(t, decls, "field1: string") +} diff --git a/internal/stdlib/fetch.go b/internal/stdlib/fetch.go index 7903109..74b6a4a 100644 --- a/internal/stdlib/fetch.go +++ b/internal/stdlib/fetch.go @@ -42,9 +42,11 @@ type Response struct { } func Fetch(ctx context.Context, args FetchArgs) (Response, error) { + // Set Default Method and Headers method := "GET" headers := make(map[string]string) + // Apply Init Options if args.Init != nil { if args.Init.Method != "" { method = args.Init.Method @@ -54,15 +56,18 @@ func Fetch(ctx context.Context, args FetchArgs) (Response, error) { } } + // Create Request req, err := http.NewRequestWithContext(ctx, method, args.Input, nil) if err != nil { return Response{}, fmt.Errorf("failed to create request: %w", err) } + // Set Request Headers for k, v := range headers { req.Header.Set(k, v) } + // Execute Request resp, err := http.DefaultClient.Do(req) if err != nil { return Response{}, fmt.Errorf("failed to fetch: %w", err) @@ -71,11 +76,13 @@ func Fetch(ctx context.Context, args FetchArgs) (Response, error) { _ = resp.Body.Close() }() + // Read Response Body body, err := io.ReadAll(resp.Body) if err != nil { return Response{}, fmt.Errorf("failed to read body: %w", err) } + // Collect Response Headers resultHeaders := make(map[string]string) for key, values := range resp.Header { if len(values) > 0 { @@ -85,6 +92,7 @@ func Fetch(ctx context.Context, args FetchArgs) (Response, error) { } } + // Return Response return Response{ OK: resp.StatusCode >= 200 && resp.StatusCode < 300, Status: resp.StatusCode, diff --git a/internal/stdlib/fetch_test.go b/internal/stdlib/fetch_test.go index 7d009f4..4946670 100644 --- a/internal/stdlib/fetch_test.go +++ b/internal/stdlib/fetch_test.go @@ -11,7 +11,10 @@ import ( ) func TestFetch(t *testing.T) { + // Create Context ctx := context.Background() + + // Create Test Server 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") @@ -20,9 +23,11 @@ func TestFetch(t *testing.T) { })) defer server.Close() + // Execute Fetch result, err := Fetch(ctx, FetchArgs{Input: server.URL}) require.NoError(t, err) + // Verify Response assert.True(t, result.OK) assert.Equal(t, http.StatusOK, result.Status) assert.Contains(t, result.Body, "Hello from httptest") @@ -32,7 +37,10 @@ func TestFetch(t *testing.T) { } func TestFetchHTTPBin(t *testing.T) { + // Create Context ctx := context.Background() + + // Create Test Server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -40,9 +48,11 @@ func TestFetchHTTPBin(t *testing.T) { })) defer server.Close() + // Execute Fetch result, err := Fetch(ctx, FetchArgs{Input: server.URL}) require.NoError(t, err) + // Verify Response assert.True(t, result.OK) assert.Equal(t, http.StatusOK, result.Status) assert.Contains(t, result.Body, `"args"`) @@ -50,23 +60,33 @@ func TestFetchHTTPBin(t *testing.T) { } func TestFetchWith404(t *testing.T) { + // Create Context ctx := context.Background() + + // Execute Fetch result, err := Fetch(ctx, FetchArgs{Input: "https://httpbin.org/status/404"}) require.NoError(t, err) + // Verify Response assert.False(t, result.OK) assert.Equal(t, http.StatusNotFound, result.Status) } func TestFetchWithInvalidURL(t *testing.T) { + // Create Context ctx := context.Background() + + // Execute Fetch - Should Fail _, err := Fetch(ctx, FetchArgs{Input: "http://this-domain-does-not-exist-12345.com"}) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to fetch") } func TestFetchWithHeaders(t *testing.T) { + // Create Context ctx := context.Background() + + // Create Test Server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) assert.Equal(t, "GET", r.Method) @@ -75,6 +95,7 @@ func TestFetchWithHeaders(t *testing.T) { })) defer server.Close() + // Configure Request Options headers := map[string]string{ "Authorization": "Bearer test-token", } @@ -82,13 +103,18 @@ func TestFetchWithHeaders(t *testing.T) { Method: "GET", Headers: headers, } + + // Execute Fetch with Headers result, err := Fetch(ctx, FetchArgs{Input: server.URL, Init: options}) require.NoError(t, err) assert.True(t, result.OK) } func TestFetchDefaults(t *testing.T) { + // Create Context ctx := context.Background() + + // Create Test Server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method, "default method should be GET") w.WriteHeader(http.StatusOK) @@ -96,6 +122,7 @@ func TestFetchDefaults(t *testing.T) { })) defer server.Close() + // Execute Fetch with Empty Options options := &RequestInit{} result, err := Fetch(ctx, FetchArgs{Input: server.URL, Init: options}) require.NoError(t, err) diff --git a/internal/tsconvert/convert.go b/internal/tsconvert/convert.go new file mode 100644 index 0000000..423fcd1 --- /dev/null +++ b/internal/tsconvert/convert.go @@ -0,0 +1,242 @@ +package tsconvert + +import ( + "fmt" + "reflect" + "strings" +) + +// ConvertType converts a Go reflect.Type to a TypeScript type string. +func ConvertType(t reflect.Type) string { + return goTypeToTSType(t, false) +} + +// goTypeToTSType converts a Go type to TypeScript type string. +// isPointer tracks if we're inside a pointer chain. +func goTypeToTSType(t reflect.Type, isPointer bool) string { + // Handle Pointer Types + if t.Kind() == reflect.Pointer { + return goTypeToTSType(t.Elem(), true) + } + + // Determine Base Type + baseType := "" + switch t.Kind() { + case reflect.String: + baseType = "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + baseType = "number" + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + baseType = "number" + case reflect.Float32, reflect.Float64: + baseType = "number" + case reflect.Bool: + baseType = "boolean" + case reflect.Interface: + baseType = "any" + case reflect.Slice: + baseType = fmt.Sprintf("%s[]", goTypeToTSType(t.Elem(), false)) + case reflect.Map: + if t.Key().Kind() == reflect.String && t.Elem().Kind() == reflect.Interface { + baseType = "Record" + } else { + baseType = "Record" + } + case reflect.Struct: + name := t.Name() + if name == "" { + baseType = "{}" + } else { + baseType = name + } + default: + baseType = "any" + } + + // Add Null for Pointer Types + if isPointer { + baseType += " | null" + } + return baseType +} + +// CollectTypes extracts all type declarations from a function signature. +// It analyzes the args type and return type to find all struct types. +func CollectTypes(argsType, fnType reflect.Type) *TypeSet { + // Create TypeSet + ts := NewTypeSet() + + // Collect Types from Args Struct + collectStructTypes(argsType, ts) + + // Collect Types from Return Type + if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { + lastIndex := fnType.NumOut() - 1 + lastType := fnType.Out(lastIndex) + + if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if fnType.NumOut() > 1 { + collectType(fnType.Out(0), ts) + } + } else { + collectType(lastType, ts) + } + } + + return ts +} + +// collectType recursively collects struct types. +func collectType(t reflect.Type, ts *TypeSet) { + // Handle Pointer Types + if t.Kind() == reflect.Pointer { + collectType(t.Elem(), ts) + return + } + + // Collect Struct Types + if t.Kind() == reflect.Struct { + name := t.Name() + if name != "" { + // Only Process if Not Already Processed + if _, exists := ts.Get(name); !exists { + collectStructTypes(t, ts) + } + } + } +} + +// collectStructTypes converts a Go struct to TypeScript interface and adds to TypeSet. +func collectStructTypes(t reflect.Type, ts *TypeSet) { + // Validate Struct Type + if t.Kind() != reflect.Struct { + return + } + + // Get Struct Name + name := t.Name() + if name == "" { + return // Skip Anonymous Structs + } + + // Check if Already Processed + if _, exists := ts.Get(name); exists { + return + } + + // Collect Fields + var fields []string + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // Skip Anonymous and Unexported Fields + if field.Anonymous || !field.IsExported() { + continue + } + + // Get Field Name + fieldName := getFieldName(field) + + // Determine Type and Optionality + var tsType string + var isOptional bool + isPointer := field.Type.Kind() == reflect.Pointer + + if isPointer { + isOptional = true + tsType = goTypeToTSType(field.Type, true) + } else { + isOptional = strings.Contains(field.Tag.Get("json"), ",omitempty") + tsType = goTypeToTSType(field.Type, false) + } + + // Mark Optional Fields + if isOptional { + fieldName += "?" + } + + fields = append(fields, fmt.Sprintf("%s: %s", fieldName, tsType)) + + // Recursively Collect Nested Types + collectType(field.Type, ts) + } + + // Add Type Definition + definition := fmt.Sprintf("interface %s {%s}", name, strings.Join(fields, "; ")) + _ = ts.Add(name, definition) +} + +// getFieldName extracts the field name from json tag or uses the Go field name. +func getFieldName(field reflect.StructField) string { + // Get JSON Tag + jsonTag := field.Tag.Get("json") + if jsonTag != "" && jsonTag != "-" { + name, _, _ := strings.Cut(jsonTag, ",") + return name + } + + // Use Go Field Name + return field.Name +} + +// GenerateFunctionDecl creates a TypeScript function declaration. +func GenerateFunctionDecl(name string, argsType, fnType reflect.Type, isAsync bool) string { + // Validate Args Type + if argsType.Kind() != reflect.Struct { + return "" + } + + // Collect Parameters + var params []string + for i := 0; i < argsType.NumField(); i++ { + field := argsType.Field(i) + fieldName := getFieldName(field) + goType := field.Type + + // Determine Type and Optionality + var tsType string + var isOptional bool + isPointer := goType.Kind() == reflect.Pointer + + if isPointer { + isOptional = true + tsType = goTypeToTSType(goType, true) + if !strings.Contains(tsType, " | null") { + tsType += " | null" + } + } else { + isOptional = strings.Contains(field.Tag.Get("json"), ",omitempty") + tsType = goTypeToTSType(goType, false) + } + + // Mark Optional Fields + if isOptional { + fieldName += "?" + } + params = append(params, fmt.Sprintf("%s: %s", fieldName, tsType)) + } + + // Determine Return Type + returnSignature := "any" + if fnType.Kind() == reflect.Func && fnType.NumOut() > 0 { + lastIndex := fnType.NumOut() - 1 + lastType := fnType.Out(lastIndex) + + if lastType.Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if fnType.NumOut() > 1 { + returnType := fnType.Out(0) + returnSignature = goTypeToTSType(returnType, returnType.Kind() == reflect.Pointer) + } + } else { + returnSignature = goTypeToTSType(lastType, lastType.Kind() == reflect.Pointer) + } + } + + // Wrap in Promise for Async Functions + if isAsync { + returnSignature = fmt.Sprintf("Promise<%s>", returnSignature) + } + + // Generate Declaration + return fmt.Sprintf("declare function %s(%s): %s;", name, strings.Join(params, ", "), returnSignature) +} diff --git a/internal/tsconvert/convert_test.go b/internal/tsconvert/convert_test.go new file mode 100644 index 0000000..87b7e23 --- /dev/null +++ b/internal/tsconvert/convert_test.go @@ -0,0 +1,333 @@ +package tsconvert + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test types for conversion +type SimpleStruct struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type NestedStruct struct { + ID int `json:"id"` + Simple SimpleStruct `json:"simple"` + Pointer *SimpleStruct `json:"pointer,omitempty"` +} + +type OptionalFields struct { + Required string `json:"required"` + Optional *string `json:"optional,omitempty"` + Number int `json:"number,omitempty"` +} + +type ComplexTypes struct { + Strings []string `json:"strings"` + Numbers []int `json:"numbers"` + Mapping map[string]any `json:"mapping"` + Nested []SimpleStruct `json:"nested"` +} + +func TestConvertType(t *testing.T) { + tests := []struct { + name string + input any + expected string + }{ + {"string", "", "string"}, + {"int", int(0), "number"}, + {"int8", int8(0), "number"}, + {"int16", int16(0), "number"}, + {"int32", int32(0), "number"}, + {"int64", int64(0), "number"}, + {"uint", uint(0), "number"}, + {"float32", float32(0), "number"}, + {"float64", float64(0), "number"}, + {"bool", true, "boolean"}, + {"interface", (*any)(nil), "any | null"}, + {"slice of strings", []string{}, "string[]"}, + {"slice of ints", []int{}, "number[]"}, + {"map", map[string]any{}, "Record"}, + {"struct", SimpleStruct{}, "SimpleStruct"}, + {"pointer to string", (*string)(nil), "string | null"}, + {"pointer to struct", (*SimpleStruct)(nil), "SimpleStruct | null"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Get Input Type + var inputType reflect.Type + if tt.input == nil { + inputType = reflect.TypeOf((*any)(nil)).Elem() + } else { + inputType = reflect.TypeOf(tt.input) + } + + // Convert Type + result := ConvertType(inputType) + + // Verify Result + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCollectTypes(t *testing.T) { + t.Run("simple struct", func(t *testing.T) { + // Collect Types + argsType := reflect.TypeOf(SimpleStruct{}) + fnType := reflect.TypeOf(func() (SimpleStruct, error) { return SimpleStruct{}, nil }) + ts := CollectTypes(argsType, fnType) + + // Verify TypeSet + require.NotNil(t, ts) + assert.Len(t, ts.All(), 1) + + // Verify SimpleStruct Definition + def, ok := ts.Get("SimpleStruct") + assert.True(t, ok) + assert.Contains(t, def, "interface SimpleStruct") + assert.Contains(t, def, "name: string") + assert.Contains(t, def, "count: number") + }) + + t.Run("nested struct", func(t *testing.T) { + // Collect Types + argsType := reflect.TypeOf(NestedStruct{}) + fnType := reflect.TypeOf(func() (NestedStruct, error) { return NestedStruct{}, nil }) + ts := CollectTypes(argsType, fnType) + + // Verify TypeSet + require.NotNil(t, ts) + all := ts.All() + assert.Len(t, all, 2) // NestedStruct and SimpleStruct + + // Verify NestedStruct Definition + def, ok := ts.Get("NestedStruct") + assert.True(t, ok) + assert.Contains(t, def, "interface NestedStruct") + assert.Contains(t, def, "id: number") + assert.Contains(t, def, "simple: SimpleStruct") + assert.Contains(t, def, "pointer?: SimpleStruct | null") + + // Verify SimpleStruct is Also Included + _, ok = ts.Get("SimpleStruct") + assert.True(t, ok) + }) + + t.Run("optional fields", func(t *testing.T) { + // Collect Types + argsType := reflect.TypeOf(OptionalFields{}) + fnType := reflect.TypeOf(func() (OptionalFields, error) { return OptionalFields{}, nil }) + ts := CollectTypes(argsType, fnType) + + // Verify TypeSet + require.NotNil(t, ts) + + // Verify OptionalFields Definition + def, ok := ts.Get("OptionalFields") + assert.True(t, ok) + assert.Contains(t, def, "required: string") + assert.Contains(t, def, "optional?: string | null") + assert.Contains(t, def, "number?: number") + }) + + t.Run("complex types", func(t *testing.T) { + // Collect Types + argsType := reflect.TypeOf(ComplexTypes{}) + fnType := reflect.TypeOf(func() (ComplexTypes, error) { return ComplexTypes{}, nil }) + ts := CollectTypes(argsType, fnType) + + // Verify TypeSet + require.NotNil(t, ts) + + // Verify ComplexTypes Definition + def, ok := ts.Get("ComplexTypes") + assert.True(t, ok) + assert.Contains(t, def, "strings: string[]") + assert.Contains(t, def, "numbers: number[]") + assert.Contains(t, def, "mapping: Record") + assert.Contains(t, def, "nested: SimpleStruct[]") + }) + + t.Run("no return type", func(t *testing.T) { + // Collect Types + argsType := reflect.TypeOf(SimpleStruct{}) + fnType := reflect.TypeOf(func() error { return nil }) + ts := CollectTypes(argsType, fnType) + + // Verify TypeSet - Only SimpleStruct from args + require.NotNil(t, ts) + assert.Len(t, ts.All(), 1) + }) +} + +func TestTypeSet(t *testing.T) { + t.Run("add and get", func(t *testing.T) { + // Create TypeSet + ts := NewTypeSet() + + // Add Type + err := ts.Add("User", "interface User { name: string }") + require.NoError(t, err) + + // Verify Type + def, ok := ts.Get("User") + assert.True(t, ok) + assert.Equal(t, "interface User { name: string }", def) + }) + + t.Run("duplicate same definition", func(t *testing.T) { + // Create TypeSet + ts := NewTypeSet() + + // Add Type + err := ts.Add("User", "interface User { name: string }") + require.NoError(t, err) + + // Add Same Type with Same Definition - Should Not Error + err = ts.Add("User", "interface User { name: string }") + require.NoError(t, err) + + // Verify Count + assert.Len(t, ts.All(), 1) + }) + + t.Run("conflicting definitions", func(t *testing.T) { + // Create TypeSet + ts := NewTypeSet() + + // Add Type + err := ts.Add("User", "interface User { name: string }") + require.NoError(t, err) + + // Add Same Type with Different Definition - Should Error + err = ts.Add("User", "interface User { id: number }") + require.Error(t, err) + assert.Contains(t, err.Error(), "type conflict") + assert.Contains(t, err.Error(), "User") + }) + + t.Run("merge type sets", func(t *testing.T) { + // Create First TypeSet + ts1 := NewTypeSet() + err := ts1.Add("User", "interface User { name: string }") + require.NoError(t, err) + + // Create Second TypeSet + ts2 := NewTypeSet() + err = ts2.Add("Post", "interface Post { title: string }") + require.NoError(t, err) + + // Merge TypeSets + err = ts1.Merge(ts2) + require.NoError(t, err) + + // Verify Merged Types + assert.Len(t, ts1.All(), 2) + _, ok := ts1.Get("User") + assert.True(t, ok) + _, ok = ts1.Get("Post") + assert.True(t, ok) + }) + + t.Run("merge with conflict", func(t *testing.T) { + // Create First TypeSet + ts1 := NewTypeSet() + err := ts1.Add("User", "interface User { name: string }") + require.NoError(t, err) + + // Create Second TypeSet with Conflicting Type + ts2 := NewTypeSet() + err = ts2.Add("User", "interface User { id: number }") + require.NoError(t, err) + + // Merge Should Fail Due to Conflict + err = ts1.Merge(ts2) + require.Error(t, err) + assert.Contains(t, err.Error(), "type conflict") + }) +} + +func TestExtractName(t *testing.T) { + tests := []struct { + definition string + expected string + }{ + {"interface User { name: string }", "User"}, + {"interface MyType { }", "MyType"}, + {"type MyAlias = string", "MyAlias"}, + {"type ComplexType = { a: number }", "ComplexType"}, + {"invalid syntax here", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.definition, func(t *testing.T) { + // Extract Name + result := ExtractName(tt.definition) + + // Verify Result + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGenerateFunctionDecl(t *testing.T) { + t.Run("simple function", func(t *testing.T) { + // Generate Declaration + argsType := reflect.TypeOf(SimpleStruct{}) + fnType := reflect.TypeOf(func() (SimpleStruct, error) { return SimpleStruct{}, nil }) + decl := GenerateFunctionDecl("myFunc", argsType, fnType, false) + + // Verify Declaration + assert.Equal(t, "declare function myFunc(name: string, count: number): SimpleStruct;", decl) + }) + + t.Run("async function", func(t *testing.T) { + // Generate Declaration + argsType := reflect.TypeOf(SimpleStruct{}) + fnType := reflect.TypeOf(func() (SimpleStruct, error) { return SimpleStruct{}, nil }) + decl := GenerateFunctionDecl("myAsyncFunc", argsType, fnType, true) + + // Verify Declaration + assert.Equal(t, "declare function myAsyncFunc(name: string, count: number): Promise;", decl) + }) + + t.Run("function with optional fields", func(t *testing.T) { + // Generate Declaration + argsType := reflect.TypeOf(OptionalFields{}) + fnType := reflect.TypeOf(func() (OptionalFields, error) { return OptionalFields{}, nil }) + decl := GenerateFunctionDecl("optionalFunc", argsType, fnType, false) + + // Verify Declaration + assert.Contains(t, decl, "required: string") + assert.Contains(t, decl, "optional?: string | null") + assert.Contains(t, decl, "number?: number") + }) + + t.Run("function with no return", func(t *testing.T) { + // Generate Declaration + argsType := reflect.TypeOf(SimpleStruct{}) + fnType := reflect.TypeOf(func() error { return nil }) + decl := GenerateFunctionDecl("noReturn", argsType, fnType, false) + + // Verify Declaration + assert.Equal(t, "declare function noReturn(name: string, count: number): any;", decl) + }) + + t.Run("non-struct args returns empty", func(t *testing.T) { + // Generate Declaration + argsType := reflect.TypeOf("") + fnType := reflect.TypeOf(func() error { return nil }) + decl := GenerateFunctionDecl("invalid", argsType, fnType, false) + + // Verify Declaration + assert.Equal(t, "", decl) + }) +} diff --git a/internal/tsconvert/types.go b/internal/tsconvert/types.go new file mode 100644 index 0000000..71efb21 --- /dev/null +++ b/internal/tsconvert/types.go @@ -0,0 +1,92 @@ +// Package tsconvert provides utilities for converting Go types to TypeScript definitions. +package tsconvert + +import ( + "fmt" + "regexp" +) + +// TypeDecl represents a TypeScript type declaration. +type TypeDecl struct { + Name string // e.g., "UserConfig" + Definition string // e.g., "interface UserConfig { name: string }" +} + +// TypeSet manages a collection of type declarations with deduplication support. +type TypeSet struct { + types map[string]string // name -> definition +} + +// NewTypeSet creates a new empty TypeSet. +func NewTypeSet() *TypeSet { + return &TypeSet{ + types: make(map[string]string), + } +} + +// Add adds a type declaration to the set. +// Returns an error if a type with the same name but different definition already exists. +func (ts *TypeSet) Add(name, definition string) error { + if existing, ok := ts.types[name]; ok { + if existing != definition { + return fmt.Errorf("type conflict: %s has conflicting definitions", name) + } + // Same name and definition, no conflict + return nil + } + ts.types[name] = definition + return nil +} + +// Get retrieves a type definition by name. +func (ts *TypeSet) Get(name string) (string, bool) { + def, ok := ts.types[name] + return def, ok +} + +// All returns all type declarations as a map. +func (ts *TypeSet) All() map[string]string { + result := make(map[string]string, len(ts.types)) + for k, v := range ts.types { + result[k] = v + } + return result +} + +// Names returns all type names in the set. +func (ts *TypeSet) Names() []string { + names := make([]string, 0, len(ts.types)) + for name := range ts.types { + names = append(names, name) + } + return names +} + +// Merge merges another TypeSet into this one. +// Returns an error if there are conflicting definitions. +func (ts *TypeSet) Merge(other *TypeSet) error { + for name, def := range other.types { + if err := ts.Add(name, def); err != nil { + return err + } + } + return nil +} + +// ExtractName extracts the type name from a TypeScript declaration. +// Supports "interface Name {...}" and "type Name = ..." patterns. +func ExtractName(definition string) string { + // Try interface pattern: "interface Name { ... }" + interfaceRe := regexp.MustCompile(`^interface\s+(\w+)`) + if matches := interfaceRe.FindStringSubmatch(definition); len(matches) > 1 { + return matches[1] + } + + // Try type alias pattern: "type Name = ..." + typeRe := regexp.MustCompile(`^type\s+(\w+)`) + if matches := typeRe.FindStringSubmatch(definition); len(matches) > 1 { + return matches[1] + } + + return "" +} diff --git a/plans/BUILTIN.md b/plans/BUILTIN.md deleted file mode 100644 index 8da9931..0000000 --- a/plans/BUILTIN.md +++ /dev/null @@ -1,324 +0,0 @@ -# Builtin System Design - -## Overview - -Type-safe builtin system for exposing Go functions to TypeScript/JavaScript with automatic type conversion and defaults support. - -## Core Design - -### Single Argument Pattern - -All builtins accept a **single struct argument** on the Go side, but are called as **multi-argument functions** on the JavaScript side. - -```go -type FetchArgs struct { - URL string `json:"url"` - Options *FetchOptions `json:"options"` -} - -func Fetch(args FetchArgs) (*FetchResult, error) { - // Implementation -} - -// JavaScript calls: -// fetch("https://example.com") -// fetch("https://example.com", { method: "POST" }) -``` - -**Mapping Rules:** -- JavaScript arguments map **positionally** to struct fields in order -- Field names in JavaScript come from `json:""` struct tag, or field name if tag is omitted -- Missing JavaScript arguments only allowed for **pointer fields** (non-pointer fields must always be provided) - -### Default Values - -Argument structs **must** implement `Defaults()` receiver method to provide default values: - -```go -type FetchOptions struct { - Method string `json:"method"` - Headers map[string]string `json:"headers"` -} - -func (f *FetchOptions) Defaults() *FetchOptions { - if f.Method == "" { - f.Method = "GET" - } - return f -} - -// If called with just URL, Options will be nil initially, -// then we create default: &FetchOptions{Method: "GET"} -``` - -**Defaults flow:** -1. Create zero-value struct -2. Fill in provided JavaScript arguments positionally -3. Call `Defaults()` on the struct to fill in remaining defaults - -### Empty Arguments - -Zero-argument builtins must still use a struct (can be empty): - -```go -// Helper type for no-argument builtins -type EmptyArgs struct {} - -func Ping(args EmptyArgs) bool { - return true -} - -// JavaScript: ping() -``` - -## Registration API - -```go -package builtin - -// RegisterBuiltin registers a Go function as a builtin -// The function must accept a single struct argument T and return (R, error) or R -func RegisterBuiltin[T any, R any](name string, fn func(T) (R, error)) -``` - -**Usage:** -```go -func init() { - builtin.RegisterBuiltin("fetch", Fetch) - builtin.RegisterBuiltin("add", Add) -} -``` - -## Type Conversion - -### JavaScript → Go - -Automatic conversion from goja values to Go struct fields: - -| JavaScript | Go Type | -|------------|---------| -| string | string | -| number | int, int8-64, uint, uint8-64, float32, float64 | -| boolean | bool | -| object/map | map[string]any | -| null/undefined | nil (for pointer fields only) | - -**Struct Field Conversion:** -- Primitives: converted by type -- Maps: `map[string]any` → Go map with string keys -- Nested structs: Recursive conversion to nested struct - -### Go → JavaScript (Return Types) - -Automatic conversion from Go return values to JavaScript: - -| Go Type | JavaScript | -|---------|------------| -| string, number, bool | primitive | -| map[string]any | object | -| map[string]string | object | -| []any | array | -| struct | object with fields from json tags | - -**Struct Return Types:** -- Auto-converted to JavaScript objects -- Field names from `json:""` struct tags -- Nested structs become nested objects -- No custom converters needed - -**Example:** -```go -type FetchResult struct { - OK bool `json:"ok"` - Status int `json:"status"` - Body string `json:"body"` -} - -// Returns: { ok: true, status: 200, body: "..." } -``` - -## Error Handling - -Go errors are automatically converted to JavaScript exceptions: - -```go -func Fetch(args FetchArgs) (*FetchResult, error) { - if someError { - return nil, fmt.Errorf("fetch failed") - } - return result, nil -} - -// JavaScript: fetch calls that error will throw -``` - -## TypeScript Declaration Generation - -TypeScript definitions are auto-generated from Go function signatures: - -```go -// Go: -func Fetch(args FetchArgs) (*FetchResult, error) - -// Generated TypeScript: -declare function fetch(url: string, options?: FetchOptions): FetchResult | never; -``` - -**Type mapping:** -- `string` → `string` -- `int/int8-64/uint/uint8-64/float32/float64` → `number` -- `bool` → `boolean` -- `*T` → `T | null` (or `T` if optional) -- `map[string]any` → `Record` -- `struct` → Interface with fields - -**Optional parameters:** -- Pointer fields become optional parameters with `T | null` -- Non-pointer fields are required - -## Implementation Notes - -### Reflection-Based Wrapping - -```go -func createWrapper[T any, R any](fn func(T) (R, error)) func(*goja.Runtime, goja.FunctionCall) goja.Value { - return func(vm *goja.Runtime, call goja.FunctionCall) goja.Value { - // 1. Create zero-value struct - var args T - argsValue := reflect.ValueOf(&args).Elem() - - // 2. Map JavaScript args to struct fields positionally - for i := 0; i < min(len(call.Arguments), argsValue.NumField()); i++ { - jsArg := call.Arguments[i] - field := argsValue.Field(i) - - // Skip nil/undefined if field is pointer - if goja.IsUndefined(jsArg) || goja.IsNull(jsArg) { - if field.Kind() == reflect.Ptr { - continue - } - } - - // Convert and assign - converted, err := convertJSValueToGo(vm, jsArg, field.Type()) - if err != nil { - panic(err) - } - field.Set(reflect.ValueOf(converted)) - } - - // 3. Call Defaults() if defined - if defaults, ok := args.(interface{ Defaults() T }); ok { - args = defaults.Defaults() - } - - // 4. Call the Go function - result, err := fn(args) - if err != nil { - panic(err) - } - - // 5. Convert return value to JavaScript - return convertGoValueToJS(vm, reflect.ValueOf(result)) - } -} -``` - -### Field Name Extraction - -```go -func getFieldName(field reflect.StructField) string { - jsonTag := field.Tag.Get("json") - if jsonTag != "" && jsonTag != "-" { - name, _, _ := strings.Cut(jsonTag, ",") - return name - } - return field.Name -} -``` - -## Migration Examples - -### Old → New - -**Old (current system):** -```go -func add(a, b int) int { - return a + b -} - -// Register: builtin.RegisterBuiltin("add", add) -// JS: add(5, 10) -``` - -**New system:** -```go -type AddArgs struct { - A int `json:"a"` - B int `json:"b"` -} - -func add(args AddArgs) int { - return args.A + args.B -} - -// Register: builtin.RegisterBuiltin("add", add) -// JS: add(5, 10) // positional -// JS: add(a=5, b=10) // named (if supported) -``` - -**Old fetch:** -```go -func Fetch(url string, options map[string]any) (*FetchResult, error) - -// Requires custom converter for result -``` - -**New fetch:** -```go -type FetchArgs struct { - URL string `json:"url"` - Options *FetchOptions `json:"options"` -} - -type FetchOptions struct { - Method string `json:"method"` - Headers map[string]string `json:"headers"` -} - -func (o *FetchOptions) Defaults() *FetchOptions { - if o.Method == "" { - o.Method = "GET" - } - return o -} - -type FetchResult struct { - OK bool `json:"ok"` - Status int `json:"status"` - Body string `json:"body"` -} - -func Fetch(args FetchArgs) (*FetchResult, error) { - // Implementation - no custom converter needed -} - -// JS: fetch("https://example.com") -// JS: fetch("https://example.com", { method: "POST" }) -``` - -## Testing - -All existing tests must pass after migration: - -- `internal/runtime/runtime_test.go` -- `internal/runtime/standard/fetch_test.go` -- `test_data/*.ts` test files - -Verify: -- TypeScript definitions are correct -- Positional argument mapping works -- Defaults are applied correctly -- Named JSON tags work -- Error handling propagates -- Return value conversion works