initial commit
This commit is contained in:
138
AGENTS.md
Normal file
138
AGENTS.md
Normal file
@@ -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<R>` 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...
|
||||||
|
```
|
||||||
158
README.md
Normal file
158
README.md
Normal file
@@ -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 <typescript-file>
|
||||||
|
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
|
||||||
40
cmd/poiesis/main.go
Normal file
40
cmd/poiesis/main.go
Normal file
@@ -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 <typescript-file>")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -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
|
||||||
|
}
|
||||||
37
flake.nix
Normal file
37
flake.nix
Normal file
@@ -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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
19
go.sum
Normal file
19
go.sum
Normal file
@@ -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=
|
||||||
115
internal/functions/registry.go
Normal file
115
internal/functions/registry.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
92
internal/functions/types.go
Normal file
92
internal/functions/types.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
272
internal/functions/typescript_test.go
Normal file
272
internal/functions/typescript_test.go
Normal file
@@ -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<string, any>, 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<TestAsyncStatus | null>;")
|
||||||
|
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): {};")
|
||||||
|
}
|
||||||
19
internal/runtime/options.go
Normal file
19
internal/runtime/options.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
180
internal/runtime/runtime.go
Normal file
180
internal/runtime/runtime.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
163
internal/runtime/runtime_test.go
Normal file
163
internal/runtime/runtime_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
102
internal/stdlib/fetch.go
Normal file
102
internal/stdlib/fetch.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
130
internal/stdlib/fetch_test.go
Normal file
130
internal/stdlib/fetch_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
242
internal/tsconvert/convert.go
Normal file
242
internal/tsconvert/convert.go
Normal file
@@ -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<string, any>"
|
||||||
|
} else {
|
||||||
|
baseType = "Record<string, any>"
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
333
internal/tsconvert/convert_test.go
Normal file
333
internal/tsconvert/convert_test.go
Normal file
@@ -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<string, any>"},
|
||||||
|
{"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<string, any>")
|
||||||
|
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<SimpleStruct>;", 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
92
internal/tsconvert/types.go
Normal file
92
internal/tsconvert/types.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user