441 lines
11 KiB
Go
441 lines
11 KiB
Go
package runner
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"maps"
|
|
|
|
luajit "git.sharkk.net/Sky/LuaJIT-to-Go"
|
|
)
|
|
|
|
// CachedTemplate holds compiled Lua code with metadata
|
|
type CachedTemplate struct {
|
|
CompiledLua []byte // Compiled Lua bytecode
|
|
ModTime time.Time
|
|
Path string
|
|
}
|
|
|
|
// TemplateCache manages template caching with fast lookups
|
|
type TemplateCache struct {
|
|
templates sync.Map // map[string]*CachedTemplate
|
|
}
|
|
|
|
var (
|
|
templateCache = &TemplateCache{}
|
|
simpleVarRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
|
)
|
|
|
|
// htmlEscape escapes HTML special characters
|
|
func htmlEscape(s string) string {
|
|
return html.EscapeString(s)
|
|
}
|
|
|
|
// compileTemplate converts template string to Lua code
|
|
func compileTemplate(templateStr string) (string, error) {
|
|
pos := 1
|
|
chunks := []any{}
|
|
templateLen := len(templateStr)
|
|
|
|
for pos <= templateLen {
|
|
// Find next template tag
|
|
unescapedStart := strings.Index(templateStr[pos-1:], "{{{")
|
|
escapedStart := strings.Index(templateStr[pos-1:], "{{")
|
|
|
|
// Adjust positions to be absolute
|
|
if unescapedStart != -1 {
|
|
unescapedStart += pos - 1
|
|
}
|
|
if escapedStart != -1 {
|
|
escapedStart += pos - 1
|
|
}
|
|
|
|
var start, openLen int
|
|
var tagType string
|
|
|
|
// Determine which tag comes first
|
|
if unescapedStart != -1 && (escapedStart == -1 || unescapedStart <= escapedStart) {
|
|
start, tagType, openLen = unescapedStart, "-", 3
|
|
} else if escapedStart != -1 {
|
|
start, tagType, openLen = escapedStart, "=", 2
|
|
} else {
|
|
// No more tags, add remaining text
|
|
if pos <= templateLen {
|
|
chunks = append(chunks, templateStr[pos-1:])
|
|
}
|
|
break
|
|
}
|
|
|
|
// Add text before tag
|
|
if start > pos-1 {
|
|
chunks = append(chunks, templateStr[pos-1:start])
|
|
}
|
|
|
|
// Find closing tag
|
|
pos = start + openLen + 1
|
|
var closeTag string
|
|
if tagType == "-" {
|
|
closeTag = "}}}"
|
|
} else {
|
|
closeTag = "}}"
|
|
}
|
|
|
|
closeStart := strings.Index(templateStr[pos-1:], closeTag)
|
|
if closeStart == -1 {
|
|
return "", fmt.Errorf("failed to find closing tag at position %d", pos)
|
|
}
|
|
closeStart += pos - 1
|
|
|
|
// Extract and trim code
|
|
code := strings.TrimSpace(templateStr[pos-1 : closeStart])
|
|
|
|
// Check if it's a simple variable for escaped output
|
|
isSimpleVar := tagType == "=" && simpleVarRe.MatchString(code)
|
|
|
|
chunks = append(chunks, []any{tagType, code, pos, isSimpleVar})
|
|
|
|
// Move past closing tag
|
|
pos = closeStart + len(closeTag) + 1
|
|
}
|
|
|
|
// Generate Lua code
|
|
buffer := []string{"local _tostring, _escape, _b, _b_i = ...\n"}
|
|
|
|
for _, chunk := range chunks {
|
|
switch v := chunk.(type) {
|
|
case string:
|
|
// Literal text
|
|
buffer = append(buffer, "_b_i = _b_i + 1\n")
|
|
buffer = append(buffer, fmt.Sprintf("_b[_b_i] = %q\n", v))
|
|
case []any:
|
|
tagType := v[0].(string)
|
|
code := v[1].(string)
|
|
pos := v[2].(int)
|
|
isSimpleVar := v[3].(bool)
|
|
|
|
switch tagType {
|
|
case "=":
|
|
if isSimpleVar {
|
|
buffer = append(buffer, "_b_i = _b_i + 1\n")
|
|
buffer = append(buffer, fmt.Sprintf("--[[%d]] _b[_b_i] = _escape(_tostring(%s))\n", pos, code))
|
|
} else {
|
|
buffer = append(buffer, fmt.Sprintf("--[[%d]] %s\n", pos, code))
|
|
}
|
|
case "-":
|
|
buffer = append(buffer, "_b_i = _b_i + 1\n")
|
|
buffer = append(buffer, fmt.Sprintf("--[[%d]] _b[_b_i] = _tostring(%s)\n", pos, code))
|
|
}
|
|
}
|
|
}
|
|
|
|
buffer = append(buffer, "return _b")
|
|
return strings.Join(buffer, ""), nil
|
|
}
|
|
|
|
// getTemplate loads or retrieves a cached template
|
|
func getTemplate(templatePath string, state *luajit.State) ([]byte, error) {
|
|
// Resolve the path using the fs system
|
|
fullPath, err := ResolvePath(templatePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template path resolution failed: %w", err)
|
|
}
|
|
|
|
// Check if file exists and get mod time
|
|
info, err := os.Stat(fullPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template file not found: %w", err)
|
|
}
|
|
|
|
// Fast path: check cache first
|
|
if cached, ok := templateCache.templates.Load(templatePath); ok {
|
|
cachedTpl := cached.(*CachedTemplate)
|
|
// Compare mod times for cache validity
|
|
if cachedTpl.ModTime.Equal(info.ModTime()) {
|
|
return cachedTpl.CompiledLua, nil
|
|
}
|
|
}
|
|
|
|
// Cache miss or file changed - load and compile template
|
|
content, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read template: %w", err)
|
|
}
|
|
|
|
// Compile template to Lua code
|
|
luaCode, err := compileTemplate(string(content))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template compile error: %w", err)
|
|
}
|
|
|
|
// Compile Lua code to bytecode
|
|
bytecode, err := state.CompileBytecode(luaCode, templatePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lua compile error: %w", err)
|
|
}
|
|
|
|
// Store in cache
|
|
cachedTpl := &CachedTemplate{
|
|
CompiledLua: bytecode,
|
|
ModTime: info.ModTime(),
|
|
Path: fullPath,
|
|
}
|
|
templateCache.templates.Store(templatePath, cachedTpl)
|
|
|
|
return bytecode, nil
|
|
}
|
|
|
|
// goHtmlEscape provides HTML escaping from Go
|
|
func goHtmlEscape(state *luajit.State) int {
|
|
if !state.IsString(1) {
|
|
state.PushString("")
|
|
return 1
|
|
}
|
|
|
|
input := state.ToString(1)
|
|
escaped := htmlEscape(input)
|
|
state.PushString(escaped)
|
|
return 1
|
|
}
|
|
|
|
// templateInclude renders a template with auto-merged data
|
|
func templateInclude(state *luajit.State) int {
|
|
// Get template path
|
|
if !state.IsString(1) {
|
|
state.PushString("template.include: path must be a string")
|
|
return 1
|
|
}
|
|
templatePath := state.ToString(1)
|
|
|
|
// Get current template data
|
|
state.GetGlobal("__template_data")
|
|
currentData, _ := state.ToTable(-1)
|
|
state.Pop(1)
|
|
|
|
// Get new data (optional)
|
|
var newData map[string]any
|
|
if state.GetTop() >= 2 && !state.IsNil(2) {
|
|
if envValue, err := state.ToValue(2); err == nil {
|
|
if envMap, ok := envValue.(map[string]any); ok {
|
|
newData = envMap
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge data
|
|
mergedData := make(map[string]any)
|
|
if currentData != nil {
|
|
maps.Copy(mergedData, currentData)
|
|
}
|
|
if newData != nil {
|
|
maps.Copy(mergedData, newData)
|
|
}
|
|
|
|
// Call templateRender with merged data
|
|
state.PushString(templatePath)
|
|
state.PushTable(mergedData)
|
|
return templateRender(state)
|
|
}
|
|
func templateRender(state *luajit.State) int {
|
|
// Get template path (required)
|
|
if !state.IsString(1) {
|
|
state.PushString("template.render: template path must be a string")
|
|
return 1
|
|
}
|
|
templatePath := state.ToString(1)
|
|
|
|
// Get data (optional)
|
|
var env map[string]any
|
|
if state.GetTop() >= 2 && !state.IsNil(2) {
|
|
var err error
|
|
envValue, err := state.ToValue(2)
|
|
if err != nil {
|
|
state.PushString("template.render: invalid data: " + err.Error())
|
|
return 1
|
|
}
|
|
if envMap, ok := envValue.(map[string]any); ok {
|
|
env = envMap
|
|
}
|
|
}
|
|
|
|
// Load compiled template from cache
|
|
bytecode, err := getTemplate(templatePath, state)
|
|
if err != nil {
|
|
state.PushString("template.render: " + err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Load bytecode
|
|
if err := state.LoadBytecode(bytecode, templatePath); err != nil {
|
|
state.PushString("template.render: load error: " + err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Create runtime environment
|
|
runtimeEnv := make(map[string]any)
|
|
if env != nil {
|
|
maps.Copy(runtimeEnv, env)
|
|
}
|
|
|
|
// Add current template data for nested calls
|
|
runtimeEnv["__template_data"] = env
|
|
|
|
// Get current global environment
|
|
state.GetGlobal("_G")
|
|
globalEnv, err := state.ToTable(-1)
|
|
if err == nil {
|
|
for k, v := range globalEnv {
|
|
if _, exists := runtimeEnv[k]; !exists {
|
|
runtimeEnv[k] = v
|
|
}
|
|
}
|
|
}
|
|
state.Pop(1)
|
|
|
|
// Set up runtime environment
|
|
if err := state.PushTable(runtimeEnv); err != nil {
|
|
state.Pop(1) // Pop bytecode
|
|
state.PushString("template.render: env error: " + err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Create metatable for environment
|
|
state.NewTable()
|
|
state.GetGlobal("_G")
|
|
state.SetField(-2, "__index")
|
|
state.SetMetatable(-2)
|
|
|
|
// Set environment using setfenv
|
|
state.GetGlobal("setfenv")
|
|
state.PushCopy(-3) // Template function
|
|
state.PushCopy(-3) // Environment
|
|
state.Call(2, 1) // setfenv(fn, env) returns the function
|
|
|
|
// Prepare arguments for template execution
|
|
state.GetGlobal("tostring") // tostring function
|
|
state.PushGoFunction(goHtmlEscape) // HTML escape function
|
|
state.NewTable() // output buffer
|
|
state.PushNumber(0) // buffer index
|
|
|
|
// Execute template (4 args, 1 result)
|
|
if err := state.Call(4, 1); err != nil {
|
|
state.Pop(1) // Pop environment
|
|
state.PushString("template.render: execution error: " + err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Get result buffer
|
|
buffer, err := state.ToTable(-1)
|
|
if err != nil {
|
|
state.Pop(2) // Pop buffer and environment
|
|
state.PushString("template.render: result error: " + err.Error())
|
|
return 1
|
|
}
|
|
|
|
// Convert buffer to string
|
|
var result strings.Builder
|
|
i := 1
|
|
for {
|
|
if val, exists := buffer[fmt.Sprintf("%d", i)]; exists {
|
|
result.WriteString(fmt.Sprintf("%v", val))
|
|
i++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
state.Pop(2) // Pop buffer and environment
|
|
state.PushString(result.String())
|
|
return 1
|
|
}
|
|
|
|
// templateExists checks if a template file exists
|
|
func templateExists(state *luajit.State) int {
|
|
if !state.IsString(1) {
|
|
state.PushBoolean(false)
|
|
return 1
|
|
}
|
|
templatePath := state.ToString(1)
|
|
|
|
// Resolve path
|
|
fullPath, err := ResolvePath(templatePath)
|
|
if err != nil {
|
|
state.PushBoolean(false)
|
|
return 1
|
|
}
|
|
|
|
// Check if file exists
|
|
_, err = os.Stat(fullPath)
|
|
state.PushBoolean(err == nil)
|
|
return 1
|
|
}
|
|
|
|
// templateClearCache clears the template cache
|
|
func templateClearCache(state *luajit.State) int {
|
|
// Optional: clear specific template
|
|
if state.GetTop() >= 1 && state.IsString(1) {
|
|
templatePath := state.ToString(1)
|
|
templateCache.templates.Delete(templatePath)
|
|
state.PushBoolean(true)
|
|
return 1
|
|
}
|
|
|
|
// Clear entire cache
|
|
templateCache.templates.Range(func(key, value any) bool {
|
|
templateCache.templates.Delete(key)
|
|
return true
|
|
})
|
|
|
|
state.PushBoolean(true)
|
|
return 1
|
|
}
|
|
|
|
// templateCacheSize returns the number of templates in cache
|
|
func templateCacheSize(state *luajit.State) int {
|
|
count := 0
|
|
templateCache.templates.Range(func(key, value any) bool {
|
|
count++
|
|
return true
|
|
})
|
|
|
|
state.PushNumber(float64(count))
|
|
return 1
|
|
}
|
|
|
|
// RegisterTemplateFunctions registers template functions with the Lua state
|
|
func RegisterTemplateFunctions(state *luajit.State) error {
|
|
if err := state.RegisterGoFunction("__template_render", templateRender); err != nil {
|
|
return err
|
|
}
|
|
if err := state.RegisterGoFunction("__template_include", templateInclude); err != nil {
|
|
return err
|
|
}
|
|
if err := state.RegisterGoFunction("__template_exists", templateExists); err != nil {
|
|
return err
|
|
}
|
|
if err := state.RegisterGoFunction("__template_clear_cache", templateClearCache); err != nil {
|
|
return err
|
|
}
|
|
if err := state.RegisterGoFunction("__template_cache_size", templateCacheSize); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanupTemplate clears the template cache
|
|
func CleanupTemplate() {
|
|
templateCache.templates.Range(func(key, value any) bool {
|
|
templateCache.templates.Delete(key)
|
|
return true
|
|
})
|
|
}
|
|
|
|
// InvalidateTemplate removes a specific template from cache
|
|
func InvalidateTemplate(templatePath string) {
|
|
templateCache.templates.Delete(templatePath)
|
|
}
|