From 440f0a83783a62fe7ff1f4fa464f662e4a7e1d92 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Wed, 5 Mar 2025 19:17:43 -0600 Subject: [PATCH] luarouter --- core/routers/luarouter.go | 276 +++++++++++++++++++++++++++++++++ core/routers/luarouter_test.go | 256 ++++++++++++++++++++++++++++++ go.mod | 7 + moonshark.go | 7 + 4 files changed, 546 insertions(+) create mode 100644 core/routers/luarouter.go create mode 100644 core/routers/luarouter_test.go create mode 100644 go.mod create mode 100644 moonshark.go diff --git a/core/routers/luarouter.go b/core/routers/luarouter.go new file mode 100644 index 0000000..4e7595f --- /dev/null +++ b/core/routers/luarouter.go @@ -0,0 +1,276 @@ +package routers + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + + luajit "git.sharkk.net/Sky/LuaJIT-to-Go" +) + +// Maximum number of URL parameters per route +const maxParams = 20 + +// LuaRouter is a filesystem-based HTTP router for Lua files +type LuaRouter struct { + routesDir string // Root directory containing route files + routes map[string]*node // Method -> route tree + mu sync.RWMutex // Lock for concurrent access to routes +} + +// node represents a node in the routing trie +type node struct { + handler string // Path to Lua file (empty if not an endpoint) + bytecode []byte // Pre-compiled Lua bytecode + paramName string // Parameter name (if this is a parameter node) + staticChild map[string]*node // Static children by segment name + paramChild *node // Parameter/wildcard child +} + +// Params holds URL parameters with fixed-size arrays to avoid allocations +type Params struct { + Keys [maxParams]string + Values [maxParams]string + Count int +} + +// Get returns a parameter value by name +func (p *Params) Get(name string) string { + for i := 0; i < p.Count; i++ { + if p.Keys[i] == name { + return p.Values[i] + } + } + return "" +} + +// NewLuaRouter creates a new LuaRouter instance +func NewLuaRouter(routesDir string) (*LuaRouter, error) { + // Verify routes directory exists + info, err := os.Stat(routesDir) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, errors.New("routes path is not a directory") + } + + r := &LuaRouter{ + routesDir: routesDir, + routes: make(map[string]*node), + } + + // Initialize method trees + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"} + for _, method := range methods { + r.routes[method] = &node{ + staticChild: make(map[string]*node), + } + } + + // Build routes + if err := r.buildRoutes(); err != nil { + return nil, err + } + + return r, nil +} + +// buildRoutes scans the routes directory and builds the routing tree +func (r *LuaRouter) buildRoutes() error { + return filepath.Walk(r.routesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Only process .lua files + if !strings.HasSuffix(info.Name(), ".lua") { + return nil + } + + // Extract method from filename + method := strings.ToUpper(strings.TrimSuffix(info.Name(), ".lua")) + + // Check if valid method + root, exists := r.routes[method] + if !exists { + return nil // Skip invalid methods + } + + // Get relative path for URL + relDir, err := filepath.Rel(r.routesDir, filepath.Dir(path)) + if err != nil { + return err + } + + // Build URL path + urlPath := "/" + if relDir != "." { + urlPath = "/" + strings.ReplaceAll(relDir, "\\", "/") + } + + // Add route to tree + return r.addRoute(root, urlPath, path) + }) +} + +// addRoute adds a route to the routing tree and compiles the Lua file to bytecode +func (r *LuaRouter) addRoute(root *node, urlPath, handlerPath string) error { + segments := strings.Split(strings.Trim(urlPath, "/"), "/") + current := root + + for _, segment := range segments { + if len(segment) >= 2 && segment[0] == '[' && segment[len(segment)-1] == ']' { + if current.paramChild == nil { + current.paramChild = &node{ + paramName: segment[1 : len(segment)-1], + staticChild: make(map[string]*node), + } + } + current = current.paramChild + } else { + // Create or get static child + child, exists := current.staticChild[segment] + if !exists { + child = &node{ + staticChild: make(map[string]*node), + } + current.staticChild[segment] = child + } + current = child + } + } + + // Set handler path + current.handler = handlerPath + + // Compile Lua file to bytecode + if err := r.compileHandler(current); err != nil { + return err + } + + return nil +} + +// Match finds a handler for the given method and path +// Uses the pre-allocated params struct to avoid allocations +func (r *LuaRouter) Match(method, path string, params *Params) (*node, bool) { + // Reset params + params.Count = 0 + + // Get route tree for method + r.mu.RLock() + root, exists := r.routes[method] + r.mu.RUnlock() + + if !exists { + return nil, false + } + + // Split path + segments := strings.Split(strings.Trim(path, "/"), "/") + + // Match path + return r.matchPath(root, segments, params, 0) +} + +// matchPath recursively matches a path against the routing tree +func (r *LuaRouter) matchPath(current *node, segments []string, params *Params, depth int) (*node, bool) { + // Base case: no more segments + if len(segments) == 0 { + if current.handler != "" { + return current, true + } + return nil, false + } + + segment := segments[0] + remaining := segments[1:] + + // Try static child first (exact match takes precedence) + if child, exists := current.staticChild[segment]; exists { + if node, found := r.matchPath(child, remaining, params, depth+1); found { + return node, true + } + } + + // Try parameter child + if current.paramChild != nil { + // Store parameter + if params.Count < maxParams { + params.Keys[params.Count] = current.paramChild.paramName + params.Values[params.Count] = segment + params.Count++ + } + + if node, found := r.matchPath(current.paramChild, remaining, params, depth+1); found { + return node, true + } + + // Backtrack: remove parameter if no match + params.Count-- + } + + return nil, false +} + +// compileHandler compiles a Lua file to bytecode +func (r *LuaRouter) compileHandler(n *node) error { + if n.handler == "" { + return nil + } + + // Read the Lua file + content, err := os.ReadFile(n.handler) + if err != nil { + return err + } + + // Compile to bytecode + state := luajit.New() + if state == nil { + return errors.New("failed to create Lua state") + } + defer state.Close() + + bytecode, err := state.CompileBytecode(string(content), n.handler) + if err != nil { + return err + } + + // Store bytecode in the node + n.bytecode = bytecode + return nil +} + +// GetBytecode returns the compiled bytecode for a matched route +func (r *LuaRouter) GetBytecode(method, path string, params *Params) ([]byte, bool) { + node, found := r.Match(method, path, params) + if !found { + return nil, false + } + return node.bytecode, true +} + +// Refresh rebuilds the router by rescanning the routes directory +func (r *LuaRouter) Refresh() error { + r.mu.Lock() + defer r.mu.Unlock() + + // Reset routes + for method := range r.routes { + r.routes[method] = &node{ + staticChild: make(map[string]*node), + } + } + + // Rebuild routes + return r.buildRoutes() +} diff --git a/core/routers/luarouter_test.go b/core/routers/luarouter_test.go new file mode 100644 index 0000000..8fc336d --- /dev/null +++ b/core/routers/luarouter_test.go @@ -0,0 +1,256 @@ +package routers + +import ( + "os" + "path/filepath" + "testing" +) + +func setupTestRoutes(t *testing.T) (string, func()) { + // Create a temporary directory for test routes + tempDir, err := os.MkdirTemp("", "fsrouter-test") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Create route structure with valid Lua code + routes := map[string]string{ + "get.lua": "return { path = '/' }", + "post.lua": "return { path = '/' }", + "api/get.lua": "return { path = '/api' }", + "api/users/get.lua": "return { path = '/api/users' }", + "api/users/[id]/get.lua": "return { path = '/api/users/[id]' }", + "api/users/[id]/posts/get.lua": "return { path = '/api/users/[id]/posts' }", + "api/[version]/docs/get.lua": "return { path = '/api/[version]/docs' }", + } + + for path, content := range routes { + routePath := filepath.Join(tempDir, path) + + // Create directories + err := os.MkdirAll(filepath.Dir(routePath), 0755) + if err != nil { + t.Fatalf("Failed to create directory %s: %v", filepath.Dir(routePath), err) + } + + // Create file + err = os.WriteFile(routePath, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create file %s: %v", routePath, err) + } + } + + // Return cleanup function + cleanup := func() { + os.RemoveAll(tempDir) + } + + return tempDir, cleanup +} + +func TestRouterInitialization(t *testing.T) { + routesDir, cleanup := setupTestRoutes(t) + defer cleanup() + + router, err := NewLuaRouter(routesDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + if router == nil { + t.Fatal("Router is nil") + } +} + +func TestRouteMatching(t *testing.T) { + routesDir, cleanup := setupTestRoutes(t) + defer cleanup() + + router, err := NewLuaRouter(routesDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + tests := []struct { + method string + path string + wantFound bool + wantParams map[string]string + wantHandler string + }{ + // Static routes + {"GET", "/", true, nil, filepath.Join(routesDir, "get.lua")}, + {"POST", "/", true, nil, filepath.Join(routesDir, "post.lua")}, + {"GET", "/api", true, nil, filepath.Join(routesDir, "api/get.lua")}, + {"GET", "/api/users", true, nil, filepath.Join(routesDir, "api/users/get.lua")}, + + // Parameterized routes + {"GET", "/api/users/123", true, map[string]string{"id": "123"}, filepath.Join(routesDir, "api/users/[id]/get.lua")}, + {"GET", "/api/users/456/posts", true, map[string]string{"id": "456"}, filepath.Join(routesDir, "api/users/[id]/posts/get.lua")}, + {"GET", "/api/v1/docs", true, map[string]string{"version": "v1"}, filepath.Join(routesDir, "api/[version]/docs/get.lua")}, + + // Non-existent routes + {"PUT", "/", false, nil, ""}, + {"GET", "/nonexistent", false, nil, ""}, + {"GET", "/api/nonexistent", false, nil, ""}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + var params Params + node, found := router.Match(tt.method, tt.path, ¶ms) + + if found != tt.wantFound { + t.Errorf("Match() found = %v, want %v", found, tt.wantFound) + } + + if !found { + return + } + + if node.handler != tt.wantHandler { + t.Errorf("Match() handler = %v, want %v", node.handler, tt.wantHandler) + } + + // Verify bytecode was compiled + if len(node.bytecode) == 0 { + t.Errorf("No bytecode found for handler: %s", node.handler) + } + + // Verify parameters + if tt.wantParams != nil { + for key, wantValue := range tt.wantParams { + gotValue := params.Get(key) + if gotValue != wantValue { + t.Errorf("Parameter %s = %s, want %s", key, gotValue, wantValue) + } + } + } + }) + } +} + +func TestParamExtraction(t *testing.T) { + routesDir, cleanup := setupTestRoutes(t) + defer cleanup() + + router, err := NewLuaRouter(routesDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + var params Params + _, found := router.Match("GET", "/api/v2/docs", ¶ms) + + if !found { + t.Fatalf("Route not found") + } + + if params.Count != 1 { + t.Errorf("Expected 1 parameter, got %d", params.Count) + } + + if params.Keys[0] != "version" { + t.Errorf("Expected parameter key 'version', got '%s'", params.Keys[0]) + } + + if params.Values[0] != "v2" { + t.Errorf("Expected parameter value 'v2', got '%s'", params.Values[0]) + } + + if params.Get("version") != "v2" { + t.Errorf("Get(\"version\") returned '%s', expected 'v2'", params.Get("version")) + } +} + +func TestGetBytecode(t *testing.T) { + routesDir, cleanup := setupTestRoutes(t) + defer cleanup() + + router, err := NewLuaRouter(routesDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + var params Params + bytecode, found := router.GetBytecode("GET", "/api/users/123", ¶ms) + + if !found { + t.Fatalf("Route not found") + } + + if len(bytecode) == 0 { + t.Errorf("Expected non-empty bytecode") + } + + // Check parameters were extracted + if params.Get("id") != "123" { + t.Errorf("Expected id parameter '123', got '%s'", params.Get("id")) + } +} + +func TestRefresh(t *testing.T) { + routesDir, cleanup := setupTestRoutes(t) + defer cleanup() + + router, err := NewLuaRouter(routesDir) + if err != nil { + t.Fatalf("Failed to create router: %v", err) + } + + // Add a new route file + newRoutePath := filepath.Join(routesDir, "new", "get.lua") + err = os.MkdirAll(filepath.Dir(newRoutePath), 0755) + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + err = os.WriteFile(newRoutePath, []byte("return { path = '/new' }"), 0644) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + + // Before refresh, route should not be found + var params Params + _, found := router.GetBytecode("GET", "/new", ¶ms) + if found { + t.Errorf("New route should not be found before refresh") + } + + // Refresh router + err = router.Refresh() + if err != nil { + t.Fatalf("Failed to refresh router: %v", err) + } + + // After refresh, route should be found + bytecode, found := router.GetBytecode("GET", "/new", ¶ms) + if !found { + t.Errorf("New route should be found after refresh") + } + + if len(bytecode) == 0 { + t.Errorf("Expected non-empty bytecode for new route") + } +} + +func TestInvalidRoutesDir(t *testing.T) { + // Non-existent directory + _, err := NewLuaRouter("/non/existent/directory") + if err == nil { + t.Error("Expected error for non-existent directory, got nil") + } + + // Create a file instead of a directory + tmpFile, err := os.CreateTemp("", "fsrouter-test-file") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + _, err = NewLuaRouter(tmpFile.Name()) + if err == nil { + t.Error("Expected error for file as routes dir, got nil") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e114b05 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.sharkk.net/Sky/Moonshark + +go 1.24.1 + +require git.sharkk.net/Sky/LuaJIT-to-Go v0.0.0 + +replace git.sharkk.net/Sky/LuaJIT-to-Go => ./luajit diff --git a/moonshark.go b/moonshark.go new file mode 100644 index 0000000..f7b60bd --- /dev/null +++ b/moonshark.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello, world!") +}