commit ccbe9cd7bf0922f69fa4b9816adb6ff16536eae4 Author: Evan Reichard Date: Tue Jan 27 09:55:09 2026 -0500 initial commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d7dfd25 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +# Poiesis + +## Module Name + +`reichard.io/poiesis` + +## Overview + +Go tool that transpiles TypeScript to JavaScript using esbuild API and executes it with github.com/fastschema/qjs. Features a flexible builtin system for exposing Go functions to TypeScript with support for both synchronous and asynchronous (Promise-based) operations. + +## Build & Test + +```bash +go build ./cmd/poiesis +go test ./... +golangci-lint run +``` + +## Project Structure + +``` +reichard.io/poiesis/ +├── cmd/poiesis/ # CLI entry point +│ └── main.go +├── internal/ +│ ├── runtime/ # Runtime management, transpilation, execution +│ │ ├── runtime.go +│ │ ├── runtime_test.go +│ │ └── options.go +│ ├── functions/ # Function registration framework +│ │ ├── registry.go +│ │ ├── types.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 +``` + +## Key Packages + +- `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 + +### 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 + +### Requirements + +- Args must be a struct implementing `Args` interface with `Validate() error` method +- Use JSON tags for TypeScript type definitions +- Async functions automatically generate `Promise` return types + +### Calling Convention + +**Important**: Functions have different calling conventions on Go vs JavaScript sides: + +- **Go side**: Function receives a **single argument struct** with all parameters +- **JavaScript side**: Function is called with **individual arguments** matching the struct fields (in field order) + +Fields are ordered by their position in the struct, with the generated TypeScript signature using those field names as argument names. + +### Example + +```go +type AddArgs struct { + A int `json:"a"` + B int `json:"b"` +} + +func (a AddArgs) Validate() error { return nil } + +func Add(_ context.Context, args AddArgs) (int, error) { + return args.A + args.B, nil +} + +// Register sync function +functions.RegisterFunction[AddArgs, int]("add", Add) + +// Register async function +functions.RegisterAsyncFunction[FetchArgs, *FetchResult]("fetch", Fetch) +``` + +## Testing Patterns + +- **Test framework**: Go's built-in `testing` package +- **Assertions**: `github.com/stretchr/testify/assert` and `require` +- **Linting**: `golangci-lint run` - must pass before committing +- **Test organization**: Test files use `_test.go` suffix, test functions prefixed with `Test` +- **TypeScript test files**: Tests that require TypeScript files should create them inline using `os.CreateTemp()` instead of relying on external test files + +## Dependencies + +- `github.com/evanw/esbuild/pkg/api` - TypeScript transpilation +- `github.com/fastschema/qjs` - JavaScript execution (CGO-free QuickJS runtime) +- `github.com/stretchr/testify/assert` - Test assertions + +## Code Conventions + +- Handle all return values from external functions (enforced by golangci-lint) +- 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/README.md b/README.md new file mode 100644 index 0000000..ba5e5e8 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Poiesis + +A Go tool that transpiles TypeScript to JavaScript using esbuild and executes it with qjs, with an extensible function system. + +## Project Structure + +``` +reichard.io/poiesis/ +├── cmd/ +│ └── poiesis/ # CLI application entry point +│ └── main.go +├── internal/ +│ ├── runtime/ # Runtime management, transpilation, execution +│ │ ├── runtime.go # Core runtime, transpilation, execution +│ │ └── runtime_test.go # Runtime tests +│ ├── functions/ # Function registration framework +│ │ ├── registry.go # Registration system +│ │ ├── types.go # Core interfaces and types +│ │ ├── typescript.go # TypeScript definition generation +│ │ ├── collector.go # Type collection utilities +│ │ └── typescript_test.go # Type system tests +│ └── stdlib/ # Standard library implementations +│ ├── fetch.go # HTTP fetch implementation +│ └── fetch_test.go # Fetch tests +``` + +## Architecture + +The project is cleanly separated into three packages: + +1. **`internal/runtime`** - Runtime management + - TypeScript transpilation with esbuild + - JavaScript execution with qjs + - Automatic function registration and execution + +2. **`internal/functions`** - Generic function registration framework + - Type-safe registration with generics + - Bidirectional Go ↔ JavaScript type conversion + - Automatic TypeScript declaration generation + +3. **`internal/stdlib`** - Standard library implementations + - `fetch` - HTTP requests + - Extensible for additional standard functions + +## Installation & Build + +```bash +go build ./cmd/poiesis +``` + +## Testing + +```bash +go test ./... +golangci-lint run +``` + +## Usage + +```bash +poiesis +poiesis -print-types +``` + +## Function System + +The function system allows you to easily expose Go functions to TypeScript/JavaScript. + +### Adding a Function + +Just write a Go function and register it: + +```go +package mystdlib + +import ( + "context" + "reichard.io/poiesis/internal/functions" +) + +type AddArgs struct { + A int `json:"a"` + B int `json:"b"` +} + +func (a AddArgs) Validate() error { + return nil +} + +func Add(_ context.Context, args AddArgs) (int, error) { + return args.A + args.B, nil +} + +func init() { + functions.RegisterFunction[AddArgs, int]("add", Add) +} +``` + +That's it! The framework automatically: +- Converts JavaScript values to Go types +- Handles errors (panics as JS errors) +- Generates TypeScript definitions +- Manages the qjs integration + +### Calling Convention + +**Important**: There's an important difference between how functions are defined in Go versus how they're called in JavaScript: + +- **Go side**: The function receives a **single argument struct** containing all parameters +- **JavaScript side**: The function is called with the **struct fields as individual arguments** (in the order they appear in the struct) + +```go +// Go: Single struct argument +type AddArgs struct { + A int `json:"a"` + B int `json:"b"` +} + +func Add(_ context.Context, args AddArgs) (int, error) { + return args.A + args.B, nil +} +``` + +```typescript +// JavaScript: Individual arguments (not an object!) +const result = add(5, 10); // NOT add({ a: 5, b: 10 }) +``` + +The framework extracts the JSON tags from the struct fields and uses them to generate the correct TypeScript function signature. + +### Example + +```typescript +// TypeScript code - call with individual arguments matching struct fields +const response = fetch("https://httpbin.org/get"); +console.log("OK:", response.ok); +console.log("Status:", response.status); +console.log("Body:", response.body); +``` + +### Built-in Functions + +- `fetch(options)` - HTTP requests + - `options.input` (string) - URL to fetch + - `options.init` (object) - Optional init object with `method`, `headers`, `body` + +## Dependencies + +- `github.com/evanw/esbuild/pkg/api` - TypeScript transpilation +- `github.com/fastschema/qjs` - JavaScript execution (QuickJS) +- `github.com/stretchr/testify/assert` - Test assertions + +## Development + +- **Test framework**: Go's built-in `testing` package +- **Code style**: Follow standard Go conventions +- **Linting**: `golangci-lint run` - must pass before committing +- **TypeScript test files**: Tests that require TypeScript files should create them inline using `os.CreateTemp()` instead of relying on external test files diff --git a/cmd/poiesis/main.go b/cmd/poiesis/main.go new file mode 100644 index 0000000..a4adca0 --- /dev/null +++ b/cmd/poiesis/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "fmt" + "os" + + "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") + os.Exit(1) + } + + // Print Types + if os.Args[1] == "-print-types" { + rt, err := runtime.New(context.Background()) + if err != nil { + panic(err) + } + fmt.Println(rt.GetTypeDeclarations()) + return + } + + // Create Runtime + rt, err := runtime.New(context.Background()) + if err != nil { + panic(err) + } + + // Run File + filePath := os.Args[1] + if err := rt.RunFile(filePath); err != nil { + panic(err) + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..beb68bf --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1769318308, + "narHash": "sha256-Mjx6p96Pkefks3+aA+72lu1xVehb6mv2yTUUqmSet6Q=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1cd347bf3355fce6c64ab37d3967b4a2cb4b878c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2105dc7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + description = "Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = ( + import nixpkgs { + system = system; + } + ); + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # Backend + go + gopls + golangci-lint + + tree + ]; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4b96c3e --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module reichard.io/poiesis + +go 1.25.5 + +require ( + github.com/evanw/esbuild v0.27.2 + github.com/fastschema/qjs v0.0.6 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/sys v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f1e663f --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanw/esbuild v0.27.2 h1:3xBEws9y/JosfewXMM2qIyHAi+xRo8hVx475hVkJfNg= +github.com/evanw/esbuild v0.27.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fastschema/qjs v0.0.6 h1:C45KMmQMd21UwsUAmQHxUxiWOfzwTg1GJW0DA0AbFEE= +github.com/fastschema/qjs v0.0.6/go.mod h1:bbg36wxXnx8g0FdKIe5+nCubrQvHa7XEVWqUptjHt/A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/functions/registry.go b/internal/functions/registry.go new file mode 100644 index 0000000..21c77e4 --- /dev/null +++ b/internal/functions/registry.go @@ -0,0 +1,115 @@ +package functions + +import ( + "fmt" + "reflect" + "strings" + "sync" + + "reichard.io/poiesis/internal/tsconvert" +) + +var ( + functionRegistry = make(map[string]Function) + registryMutex sync.RWMutex +) + +func registerFunction[A Args, R any](name string, isAsync bool, fn GoFunc[A, R]) { + // Lock Registry + registryMutex.Lock() + defer registryMutex.Unlock() + + // 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 := tsconvert.CollectTypes(tType, fnType) + definition := tsconvert.GenerateFunctionDecl(name, tType, fnType, isAsync) + + // Register Function + functionRegistry[name] = &functionImpl[A, R]{ + name: name, + fn: fn, + 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 + + for _, fn := range functionRegistry { + for _, t := range fn.Types() { + if !typeDefinitions[t] { + typeDefinitions[t] = true + typeDefs = append(typeDefs, t) + } + } + functionDecls = append(functionDecls, fn.Definition()) + } + + // Build Result + result := strings.Join(typeDefs, "\n\n") + if len(result) > 0 && len(functionDecls) > 0 { + result += "\n\n" + } + result += strings.Join(functionDecls, "\n") + + return result +} + +// 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 + } + return result +} + +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 new file mode 100644 index 0000000..040c295 --- /dev/null +++ b/internal/functions/types.go @@ -0,0 +1,92 @@ +package functions + +import ( + "context" + "errors" + "reflect" +) + +type Function interface { + Name() string + Types() map[string]string + Definition() string + IsAsync() bool + Arguments() []reflect.Type + Call(context.Context, []any) (any, error) +} + +type GoFunc[A Args, R any] func(context.Context, A) (R, error) + +type Args interface { + Validate() error +} + +type functionImpl[A Args, R any] struct { + name string + fn GoFunc[A, R] + definition string + types map[string]string + isAsync bool +} + +func (b *functionImpl[A, R]) Name() string { + return b.name +} + +func (b *functionImpl[A, R]) Types() map[string]string { + return b.types +} + +func (b *functionImpl[A, R]) Definition() string { + return b.definition +} + +func (b *functionImpl[A, R]) IsAsync() bool { + return b.isAsync +} + +func (b *functionImpl[A, R]) Function() any { + return b.fn +} + +func (b *functionImpl[A, R]) Arguments() []reflect.Type { + // Collect Argument Types + var allTypes []reflect.Type + + rType := reflect.TypeFor[A]() + for i := range rType.NumField() { + allTypes = append(allTypes, rType.Field(i).Type) + } + + return allTypes +} + +func (b *functionImpl[A, R]) Call(ctx context.Context, allArgs []any) (any, error) { + return b.CallGeneric(ctx, allArgs) +} + +func (b *functionImpl[A, R]) CallGeneric(ctx context.Context, allArgs []any) (zeroR R, err error) { + // Populate Arguments + var fnArgs A + aVal := reflect.ValueOf(&fnArgs).Elem() + + // Populate Fields + 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_test.go b/internal/functions/typescript_test.go new file mode 100644 index 0000000..491e47c --- /dev/null +++ b/internal/functions/typescript_test.go @@ -0,0 +1,272 @@ +package functions + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +type TestBasicArgs struct { + Name string `json:"name"` + Age int `json:"age"` +} + +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) +} + +var ( + registryLock sync.Mutex +) + +type TestComplexArgs struct { + Items []int `json:"items"` + Data map[string]any `json:"data"` + Flag bool `json:"flag"` +} + +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;") +} + +type TestNestedArgs struct { + User struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } `json:"user"` +} + +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;") +} + +type TestOptionalArgs struct { + Name string `json:"name"` + Age *int `json:"age,omitempty"` + Score *int `json:"score"` +} + +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;") +} + +type TestResult struct { + ID int `json:"id"` + Data []byte `json:"data"` +} + +type TestResultArgs struct { + Input string `json:"input"` +} + +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[]}") +} + +type TestAsyncArgs struct { + URL string `json:"url"` +} + +func (t TestAsyncArgs) Validate() error { return nil } + +type TestAsyncResult struct { + Status int `json:"status"` +} + +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") +} + +type TestAsyncStatus struct { + Code int `json:"code"` +} + +type TestNestedPointerResult struct { + Value string `json:"value"` +} + +type TestNestedPointerArgs struct { + ID int `json:"id"` +} + +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;") +} + +type TestUintArgs struct { + Value uint `json:"value"` +} + +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;") +} + +type TestFloatArgs struct { + Amount float64 `json:"amount"` +} + +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;") +} + +type TestPointerInArgs struct { + User *struct { + Name string `json:"name"` + } `json:"user"` +} + +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;") +} + +type TestErrorOnlyArgs struct { + Input string `json:"input"` +} + +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): {};") +} diff --git a/internal/runtime/options.go b/internal/runtime/options.go new file mode 100644 index 0000000..9213669 --- /dev/null +++ b/internal/runtime/options.go @@ -0,0 +1,19 @@ +package runtime + +import "io" + +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 new file mode 100644 index 0000000..e068fb6 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,180 @@ +package runtime + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/evanw/esbuild/pkg/api" + "github.com/fastschema/qjs" + + "reichard.io/poiesis/internal/functions" + _ "reichard.io/poiesis/internal/stdlib" +) + +type Runtime struct { + ctx *qjs.Context + opts qjs.Option + funcs map[string]functions.Function + typeDecls map[string]string +} + +func New(ctx context.Context, opts ...RuntimeOption) (*Runtime, error) { + // Create Runtime + r := &Runtime{opts: qjs.Option{Context: ctx}} + for _, opt := range opts { + opt(r) + } + + // Create QuickJS Context + rt, err := qjs.New(r.opts) + if err != nil { + return nil, err + } + r.ctx = rt.Context() + + // Populate Globals + if err := r.populateGlobals(); err != nil { + return nil, err + } + + return r, nil +} + +func (r *Runtime) populateGlobals() error { + // 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) + if err != nil { + _ = this.Promise().Reject(this.Context().NewError(err)) + return + } + _ = this.Promise().Resolve(qjsVal) + }) + } else { + r.ctx.SetFunc(name, func(this *qjs.This) (*qjs.Value, error) { + return callFunc(this, fn) + }) + } + } + + 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 { + return fmt.Errorf("error reading file: %w", err) + } + + return r.RunCode(string(tsFileContent)) +} + +func (r *Runtime) RunCode(tsCode string) error { + transformedCode, err := r.transformCode(tsCode) + if err != nil { + return err + } + + result, err := r.ctx.Eval("code.ts", qjs.Code(string(transformedCode))) + if result != nil { + result.Free() + } + return err +} + +func (r *Runtime) transformCode(tsCode string) ([]byte, error) { + result := api.Transform(tsCode, api.TransformOptions{ + Loader: api.LoaderTS, + Target: api.ES2022, + Format: api.FormatIIFE, + Sourcemap: api.SourceMapNone, + TreeShaking: api.TreeShakingFalse, + }) + + if len(result.Errors) > 0 { + var b strings.Builder + for i, e := range result.Errors { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(e.Text) + } + return nil, fmt.Errorf("transpilation failed: %s", b.String()) + } + + return result.Code, nil +} + +func callFunc(this *qjs.This, fn functions.Function) (*qjs.Value, error) { + qjsArgs := this.Args() + fnArgs := fn.Arguments() + + var allArgs []any + for i := range min(len(fnArgs), len(qjsArgs)) { + rVal, err := qjs.JsArgToGo(qjsArgs[i], fnArgs[i]) + if err != nil { + return nil, fmt.Errorf("argument conversion failed: %w", err) + } + allArgs = append(allArgs, rVal.Interface()) + } + + result, err := fn.Call(this.Context(), allArgs) + if err != nil { + return nil, err + } + + val, err := qjs.ToJsValue(this.Context(), result) + if err != nil { + return nil, err + } + + return val, nil +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 0000000..7e79576 --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,163 @@ +package runtime + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + + "github.com/fastschema/qjs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "reichard.io/poiesis/internal/functions" +) + +type TestArgs struct { + Field1 string `json:"field1"` +} + +func (t TestArgs) Validate() error { + return nil +} + +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; + email: string; +} + +function greet(person: Person): string { + return "Hello, " + person.name + "! You are " + person.age + " years old."; +} + +const user: Person = { + name: "Alice", + age: 30, + email: "alice@example.com", +}; + +console.log(greet(user)); +console.log("Email: " + user.email); + +function calculateSum(a: number, b: number): number { + return a + b; +} + +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) + + // 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) + + // 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 new file mode 100644 index 0000000..74b6a4a --- /dev/null +++ b/internal/stdlib/fetch.go @@ -0,0 +1,102 @@ +package stdlib + +import ( + "context" + "fmt" + "io" + "maps" + "net/http" + "strings" + + "reichard.io/poiesis/internal/functions" +) + +func init() { + functions.RegisterAsyncFunction("fetch", Fetch) +} + +type FetchArgs struct { + Input string `json:"input"` + Init *RequestInit `json:"init,omitempty"` +} + +func (f FetchArgs) Validate() error { + return nil +} + +type RequestInit struct { + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body *string `json:"body,omitempty"` +} + +func (o *RequestInit) Validate() error { + return nil +} + +type Response struct { + OK bool `json:"ok"` + Status int `json:"status"` + Body string `json:"body"` + Headers map[string]string `json:"headers"` +} + +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 + } + if args.Init.Headers != nil { + maps.Copy(headers, args.Init.Headers) + } + } + + // 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) + } + defer func() { + _ = 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 { + val := values[0] + resultHeaders[key] = val + resultHeaders[strings.ToLower(key)] = val + } + } + + // Return Response + return Response{ + OK: resp.StatusCode >= 200 && resp.StatusCode < 300, + Status: resp.StatusCode, + Body: string(body), + Headers: resultHeaders, + }, nil +} diff --git a/internal/stdlib/fetch_test.go b/internal/stdlib/fetch_test.go new file mode 100644 index 0000000..4946670 --- /dev/null +++ b/internal/stdlib/fetch_test.go @@ -0,0 +1,130 @@ +package stdlib + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok","message":"Hello from httptest"}`)) + })) + 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") + 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) { + // 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) + _, _ = w.Write([]byte(`{"args":{}}`)) + })) + 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"`) + assert.Equal(t, "application/json", result.Headers["Content-Type"]) +} + +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) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + + // Configure Request Options + headers := map[string]string{ + "Authorization": "Bearer test-token", + } + options := &RequestInit{ + 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) + _, _ = w.Write([]byte(`ok`)) + })) + defer server.Close() + + // Execute Fetch with Empty Options + options := &RequestInit{} + result, err := Fetch(ctx, FetchArgs{Input: server.URL, Init: options}) + require.NoError(t, err) + assert.True(t, result.OK) +} 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 "" +}