Files
avorion-docgen/class.go

541 lines
18 KiB
Go

package main
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/PuerkitoBio/goquery"
)
//go:embed class.tmpl
var templatestr string
var classTemplate *template.Template
var fns = template.FuncMap{
"plus1": func(x int) int {
return x + 1
},
"sub": func(x, y int) int {
return x - y
},
"truncateComment": func(comment string, maxLen int) string {
if len(comment) <= maxLen {
return comment
}
// Find the last space before maxLen
truncated := comment[:maxLen]
lastSpace := strings.LastIndex(truncated, " ")
if lastSpace > maxLen/2 {
return truncated[:lastSpace]
}
return truncated
},
}
func init() {
log := logger.Default.WithPrefix("init")
log.Info("Initializing template")
var err error
classTemplate, err = template.New("class").Funcs(fns).Parse(templatestr)
if err != nil {
logger.Error("Error parsing template: %v", err)
return
}
log.Info("Template initialized successfully")
}
type (
Class struct {
ClassName string
Inheritance string
Fields []Field
Methods []Method
Constructors []Constructor
}
Field struct {
Name string
Type string
Comment string
}
Method struct {
Name string
Params []Param
Returns []Return
Comment string
}
Param struct {
Name string
Type string
Comment string
}
Return struct {
Type string
Comment string
}
Constructor struct {
Params []Param
Comment string
}
)
func (c *Class) GetOutFile(root string) (*os.File, error) {
log := logger.Default.WithPrefix("GetOutFile")
log.Debug("Getting output file for class: %s", c.ClassName)
if c.ClassName == "" {
log.Error("ClassName is empty")
return nil, fmt.Errorf("ClassName is empty")
}
filename := fmt.Sprintf("%s.lua", c.ClassName)
log.Trace("Original filename: %s", filename)
filename = strings.ReplaceAll(filename, " ", "")
filename = strings.ReplaceAll(filename, "-", "")
filename = strings.ReplaceAll(filename, ",", "")
filename = strings.ReplaceAll(filename, ":", "")
log.Trace("Cleaned filename: %s", filename)
filePath := filepath.Join(root, filename)
filePath = filepath.Clean(filePath)
log.Debug("Full file path: %s", filePath)
f, err := os.Create(filePath)
if err != nil {
log.Error("Error creating file: %v", err)
return nil, err
}
log.Info("Successfully created output file: %s", filePath)
return f, nil
}
func (c *Class) Write(root string, tmpl *template.Template) error {
log := logger.Default.WithPrefix("Write")
log.Info("Writing class %s to output", c.ClassName)
outfile, err := c.GetOutFile(root)
if err != nil {
log.Error("Error creating output file %v: %v", c.ClassName, err)
return fmt.Errorf("error creating output file %v: %v", c.ClassName, err)
}
log.Debug("Executing template for class: %s", c.ClassName)
err = tmpl.Execute(outfile, c)
if err != nil {
log.Error("Error writing output file %v: %v", c.ClassName, err)
return fmt.Errorf("error writing output file %v: %v", c.ClassName, err)
}
log.Info("Successfully wrote class %s to output", c.ClassName)
return nil
}
func ParseClass(file string) (*Class, error) {
log := logger.Default.WithPrefix(file)
log.Info("Starting to parse file")
res := Class{
Fields: []Field{},
Methods: []Method{},
Constructors: []Constructor{},
}
log.Debug("Opening file: %s", file)
filehandle, err := os.Open(file)
if err != nil {
log.Error("Error opening file: %v", err)
return nil, fmt.Errorf("error opening file: %w", err)
}
log.Debug("Creating goquery document from file")
doc, err := goquery.NewDocumentFromReader(filehandle)
if err != nil {
log.Error("Error parsing file: %v", err)
return nil, fmt.Errorf("error parsing file: %w", err)
}
log.Debug("Looking for class name")
class := doc.Find("div.floatright > h1")
if class.Length() == 0 {
log.Error("No class found in document")
return nil, fmt.Errorf("no class found")
}
className := strings.TrimSpace(class.Text())
// Clean up class name to be a valid Lua identifier
// Merge [Client] and [Server] classes into single types
// Replace "Player [Client]" and "Player [Server]" with "Player"
// Replace "Alliance [Client]" and "Alliance [Server]" with "Alliance"
// Remove inheritance part (after ":") - keep only the main class name
if strings.Contains(className, " : ") {
parts := strings.Split(className, " : ")
if len(parts) == 2 {
className = parts[0] // Keep only the main class name, ignore inheritance
}
}
// Remove [Client] and [Server] patterns to merge classes
className = strings.ReplaceAll(className, "[Client]", "")
className = strings.ReplaceAll(className, "[Server]", "")
// Remove any remaining brackets and clean up
className = strings.ReplaceAll(className, "[", "")
className = strings.ReplaceAll(className, "]", "")
className = strings.ReplaceAll(className, "-", "_")
className = strings.ReplaceAll(className, ",", "")
// Replace spaces with underscores
className = strings.ReplaceAll(className, " ", "_")
// Clean up multiple underscores
for strings.Contains(className, "__") {
className = strings.ReplaceAll(className, "__", "_")
}
// Remove leading/trailing underscores
className = strings.Trim(className, "_")
res.ClassName = className
log.Info("Found class: %s (cleaned from: %s)", res.ClassName, strings.TrimSpace(class.Text()))
log.Debug("Parsing constructors")
res.Constructors, err = getConstructors(doc, log)
if err != nil {
log.Error("Error getting constructors: %v", err)
return nil, fmt.Errorf("error getting constructors: %w", err)
}
log.Debug("Found %d constructors", len(res.Constructors))
log.Debug("Parsing fields")
res.Fields, err = getFields(doc, log)
if err != nil {
log.Error("Error getting fields: %v", err)
return nil, fmt.Errorf("error getting fields: %w", err)
}
log.Debug("Found %d fields", len(res.Fields))
log.Debug("Parsing methods")
res.Methods, err = getMethods(doc, log, res.ClassName)
if err != nil {
log.Error("Error getting methods: %v", err)
return nil, fmt.Errorf("error getting methods: %w", err)
}
log.Debug("Found %d methods", len(res.Methods))
log.Info("Parsing complete for class: %s", res.ClassName)
logger.Dump("Final parsed class", res)
return &res, nil
}
// TODO: Implement parsing comments for return values
// Something like "---returns (something) -> comment"
// Where "-> comment" is only shown if there's a comment
// And "---returns" only if there's a return type
// This is NOT a luals annotation because we can not use annotations on @overload
// But just a regular plain Lua comment
// TODO: Implement parsing comments for classes and constructors
func getConstructors(doc *goquery.Document, log *logger.Logger) ([]Constructor, error) {
log.Debug("Starting constructor parsing")
res := []Constructor{}
codeblocks := doc.Find("div.floatright > div.codecontainer")
log.Trace("Found %d code blocks", codeblocks.Length())
if codeblocks.Length() == 0 {
log.Error("No codeblocks found")
return res, fmt.Errorf("no codeblocks found")
}
// The first code block should be the constructor
// So far I have not found any classes with overloaded constructors...
// So I can not handle that case yet
constructorBlock := codeblocks.Eq(0)
log.Debug("Processing first code block as constructor")
constructor := constructorBlock.Find("div.function")
paramTypes := constructor.Find("span.type")
paramNames := constructor.Find("span.parameter")
log.Trace("Found %d parameter types and %d parameter names", paramTypes.Length(), paramNames.Length())
resConstructor := Constructor{}
paramTypes.Each(func(i int, s *goquery.Selection) {
pname := strings.TrimSpace(paramNames.Eq(i).Text())
ptype := strings.TrimSpace(paramTypes.Eq(i).Text())
ptype = MapType(ptype)
log.Trace("Parameter %d: name='%s', type='%s'", i, pname, ptype)
resConstructor.Params = append(resConstructor.Params, Param{
Name: pname,
Type: ptype,
Comment: "",
})
})
constructorDetails := constructorBlock.Children().Eq(1)
constructorParameterDetails := constructorDetails.Find("span.parameter")
log.Trace("Found %d constructor parameter details", constructorParameterDetails.Length())
constructorParameterDetails.Each(func(i int, s *goquery.Selection) {
param := strings.TrimSpace(s.Text())
parameterParent := s.Parent()
parameterDescription := parameterParent.Text()
parameterDescription = strings.ReplaceAll(parameterDescription, fmt.Sprintf("\n%s\n", param), "")
parameterDescription = strings.TrimSpace(parameterDescription)
if len(resConstructor.Params) > i {
log.Trace("Parameter %d comment: '%s'", i, parameterDescription)
resConstructor.Params[i].Comment = parameterDescription
}
})
constructorBlock.Find("div:not(.function):not(.indented) > p:not(:has(*))").Each(func(i int, s *goquery.Selection) {
comment := strings.TrimSpace(s.Text())
log.Trace("Constructor comment line %d: '%s'", i, comment)
resConstructor.Comment += comment + "\n"
resConstructor.Comment = strings.TrimSpace(resConstructor.Comment)
})
log.Trace("Final constructor: %+v", resConstructor)
log.Debug("Constructor parsing complete")
return append(res, resConstructor), nil
}
func getFields(doc *goquery.Document, log *logger.Logger) ([]Field, error) {
log.Debug("Starting field parsing")
res := []Field{}
properties := doc.Find("div.floatright > div.codecontainer#Properties")
log.Trace("Found properties container: %d elements", properties.Length())
properties.ChildrenFiltered("div").Each(func(i int, s *goquery.Selection) {
property := Field{}
property.Name = strings.TrimSpace(s.Find("span.property").Text())
property.Type = strings.TrimSpace(s.Find("span.type").Text())
property.Type = MapType(property.Type)
comment := s.Find("td[align='right']").Text()
if comment != "" {
property.Comment = strings.TrimSpace(comment)
}
log.Trace("Field %d: name='%s', type='%s', comment='%s'", i, property.Name, property.Type, property.Comment)
res = append(res, property)
})
log.Debug("Found %d fields", len(res))
return res, nil
}
func getMethods(doc *goquery.Document, log *logger.Logger, className string) ([]Method, error) {
log.Debug("Starting method parsing")
res := []Method{}
codeblocks := doc.Find("div.floatright > div.codecontainer")
log.Trace("Found %d code blocks for method parsing", codeblocks.Length())
codeblocks.Each(func(blockIndex int, codeblock *goquery.Selection) {
functionDiv := codeblock.Find("div.function")
if functionDiv.Length() == 0 {
return
}
method := Method{}
method.Name = strings.TrimSpace(functionDiv.AttrOr("id", ""))
method.Comment = strings.TrimSpace(functionDiv.Find("span.comment").Text())
log.Trace("Processing method %d: name='%s', comment='%s'", blockIndex, method.Name, method.Comment)
// Parse parameters
types := functionDiv.Find("span.type")
parameters := functionDiv.Find("span.parameter")
log.Trace("Method %s has %d types and %d parameters", method.Name, types.Length(), parameters.Length())
types.Each(func(i int, s *goquery.Selection) {
param := Param{}
param.Name = strings.TrimSpace(parameters.Eq(i).Text())
// Skip parameters with empty names
if param.Name == "" {
log.Trace("Method %s parameter %d has empty name, skipping", method.Name, i)
return
}
if IsReservedKeyword(param.Name) {
log.Trace("Parameter name '%s' is reserved keyword, prefixing with __", param.Name)
param.Name = fmt.Sprintf("__%s", param.Name)
}
// Get the type text and handle cases where it might be split across lines
typeText := strings.TrimSpace(types.Eq(i).Text())
// Replace newlines and multiple spaces with single spaces
typeText = strings.ReplaceAll(typeText, "\n", " ")
typeText = strings.ReplaceAll(typeText, "\r", " ")
// Replace multiple spaces with single space
for strings.Contains(typeText, " ") {
typeText = strings.ReplaceAll(typeText, " ", " ")
}
typeText = strings.TrimSpace(typeText)
param.Type = MapType(typeText)
log.Trace("Method %s parameter %d: name='%s', type='%s'", method.Name, i, param.Name, param.Type)
method.Params = append(method.Params, param)
})
// Parse return values
// First, try to get return type from function signature
functionText := functionDiv.Text()
if strings.Contains(functionText, "function") {
// Extract return types from function signature
// Look for patterns like "function var", "function int...", "function Matrix, int..."
lines := strings.Split(functionText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "function") {
// Extract everything after "function" until the function name
functionPart := strings.TrimPrefix(line, "function")
// Find the function name (usually ends with "(" or " ")
funcNameIndex := strings.Index(functionPart, "(")
if funcNameIndex == -1 {
funcNameIndex = strings.Index(functionPart, " ")
}
if funcNameIndex != -1 {
returnTypesPart := strings.TrimSpace(functionPart[:funcNameIndex])
// Remove the function name from the return types part
// The function name is the last word in the return types part
words := strings.Fields(returnTypesPart)
if len(words) > 1 {
// Remove the last word (function name) and join the rest
returnTypesPart = strings.Join(words[:len(words)-1], " ")
}
// Handle complex return types like "table<int, string>" and multiple returns like "Matrix, int..."
// We need to be careful about commas inside angle brackets
var returnTypes []string
var currentType strings.Builder
bracketDepth := 0
for i := 0; i < len(returnTypesPart); i++ {
char := returnTypesPart[i]
if char == '<' {
bracketDepth++
currentType.WriteByte(char)
} else if char == '>' {
bracketDepth--
currentType.WriteByte(char)
} else if char == ',' && bracketDepth == 0 {
// Only split on commas that are not inside angle brackets
returnTypes = append(returnTypes, strings.TrimSpace(currentType.String()))
currentType.Reset()
} else {
currentType.WriteByte(char)
}
}
// Add the last type
if currentType.Len() > 0 {
returnTypes = append(returnTypes, strings.TrimSpace(currentType.String()))
}
for _, returnType := range returnTypes {
if returnType == "" {
continue
}
// Handle cases like "int..." (multiple return values)
if strings.HasSuffix(returnType, "...") {
returnType = strings.TrimSuffix(returnType, "...")
returnType = MapType(returnType)
method.Returns = append(method.Returns, Return{
Type: returnType,
Comment: "multiple return values",
})
} else {
returnType = MapType(returnType)
method.Returns = append(method.Returns, Return{
Type: returnType,
Comment: "",
})
}
}
}
break
}
}
}
// Parse return documentation from the second div
detailsDiv := codeblock.Find("div:not(.function)")
if detailsDiv.Length() > 0 {
// Look for "Returns" section
returnsHeader := detailsDiv.Find("p:contains('Returns')")
if returnsHeader.Length() > 0 {
// Get the indented content after the Returns header
indentedContent := detailsDiv.Find("div.indented p")
indentedContent.Each(func(i int, s *goquery.Selection) {
comment := strings.TrimSpace(s.Text())
if comment != "" {
// If we have return types but no comments yet, add this as a comment
if len(method.Returns) > i {
method.Returns[i].Comment = comment
} else if len(method.Returns) == 0 {
// If no return types were found in signature, create a return with just comment
method.Returns = append(method.Returns, Return{
Type: "any",
Comment: comment,
})
}
}
})
}
}
// If this is a constructor (method name matches class name or is contained in the original class name), ensure it returns the correct type
// Handle cases where method name is "Alliance" but class name is "Alliance_Client"
originalClassName := strings.TrimSpace(doc.Find("div.floatright > h1").Text())
if method.Name == className || method.Name == originalClassName || strings.Contains(originalClassName, method.Name) {
// Override the return type to be the cleaned class name
if len(method.Returns) > 0 {
method.Returns[0].Type = className
method.Returns[0].Comment = "A new instance of " + className
} else {
method.Returns = []Return{
{
Type: className,
Comment: "A new instance of " + className,
},
}
}
}
// Determine availability based on the original class name
availability := determineAvailability(originalClassName)
if availability != "" {
method.Comment = availability + " " + method.Comment
}
log.Trace("Method %s has %d parameters and %d return values", method.Name, len(method.Params), len(method.Returns))
res = append(res, method)
})
log.Debug("Found %d methods", len(res))
return res, nil
}
// determineAvailability returns a comment indicating whether a method is available on client, server, or both
func determineAvailability(originalClassName string) string {
if strings.Contains(originalClassName, "[Client]") && strings.Contains(originalClassName, "[Server]") {
return "[Client/Server]"
} else if strings.Contains(originalClassName, "[Client]") {
return "[Client]"
} else if strings.Contains(originalClassName, "[Server]") {
return "[Server]"
}
return ""
}