23 Commits

Author SHA1 Message Date
969ccae25c Use diffs for tests 2025-08-22 10:10:37 +02:00
5b46ff0efd Fix broken test introduced in previous commit 2025-08-22 10:04:11 +02:00
d234616406 Add broken test 2025-08-22 09:53:00 +02:00
af3e55e518 Fix some retarded bullshit 2025-08-22 00:10:46 +02:00
13b48229ac Fix some bullshit (the re) 2025-08-22 00:05:22 +02:00
670f6ed7a0 Add tests for EvalRegex 2025-08-21 23:56:05 +02:00
bbc7c50fae Decringe 2025-08-21 23:17:36 +02:00
779d1e0a0e Fix some more shit I guess 2025-08-21 23:16:23 +02:00
54581f0216 Clean up the cringe 2025-08-21 23:10:18 +02:00
3d01822e77 Fix failing test 2025-08-21 23:05:57 +02:00
4e0ca92c77 Add failing test 2025-08-21 23:05:57 +02:00
388e54b3e3 Add comprehensive help string for available Lua functions 2025-08-21 22:32:10 +02:00
6f2e76221a Add real regex support to lua 2025-08-21 22:27:37 +02:00
e0d3b938e3 Fix tests 2025-08-21 22:26:20 +02:00
491a030bf8 Hallucinate actual json fucking thing 2025-08-21 22:19:21 +02:00
bff7cc2a27 Hallucinate a json mode implementation 2025-08-21 20:39:35 +02:00
ff30b00e71 refactor(db.go, file.go): improve database error handling and file snapshot seeding 2025-08-09 16:12:26 +02:00
e1eb5eeaa6 Improve config readability by removing unnecessary fields and adding omitempty 2025-08-08 09:58:03 +02:00
2a2e11d8e0 Add more examples to the configuration file generator 2025-08-08 09:52:47 +02:00
6eb4f31127 Implement regexes, an entry option allow for same modification to apply to multiple regexes and variables that can be referenced in lua 2025-08-08 09:50:31 +02:00
4b58e00c26 Hallucinate some logs
Hallucinate more logs

fix(utils/db.go): handle auto migration errors gracefully

fix(utils/modifycommand.go): improve file matching accuracy

fix(processor/regex.go): improve capture group deduplication logic

fix(utils/db.go): add logging for database wrapper initialization

feat(processor/regex.go): preserve input order when deduplicating capture groups

fix(utils/modifycommand.go): add logging for file matching skips

feat(processor/regex.go): add logging for capture group processing

feat(main.go): add trace logging for arguments and parallel workers

fix(main.go): add trace logging for file content

fix(utils/db.go): add logging for database opening

fix(main.go): add trace logging for file processing

fix(utils/modifycommand.go): improve file matching by using absolute paths

feat(modifycommand.go): add trace logging for file matching in AssociateFilesWithCommands

feat(main.go): add per-file association summary for better visibility when debugging
2025-08-08 08:10:51 +02:00
8ffd8af13c Use atomic values instead of mutexes 2025-08-01 16:38:31 +02:00
67861d4455 Now only save CHANGED files to database (before changes of course) 2025-08-01 16:34:48 +02:00
17 changed files with 2981 additions and 404 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.exe *.exe
.qodo .qodo
*.sqlite *.sqlite
testfiles

13
.vscode/launch.json vendored
View File

@@ -98,6 +98,19 @@
"args": [ "args": [
"cook_tacz.yml", "cook_tacz.yml",
] ]
},
{
"name": "Launch Package (ICARUS)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "C:/Users/Administrator/Seafile/Games-ICARUS/Icarus/Saved/IME3/Mods",
"args": [
"-loglevel",
"trace",
"cook_processorrecipes.yml",
]
} }
] ]
} }

9
go.mod
View File

@@ -13,7 +13,6 @@ require (
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hexops/valast v1.5.0 // indirect github.com/hexops/valast v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -21,6 +20,8 @@ require (
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/mod v0.21.0 // indirect golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
@@ -29,4 +30,8 @@ require (
mvdan.cc/gofumpt v0.4.0 // indirect mvdan.cc/gofumpt v0.4.0 // indirect
) )
require gorm.io/driver/sqlite v1.6.0 require (
github.com/google/go-cmp v0.6.0
github.com/tidwall/gjson v1.18.0
gorm.io/driver/sqlite v1.6.0
)

6
go.sum
View File

@@ -36,6 +36,12 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=

530
main.go
View File

@@ -1,11 +1,13 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"sort" "sort"
"sync" "sync"
"sync/atomic"
"time" "time"
"cook/processor" "cook/processor"
@@ -16,11 +18,14 @@ import (
logger "git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
) )
// mainLogger is a scoped logger for the main package.
var mainLogger = logger.Default.WithPrefix("main")
type GlobalStats struct { type GlobalStats struct {
TotalMatches int TotalMatches int64
TotalModifications int TotalModifications int64
ProcessedFiles int ProcessedFiles int64
FailedFiles int FailedFiles int64
ModificationsPerCommand sync.Map ModificationsPerCommand sync.Map
} }
@@ -39,9 +44,13 @@ func main() {
fmt.Fprintf(os.Stderr, " Reset files to their original state\n") fmt.Fprintf(os.Stderr, " Reset files to their original state\n")
fmt.Fprintf(os.Stderr, " -loglevel string\n") fmt.Fprintf(os.Stderr, " -loglevel string\n")
fmt.Fprintf(os.Stderr, " Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE (default \"INFO\")\n") fmt.Fprintf(os.Stderr, " Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE (default \"INFO\")\n")
fmt.Fprintf(os.Stderr, " -json\n")
fmt.Fprintf(os.Stderr, " Enable JSON mode for processing JSON files\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " Regex mode (default):\n") fmt.Fprintf(os.Stderr, " Regex mode (default):\n")
fmt.Fprintf(os.Stderr, " %s \"<value>(\\d+)</value>\" \"*1.5\" data.xml\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s \"<value>(\\\\d+)</value>\" \"*1.5\" data.xml\n", os.Args[0])
fmt.Fprintf(os.Stderr, " JSON mode:\n")
fmt.Fprintf(os.Stderr, " %s -json data.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n") fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n")
fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n") fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n")
fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n") fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n")
@@ -49,95 +58,147 @@ func main() {
fmt.Fprintf(os.Stderr, " If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended\n") fmt.Fprintf(os.Stderr, " If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended\n")
fmt.Fprintf(os.Stderr, " You can use any valid Lua code, including if statements, loops, etc.\n") fmt.Fprintf(os.Stderr, " You can use any valid Lua code, including if statements, loops, etc.\n")
fmt.Fprintf(os.Stderr, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n") fmt.Fprintf(os.Stderr, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n")
fmt.Fprintf(os.Stderr, "\nLua Functions Available:\n")
fmt.Fprintf(os.Stderr, "%s\n", processor.GetLuaFunctionsHelp())
} }
// TODO: Fix bed shitting when doing *.yml in barotrauma directory // TODO: Fix bed shitting when doing *.yml in barotrauma directory
flag.Parse() flag.Parse()
args := flag.Args() args := flag.Args()
logger.InitFlag() logger.InitFlag()
logger.Info("Initializing with log level: %s", logger.GetLevel().String()) mainLogger.Info("Initializing with log level: %s", logger.GetLevel().String())
mainLogger.Trace("Full argv: %v", os.Args)
if flag.NArg() == 0 { if flag.NArg() == 0 {
flag.Usage() flag.Usage()
return return
} }
mainLogger.Debug("Getting database connection")
db, err := utils.GetDB() db, err := utils.GetDB()
if err != nil { if err != nil {
logger.Error("Failed to get database: %v", err) mainLogger.Error("Failed to get database: %v", err)
return return
} }
mainLogger.Debug("Database connection established")
workdone, err := HandleSpecialArgs(args, err, db) workdone, err := HandleSpecialArgs(args, err, db)
if err != nil { if err != nil {
logger.Error("Failed to handle special args: %v", err) mainLogger.Error("Failed to handle special args: %v", err)
return return
} }
if workdone { if workdone {
mainLogger.Info("Special arguments handled, exiting.")
return return
} }
// The plan is: // The plan is:
// Load all commands // Load all commands
mainLogger.Debug("Loading commands from arguments")
mainLogger.Trace("Arguments: %v", args)
commands, err := utils.LoadCommands(args) commands, err := utils.LoadCommands(args)
if err != nil || len(commands) == 0 { if err != nil || len(commands) == 0 {
logger.Error("Failed to load commands: %v", err) mainLogger.Error("Failed to load commands: %v", err)
flag.Usage() flag.Usage()
return return
} }
// Collect global modifiers from special entries and filter them out
vars := map[string]interface{}{}
filtered := make([]utils.ModifyCommand, 0, len(commands))
for _, c := range commands {
if len(c.Modifiers) > 0 && c.Name == "" && c.Regex == "" && len(c.Regexes) == 0 && c.Lua == "" && len(c.Files) == 0 {
for k, v := range c.Modifiers {
vars[k] = v
}
continue
}
filtered = append(filtered, c)
}
if len(vars) > 0 {
mainLogger.Info("Loaded %d global modifiers", len(vars))
processor.SetVariables(vars)
}
commands = filtered
mainLogger.Info("Loaded %d commands", len(commands))
if *utils.Filter != "" { if *utils.Filter != "" {
logger.Info("Filtering commands by name: %s", *utils.Filter) mainLogger.Info("Filtering commands by name: %s", *utils.Filter)
commands = utils.FilterCommands(commands, *utils.Filter) commands = utils.FilterCommands(commands, *utils.Filter)
logger.Info("Filtered %d commands", len(commands)) mainLogger.Info("Filtered %d commands", len(commands))
} }
// Then aggregate all the globs and deduplicate them // Then aggregate all the globs and deduplicate them
mainLogger.Debug("Aggregating globs and deduplicating")
globs := utils.AggregateGlobs(commands) globs := utils.AggregateGlobs(commands)
logger.Debug("Aggregated %d globs before deduplication", utils.CountGlobsBeforeDedup(commands)) mainLogger.Debug("Aggregated %d globs before deduplication", utils.CountGlobsBeforeDedup(commands))
for _, command := range commands { for _, command := range commands {
logger.Trace("Command: %s", command.Name) mainLogger.Trace("Command: %s", command.Name)
logger.Trace("Regex: %s", command.Regex) if len(command.Regexes) > 0 {
logger.Trace("Files: %v", command.Files) mainLogger.Trace("Regexes: %v", command.Regexes)
logger.Trace("Lua: %s", command.Lua) } else {
logger.Trace("Reset: %t", command.Reset) mainLogger.Trace("Regex: %s", command.Regex)
logger.Trace("Isolate: %t", command.Isolate) }
logger.Trace("LogLevel: %s", command.LogLevel) mainLogger.Trace("Files: %v", command.Files)
mainLogger.Trace("Lua: %s", command.Lua)
mainLogger.Trace("Reset: %t", command.Reset)
mainLogger.Trace("Isolate: %t", command.Isolate)
mainLogger.Trace("LogLevel: %s", command.LogLevel)
} }
// Resolve all the files for all the globs // Resolve all the files for all the globs
logger.Info("Found %d unique file patterns", len(globs)) mainLogger.Info("Found %d unique file patterns", len(globs))
mainLogger.Debug("Expanding glob patterns to files")
files, err := utils.ExpandGLobs(globs) files, err := utils.ExpandGLobs(globs)
if err != nil { if err != nil {
logger.Error("Failed to expand file patterns: %v", err) mainLogger.Error("Failed to expand file patterns: %v", err)
return return
} }
logger.Info("Found %d files to process", len(files)) mainLogger.Info("Found %d files to process", len(files))
mainLogger.Trace("Files to process: %v", files)
// Somehow connect files to commands via globs.. // Somehow connect files to commands via globs..
// For each file check every glob of every command // For each file check every glob of every command
// Maybe memoize this part // Maybe memoize this part
// That way we know what commands affect what files // That way we know what commands affect what files
mainLogger.Debug("Associating files with commands")
associations, err := utils.AssociateFilesWithCommands(files, commands) associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil { if err != nil {
logger.Error("Failed to associate files with commands: %v", err) mainLogger.Error("Failed to associate files with commands: %v", err)
return return
} }
mainLogger.Debug("Files associated with commands")
mainLogger.Trace("File-command associations: %v", associations)
// Per-file association summary for better visibility when debugging
for file, assoc := range associations {
cmdNames := make([]string, 0, len(assoc.Commands))
for _, c := range assoc.Commands {
cmdNames = append(cmdNames, c.Name)
}
isoNames := make([]string, 0, len(assoc.IsolateCommands))
for _, c := range assoc.IsolateCommands {
isoNames = append(isoNames, c.Name)
}
mainLogger.Debug("File %q has %d regular and %d isolate commands", file, len(assoc.Commands), len(assoc.IsolateCommands))
mainLogger.Trace("\tRegular: %v", cmdNames)
mainLogger.Trace("\tIsolate: %v", isoNames)
}
mainLogger.Debug("Resetting files where necessary")
err = utils.ResetWhereNecessary(associations, db) err = utils.ResetWhereNecessary(associations, db)
if err != nil { if err != nil {
logger.Error("Failed to reset files where necessary: %v", err) mainLogger.Error("Failed to reset files where necessary: %v", err)
return return
} }
mainLogger.Debug("Files reset where necessary")
// Then for each file run all commands associated with the file // Then for each file run all commands associated with the file
workers := make(chan struct{}, *utils.ParallelFiles) workers := make(chan struct{}, *utils.ParallelFiles)
wg := sync.WaitGroup{} wg := sync.WaitGroup{}
mainLogger.Debug("Starting file processing with %d parallel workers", *utils.ParallelFiles)
// Add performance tracking // Add performance tracking
startTime := time.Now() startTime := time.Now()
var fileMutex sync.Mutex
// Create a map to store loggers for each command // Create a map to store loggers for each command
commandLoggers := make(map[string]*logger.Logger) commandLoggers := make(map[string]*logger.Logger)
@@ -157,10 +218,10 @@ func main() {
cmdLogLevel := logger.ParseLevel(command.LogLevel) cmdLogLevel := logger.ParseLevel(command.LogLevel)
// Create a logger with the command name as a field // Create a logger with the command name as a field
commandLoggers[command.Name] = logger.WithField("command", cmdName) commandLoggers[command.Name] = logger.Default.WithField("command", cmdName)
commandLoggers[command.Name].SetLevel(cmdLogLevel) commandLoggers[command.Name].SetLevel(cmdLogLevel)
logger.Debug("Created logger for command %q with log level %s", cmdName, cmdLogLevel.String()) mainLogger.Debug("Created logger for command %q with log level %s", cmdName, cmdLogLevel.String())
} }
for file, association := range associations { for file, association := range associations {
@@ -172,51 +233,72 @@ func main() {
// Track per-file processing time // Track per-file processing time
fileStartTime := time.Now() fileStartTime := time.Now()
logger.Debug("Reading file %q", file) mainLogger.Debug("Reading file %q", file)
fileData, err := os.ReadFile(file) fileData, err := os.ReadFile(file)
if err != nil { if err != nil {
logger.Error("Failed to read file %q: %v", file, err) mainLogger.Error("Failed to read file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
fileDataStr := string(fileData) fileDataStr := string(fileData)
mainLogger.Trace("File %q content: %s", file, utils.LimitString(fileDataStr, 500))
logger.Debug("Saving file %q to database", file) isChanged := false
err = db.SaveFile(file, fileData) mainLogger.Debug("Running isolate commands for file %q", file)
if err != nil { fileDataStr, err = RunIsolateCommands(association, file, fileDataStr)
logger.Error("Failed to save file %q to database: %v", file, err) if err != nil && err != NothingToDo {
mainLogger.Error("Failed to run isolate commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
if err != NothingToDo {
logger.Debug("Running isolate commands for file %q", file) isChanged = true
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr, &fileMutex)
if err != nil {
logger.Error("Failed to run isolate commands for file %q: %v", file, err)
return
} }
logger.Debug("Running other commands for file %q", file) mainLogger.Debug("Running other commands for file %q", file)
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, &fileMutex, commandLoggers) fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers)
if err != nil { if err != nil && err != NothingToDo {
logger.Error("Failed to run other commands for file %q: %v", file, err) mainLogger.Error("Failed to run other commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
if err != NothingToDo {
isChanged = true
}
logger.Debug("Writing file %q", file) if isChanged {
mainLogger.Debug("Saving file %q to database", file)
err = db.SaveFile(file, fileData)
if err != nil {
mainLogger.Error("Failed to save file %q to database: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return
}
mainLogger.Debug("File %q saved to database", file)
}
mainLogger.Debug("Writing file %q", file)
err = os.WriteFile(file, []byte(fileDataStr), 0644) err = os.WriteFile(file, []byte(fileDataStr), 0644)
if err != nil { if err != nil {
logger.Error("Failed to write file %q: %v", file, err) mainLogger.Error("Failed to write file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
mainLogger.Debug("File %q written", file)
logger.Debug("File %q processed in %v", file, time.Since(fileStartTime)) // Only increment ProcessedFiles once per file, after all processing is complete
atomic.AddInt64(&stats.ProcessedFiles, 1)
mainLogger.Debug("File %q processed in %v", file, time.Since(fileStartTime))
}, file, commands) }, file, commands)
} }
wg.Wait() wg.Wait()
processingTime := time.Since(startTime) processingTime := time.Since(startTime)
logger.Info("Processing completed in %v", processingTime) mainLogger.Info("Processing completed in %v", processingTime)
if stats.ProcessedFiles > 0 { processedFiles := atomic.LoadInt64(&stats.ProcessedFiles)
logger.Info("Average time per file: %v", processingTime/time.Duration(stats.ProcessedFiles)) if processedFiles > 0 {
mainLogger.Info("Average time per file: %v", processingTime/time.Duration(processedFiles))
} }
// TODO: Also give each command its own logger, maybe prefix it with something... Maybe give commands a name? // TODO: Also give each command its own logger, maybe prefix it with something... Maybe give commands a name?
@@ -226,44 +308,46 @@ func main() {
// TODO: What to do with git? Figure it out .... // TODO: What to do with git? Figure it out ....
// if *gitFlag { // if *gitFlag {
// logger.Info("Git integration enabled, setting up git repository") // mainLogger.Info("Git integration enabled, setting up git repository")
// err := setupGit() // err := setupGit()
// if err != nil { // if err != nil {
// logger.Error("Failed to setup git: %v", err) // mainLogger.Error("Failed to setup git: %v", err)
// fmt.Fprintf(os.Stderr, "Error setting up git: %v\n", err) // fmt.Fprintf(os.Stderr, "Error setting up git: %v\n", err)
// return // return
// } // }
// } // }
// logger.Debug("Expanding file patterns") // mainLogger.Debug("Expanding file patterns")
// files, err := expandFilePatterns(filePatterns) // files, err := expandFilePatterns(filePatterns)
// if err != nil { // if err != nil {
// logger.Error("Failed to expand file patterns: %v", err) // mainLogger.Error("Failed to expand file patterns: %v", err)
// fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err) // fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err)
// return // return
// } // }
// if *gitFlag { // if *gitFlag {
// logger.Info("Cleaning up git files before processing") // mainLogger.Info("Cleaning up git files before processing")
// err := cleanupGitFiles(files) // err := cleanupGitFiles(files)
// if err != nil { // if err != nil {
// logger.Error("Failed to cleanup git files: %v", err) // mainLogger.Error("Failed to cleanup git files: %v", err)
// fmt.Fprintf(os.Stderr, "Error cleaning up git files: %v\n", err) // fmt.Fprintf(os.Stderr, "Error cleaning up git files: %v\n", err)
// return // return
// } // }
// } // }
// if *resetFlag { // if *resetFlag {
// logger.Info("Files reset to their original state, nothing more to do") // mainLogger.Info("Files reset to their original state, nothing more to do")
// log.Printf("Files reset to their original state, nothing more to do") // log.Printf("Files reset to their original state, nothing more to do")
// return // return
// } // }
// Print summary // Print summary
if stats.TotalModifications == 0 { totalModifications := atomic.LoadInt64(&stats.TotalModifications)
logger.Warning("No modifications were made in any files") if totalModifications == 0 {
mainLogger.Warning("No modifications were made in any files")
} else { } else {
logger.Info("Operation complete! Modified %d values in %d/%d files", failedFiles := atomic.LoadInt64(&stats.FailedFiles)
stats.TotalModifications, stats.ProcessedFiles, stats.ProcessedFiles+stats.FailedFiles) mainLogger.Info("Operation complete! Modified %d values in %d/%d files",
totalModifications, processedFiles, processedFiles+failedFiles)
sortedCommands := []string{} sortedCommands := []string{}
stats.ModificationsPerCommand.Range(func(key, value interface{}) bool { stats.ModificationsPerCommand.Range(func(key, value interface{}) bool {
sortedCommands = append(sortedCommands, key.(string)) sortedCommands = append(sortedCommands, key.(string))
@@ -274,148 +358,346 @@ func main() {
for _, command := range sortedCommands { for _, command := range sortedCommands {
count, _ := stats.ModificationsPerCommand.Load(command) count, _ := stats.ModificationsPerCommand.Load(command)
if count.(int) > 0 { if count.(int) > 0 {
logger.Info("\tCommand %q made %d modifications", command, count) mainLogger.Info("\tCommand %q made %d modifications", command, count)
} else { } else {
logger.Warning("\tCommand %q made no modifications", command) mainLogger.Warning("\tCommand %q made no modifications", command)
} }
} }
} }
} }
func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) { func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) {
handleSpecialArgsLogger := logger.Default.WithPrefix("HandleSpecialArgs")
handleSpecialArgsLogger.Debug("Handling special arguments: %v", args)
switch args[0] { switch args[0] {
case "reset": case "reset":
handleSpecialArgsLogger.Info("Resetting all files")
err = utils.ResetAllFiles(db) err = utils.ResetAllFiles(db)
if err != nil { if err != nil {
logger.Error("Failed to reset all files: %v", err) handleSpecialArgsLogger.Error("Failed to reset all files: %v", err)
return true, err return true, err
} }
logger.Info("All files reset") handleSpecialArgsLogger.Info("All files reset")
return true, nil return true, nil
case "dump": case "dump":
handleSpecialArgsLogger.Info("Dumping all files from database")
err = db.RemoveAllFiles() err = db.RemoveAllFiles()
if err != nil { if err != nil {
logger.Error("Failed to remove all files from database: %v", err) handleSpecialArgsLogger.Error("Failed to remove all files from database: %v", err)
return true, err return true, err
} }
logger.Info("All files removed from database") handleSpecialArgsLogger.Info("All files removed from database")
return true, nil return true, nil
} }
handleSpecialArgsLogger.Debug("No special arguments handled, returning false")
return false, nil return false, nil
} }
func CreateExampleConfig() { func CreateExampleConfig() {
createExampleConfigLogger := logger.Default.WithPrefix("CreateExampleConfig")
createExampleConfigLogger.Debug("Creating example configuration file")
commands := []utils.ModifyCommand{ commands := []utils.ModifyCommand{
// Global modifiers only entry (no name/regex/lua/files)
{ {
Name: "DoubleNumericValues", Modifiers: map[string]interface{}{
Regex: "<value>(\\d+)</value>", "foobar": 4,
Lua: "v1 * 2", "multiply": 1.5,
Files: []string{"data/*.xml"}, "prefix": "NEW_",
LogLevel: "INFO", "enabled": true,
},
}, },
// Multi-regex example using $variable in Lua
{ {
Name: "UpdatePrices", Name: "RFToolsMultiply",
Regex: "price=\"(\\d+)\"", Regexes: []string{"generatePerTick = !num", "ticksPer\\w+ = !num", "generatorRFPerTick = !num"},
Lua: "if num(v1) < 100 then return v1 * 1.5 else return v1 end", Lua: "* $foobar",
Files: []string{"items/*.xml", "shop/*.xml"}, Files: []string{"polymc/instances/**/rftools*.toml", `polymc\\instances\\**\\rftools*.toml`},
Reset: true,
// LogLevel defaults to INFO
},
// Named capture groups with arithmetic and string ops
{
Name: "UpdateAmountsAndItems",
Regex: `(?P<amount>!num)\s+units\s+of\s+(?P<item>[A-Za-z_\-]+)`,
Lua: `amount = amount * $multiply; item = upper(item); return true`,
Files: []string{"data/**/*.txt"},
// INFO log level
},
// Full replacement via Lua 'replacement' variable
{
Name: "BumpMinorVersion",
Regex: `version\s*=\s*"(?P<major>!num)\.(?P<minor>!num)\.(?P<patch>!num)"`,
Lua: `replacement = format("version=\"%s.%s.%s\"", major, num(minor)+1, 0); return true`,
Files: []string{"config/*.ini", "config/*.cfg"},
},
// Multiline regex example (DOTALL is auto-enabled). Captures numeric in nested XML.
{
Name: "XMLNestedValueMultiply",
Regex: `<item>\s*\s*<name>!any<\/name>\s*\s*<value>(!num)<\/value>\s*\s*<\/item>`,
Lua: `* $multiply`,
Files: []string{"data/**/*.xml"},
// Demonstrates multiline regex in YAML
},
// Multiline regexES array, with different patterns handled by same Lua
{
Name: "MultiLinePatterns",
Regexes: []string{
`<entry>\s*\n\s*<id>(?P<id>!num)</id>\s*\n\s*<score>(?P<score>!num)</score>\s*\n\s*</entry>`,
`\[block\]\nkey=(?P<key>[A-Za-z_]+)\nvalue=(?P<val>!num)`,
},
Lua: `if is_number(score) then score = score * 2 end; if is_number(val) then val = val * 3 end; return true`,
Files: []string{"examples/**/*.*"},
LogLevel: "DEBUG", LogLevel: "DEBUG",
}, },
// Use equals operator shorthand and boolean variable
{ {
Name: "IsolatedTagUpdate", Name: "EnableFlags",
Regex: "<tag>(.*?)</tag>", Regex: `enabled\s*=\s*(true|false)`,
Lua: "string.upper(s1)", Lua: `= $enabled`,
Files: []string{"config.xml"}, Files: []string{"**/*.toml"},
},
// Demonstrate NoDedup to allow overlapping replacements
{
Name: "OverlappingGroups",
Regex: `(?P<a>!num)(?P<b>!num)`,
Lua: `a = num(a) + 1; b = num(b) + 1; return true`,
Files: []string{"overlap/**/*.txt"},
NoDedup: true,
},
// Isolate command example operating on entire matched block
{
Name: "IsolateUppercaseBlock",
Regex: `BEGIN\n(?P<block>!any)\nEND`,
Lua: `block = upper(block); return true`,
Files: []string{"logs/**/*.log"},
Isolate: true, Isolate: true,
NoDedup: true,
LogLevel: "TRACE", LogLevel: "TRACE",
}, },
// Using !rep placeholder and arrays of files
{
Name: "RepeatPlaceholderExample",
Regex: `name: (.*) !rep(, .* , 2)`,
Lua: `-- no-op, just demonstrate placeholder; return false`,
Files: []string{"lists/**/*.yml", "lists/**/*.yaml"},
},
// Using string variable in Lua expression
{
Name: "PrefixKeys",
Regex: `(?P<key>[A-Za-z0-9_]+)\s*=`,
Lua: `key = $prefix .. key; return true`,
Files: []string{"**/*.properties"},
},
// JSON mode examples
{
Name: "JSONArrayMultiply",
JSON: true,
Lua: `for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true`,
Files: []string{"data/**/*.json"},
},
{
Name: "JSONObjectUpdate",
JSON: true,
Lua: `data.version = "2.0.0"; data.enabled = true; return true`,
Files: []string{"config/**/*.json"},
},
{
Name: "JSONNestedModify",
JSON: true,
Lua: `if data.settings and data.settings.performance then data.settings.performance.multiplier = data.settings.performance.multiplier * 1.5 end; return true`,
Files: []string{"settings/**/*.json"},
},
} }
data, err := yaml.Marshal(commands) data, err := yaml.Marshal(commands)
if err != nil { if err != nil {
logger.Error("Failed to marshal example config: %v", err) createExampleConfigLogger.Error("Failed to marshal example config: %v", err)
return return
} }
createExampleConfigLogger.Debug("Writing example_cook.yml")
err = os.WriteFile("example_cook.yml", data, 0644) err = os.WriteFile("example_cook.yml", data, 0644)
if err != nil { if err != nil {
logger.Error("Failed to write example_cook.yml: %v", err) createExampleConfigLogger.Error("Failed to write example_cook.yml: %v", err)
return return
} }
logger.Info("Wrote example_cook.yml") createExampleConfigLogger.Info("Wrote example_cook.yml")
} }
func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, fileMutex *sync.Mutex, commandLoggers map[string]*logger.Logger) (string, error) { var NothingToDo = errors.New("nothing to do")
// Aggregate all the modifications and execute them
modifications := []utils.ReplaceCommand{} func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, commandLoggers map[string]*logger.Logger) (string, error) {
runOtherCommandsLogger := mainLogger.WithPrefix("RunOtherCommands").WithField("file", file)
runOtherCommandsLogger.Debug("Running other commands for file")
runOtherCommandsLogger.Trace("File data before modifications: %s", utils.LimitString(fileDataStr, 200))
// Separate JSON and regex commands for different processing approaches
jsonCommands := []utils.ModifyCommand{}
regexCommands := []utils.ModifyCommand{}
for _, command := range association.Commands { for _, command := range association.Commands {
// Use command-specific logger if available, otherwise fall back to default logger if command.JSON || *utils.JSON {
jsonCommands = append(jsonCommands, command)
} else {
regexCommands = append(regexCommands, command)
}
}
// Process JSON commands sequentially (each operates on the entire file)
for _, command := range jsonCommands {
cmdLogger := logger.Default cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok { if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog cmdLogger = cmdLog
} }
cmdLogger.Info("Processing file %q with command %q", file, command.Regex) cmdLogger.Debug("Processing file with JSON mode for command %q", command.Name)
newModifications, err := processor.ProcessRegex(fileDataStr, command, file) newModifications, err := processor.ProcessJSON(fileDataStr, command, file)
if err != nil { if err != nil {
logger.Error("Failed to process file %q with command %q: %v", file, command.Regex, err) runOtherCommandsLogger.Error("Failed to process file with JSON command %q: %v", command.Name, err)
continue continue
} }
modifications = append(modifications, newModifications...)
// It is not guranteed that all the commands will be executed... // Apply JSON modifications immediately
// TODO: Make this better if len(newModifications) > 0 {
// We'd have to pass the map to executemodifications or something... var count int
fileDataStr, count = utils.ExecuteModifications(newModifications, fileDataStr)
atomic.AddInt64(&stats.TotalModifications, int64(count))
cmdLogger.Debug("Applied %d JSON modifications for command %q", count, command.Name)
}
count, ok := stats.ModificationsPerCommand.Load(command.Name) count, ok := stats.ModificationsPerCommand.Load(command.Name)
if !ok { if !ok {
count = 0 count = 0
} }
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications)) stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
cmdLogger.Debug("Command %q generated %d modifications", command.Name, len(newModifications))
} }
// Aggregate regex modifications and execute them
modifications := []utils.ReplaceCommand{}
numCommandsConsidered := 0
for _, command := range regexCommands {
cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog
}
patterns := command.Regexes
if len(patterns) == 0 {
patterns = []string{command.Regex}
}
for idx, pattern := range patterns {
tmpCmd := command
tmpCmd.Regex = pattern
cmdLogger.Debug("Begin processing file with command %q (pattern %d/%d)", command.Name, idx+1, len(patterns))
numCommandsConsidered++
newModifications, err := processor.ProcessRegex(fileDataStr, tmpCmd, file)
if err != nil {
runOtherCommandsLogger.Error("Failed to process file with command %q: %v", command.Name, err)
continue
}
modifications = append(modifications, newModifications...)
count, ok := stats.ModificationsPerCommand.Load(command.Name)
if !ok {
count = 0
}
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
cmdLogger.Debug("Command %q generated %d modifications (pattern %d/%d)", command.Name, len(newModifications), idx+1, len(patterns))
cmdLogger.Trace("Modifications generated by command %q: %v", command.Name, newModifications)
if len(newModifications) == 0 {
cmdLogger.Debug("No modifications yielded by command %q (pattern %d/%d)", command.Name, idx+1, len(patterns))
}
}
}
runOtherCommandsLogger.Debug("Aggregated %d modifications from %d command-pattern runs", len(modifications), numCommandsConsidered)
runOtherCommandsLogger.Trace("All aggregated modifications: %v", modifications)
if len(modifications) == 0 { if len(modifications) == 0 {
logger.Warning("No modifications found for file %q", file) runOtherCommandsLogger.Warning("No modifications found for file")
return fileDataStr, nil return fileDataStr, NothingToDo
} }
runOtherCommandsLogger.Debug("Executing %d modifications for file", len(modifications))
// Sort commands in reverse order for safe replacements // Sort commands in reverse order for safe replacements
var count int var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr) fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
runOtherCommandsLogger.Trace("File data after modifications: %s", utils.LimitString(fileDataStr, 200))
fileMutex.Lock() atomic.AddInt64(&stats.TotalModifications, int64(count))
stats.ProcessedFiles++
stats.TotalModifications += count
fileMutex.Unlock()
logger.Info("Executed %d modifications for file %q", count, file) runOtherCommandsLogger.Info("Executed %d modifications for file", count)
return fileDataStr, nil return fileDataStr, nil
} }
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string, fileMutex *sync.Mutex) (string, error) { func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string) (string, error) {
runIsolateCommandsLogger := mainLogger.WithPrefix("RunIsolateCommands").WithField("file", file)
runIsolateCommandsLogger.Debug("Running isolate commands for file")
runIsolateCommandsLogger.Trace("File data before isolate modifications: %s", utils.LimitString(fileDataStr, 200))
anythingDone := false
for _, isolateCommand := range association.IsolateCommands { for _, isolateCommand := range association.IsolateCommands {
logger.Info("Processing file %q with isolate command %q", file, isolateCommand.Regex) // Check if this isolate command should use JSON mode
modifications, err := processor.ProcessRegex(fileDataStr, isolateCommand, file) if isolateCommand.JSON || *utils.JSON {
if err != nil { runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
logger.Error("Failed to process file %q with isolate command %q: %v", file, isolateCommand.Regex, err) modifications, err := processor.ProcessJSON(fileDataStr, isolateCommand, file)
continue if err != nil {
runIsolateCommandsLogger.Error("Failed to process file with JSON isolate command %q: %v", isolateCommand.Name, err)
continue
}
if len(modifications) == 0 {
runIsolateCommandsLogger.Debug("JSON isolate command %q produced no modifications", isolateCommand.Name)
continue
}
anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d JSON isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("JSON isolate modifications: %v", modifications)
var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
runIsolateCommandsLogger.Trace("File data after JSON isolate modifications: %s", utils.LimitString(fileDataStr, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
runIsolateCommandsLogger.Info("Executed %d JSON isolate modifications for file", count)
} else {
// Regular regex processing for isolate commands
runIsolateCommandsLogger.Debug("Begin processing file with isolate command %q", isolateCommand.Regex)
patterns := isolateCommand.Regexes
if len(patterns) == 0 {
patterns = []string{isolateCommand.Regex}
}
for idx, pattern := range patterns {
tmpCmd := isolateCommand
tmpCmd.Regex = pattern
modifications, err := processor.ProcessRegex(fileDataStr, tmpCmd, file)
if err != nil {
runIsolateCommandsLogger.Error("Failed to process file with isolate command %q (pattern %d/%d): %v", isolateCommand.Name, idx+1, len(patterns), err)
continue
}
if len(modifications) == 0 {
runIsolateCommandsLogger.Debug("Isolate command %q produced no modifications (pattern %d/%d)", isolateCommand.Name, idx+1, len(patterns))
continue
}
anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("Isolate modifications: %v", modifications)
var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
runIsolateCommandsLogger.Trace("File data after isolate modifications: %s", utils.LimitString(fileDataStr, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
runIsolateCommandsLogger.Info("Executed %d isolate modifications for file", count)
}
} }
}
if len(modifications) == 0 { if !anythingDone {
logger.Warning("No modifications found for file %q", file) runIsolateCommandsLogger.Debug("No isolate modifications were made for file")
continue return fileDataStr, NothingToDo
}
var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
fileMutex.Lock()
stats.ProcessedFiles++
stats.TotalModifications += count
fileMutex.Unlock()
logger.Info("Executed %d isolate modifications for file %q", count, file)
} }
return fileDataStr, nil return fileDataStr, nil
} }

656
processor/json.go Normal file
View File

@@ -0,0 +1,656 @@
package processor
import (
"cook/utils"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/tidwall/gjson"
lua "github.com/yuin/gopher-lua"
)
// jsonLogger is a scoped logger for the processor/json package.
var jsonLogger = logger.Default.WithPrefix("processor/json")
// ProcessJSON applies Lua processing to JSON content
func ProcessJSON(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) {
processJsonLogger := jsonLogger.WithPrefix("ProcessJSON").WithField("commandName", command.Name).WithField("file", filename)
processJsonLogger.Debug("Starting JSON processing for file")
processJsonLogger.Trace("Initial file content length: %d", len(content))
var commands []utils.ReplaceCommand
startTime := time.Now()
// Parse JSON content
var jsonData interface{}
err := json.Unmarshal([]byte(content), &jsonData)
if err != nil {
processJsonLogger.Error("Failed to parse JSON content: %v", err)
return commands, fmt.Errorf("failed to parse JSON: %v", err)
}
processJsonLogger.Debug("Successfully parsed JSON content")
// Create Lua state
L, err := NewLuaState()
if err != nil {
processJsonLogger.Error("Error creating Lua state: %v", err)
return commands, fmt.Errorf("error creating Lua state: %v", err)
}
defer L.Close()
// Set filename global
L.SetGlobal("file", lua.LString(filename))
// Convert JSON data to Lua table
luaTable, err := ToLuaTable(L, jsonData)
if err != nil {
processJsonLogger.Error("Failed to convert JSON to Lua table: %v", err)
return commands, fmt.Errorf("failed to convert JSON to Lua table: %v", err)
}
// Set the JSON data as a global variable
L.SetGlobal("data", luaTable)
processJsonLogger.Debug("Set JSON data as Lua global 'data'")
// Build and execute Lua script for JSON mode
luaExpr := BuildJSONLuaScript(command.Lua)
processJsonLogger.Debug("Built Lua script from expression: %q", command.Lua)
processJsonLogger.Trace("Full Lua script: %q", utils.LimitString(luaExpr, 200))
if err := L.DoString(luaExpr); err != nil {
processJsonLogger.Error("Lua script execution failed: %v\nScript: %s", err, utils.LimitString(luaExpr, 200))
return commands, fmt.Errorf("lua script execution failed: %v", err)
}
processJsonLogger.Debug("Lua script executed successfully")
// Check if modification flag is set
modifiedVal := L.GetGlobal("modified")
if modifiedVal.Type() != lua.LTBool || !lua.LVAsBool(modifiedVal) {
processJsonLogger.Debug("Skipping - no modifications indicated by Lua script")
return commands, nil
}
// Get the modified data from Lua
modifiedData := L.GetGlobal("data")
if modifiedData.Type() != lua.LTTable {
processJsonLogger.Error("Expected 'data' to be a table after Lua processing, got %s", modifiedData.Type().String())
return commands, fmt.Errorf("expected 'data' to be a table after Lua processing")
}
// Convert back to Go interface
goData, err := FromLua(L, modifiedData)
if err != nil {
processJsonLogger.Error("Failed to convert Lua table back to Go: %v", err)
return commands, fmt.Errorf("failed to convert Lua table back to Go: %v", err)
}
processJsonLogger.Debug("About to call applyChanges with original data and modified data")
commands, err = applyChanges(content, jsonData, goData)
if err != nil {
processJsonLogger.Error("Failed to apply surgical JSON changes: %v", err)
return commands, fmt.Errorf("failed to apply surgical JSON changes: %v", err)
}
processJsonLogger.Debug("Total JSON processing time: %v", time.Since(startTime))
processJsonLogger.Debug("Generated %d total modifications", len(commands))
return commands, nil
}
// applyJSONChanges compares original and modified data and applies changes surgically
func applyJSONChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand
appliedCommands, err := applyChanges(content, originalData, modifiedData)
if err == nil && len(appliedCommands) > 0 {
return appliedCommands, nil
}
return commands, fmt.Errorf("failed to make any changes to the json")
}
// applyChanges attempts to make surgical changes while preserving exact formatting
func applyChanges(content string, originalData, modifiedData interface{}) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand
// Find all changes between original and modified data
changes := findDeepChanges("", originalData, modifiedData)
jsonLogger.Debug("applyChanges: Found %d changes: %v", len(changes), changes)
if len(changes) == 0 {
return commands, nil
}
// Sort removal operations by index in descending order to avoid index shifting
var removals []string
var additions []string
var valueChanges []string
for path := range changes {
if strings.HasSuffix(path, "@remove") {
removals = append(removals, path)
} else if strings.HasSuffix(path, "@add") {
additions = append(additions, path)
} else {
valueChanges = append(valueChanges, path)
}
}
jsonLogger.Debug("applyChanges: %d removals, %d additions, %d value changes", len(removals), len(additions), len(valueChanges))
// Apply removals first (from end to beginning to avoid index shifting)
for _, removalPath := range removals {
actualPath := strings.TrimSuffix(removalPath, "@remove")
elementIndex := extractIndexFromRemovalPath(actualPath)
arrayPath := getArrayPathFromElementPath(actualPath)
jsonLogger.Debug("Processing removal: path=%s, index=%d, arrayPath=%s", actualPath, elementIndex, arrayPath)
// Find the exact byte range to remove
from, to := findArrayElementRemovalRange(content, arrayPath, elementIndex)
jsonLogger.Debug("Removing bytes %d-%d", from, to)
commands = append(commands, utils.ReplaceCommand{
From: from,
To: to,
With: "",
})
jsonLogger.Debug("Added removal command: From=%d, To=%d, With=\"\"", from, to)
}
// Apply additions (new fields)
for _, additionPath := range additions {
actualPath := strings.TrimSuffix(additionPath, "@add")
newValue := changes[additionPath]
jsonLogger.Debug("Processing addition: path=%s, value=%v", actualPath, newValue)
// Find the parent object to add the field to
parentPath := getParentPath(actualPath)
fieldName := getFieldName(actualPath)
jsonLogger.Debug("Parent path: %s, field name: %s", parentPath, fieldName)
// Get the parent object
var parentResult gjson.Result
if parentPath == "" {
// Adding to root object - get the entire JSON
parentResult = gjson.Parse(content)
} else {
parentResult = gjson.Get(content, parentPath)
}
if !parentResult.Exists() {
jsonLogger.Debug("Parent path %s does not exist, skipping", parentPath)
continue
}
// Find where to insert the new field (at the end of the object)
startPos := int(parentResult.Index + len(parentResult.Raw) - 1) // Before closing brace
jsonLogger.Debug("Inserting at pos %d", startPos)
// Convert the new value to JSON string
newValueStr := convertValueToJSONString(newValue)
// Insert the new field with pretty-printed formatting
// Format: ,"fieldName": { ... }
insertText := fmt.Sprintf(`,"%s": %s`, fieldName, newValueStr)
commands = append(commands, utils.ReplaceCommand{
From: startPos,
To: startPos,
With: insertText,
})
jsonLogger.Debug("Added addition command: From=%d, To=%d, With=%q", startPos, startPos, insertText)
}
// Apply value changes (in reverse order to avoid position shifting)
sort.Slice(valueChanges, func(i, j int) bool {
// Get positions for comparison
resultI := gjson.Get(content, valueChanges[i])
resultJ := gjson.Get(content, valueChanges[j])
return resultI.Index > resultJ.Index // Descending order
})
for _, path := range valueChanges {
newValue := changes[path]
jsonLogger.Debug("Processing value change: path=%s, value=%v", path, newValue)
// Get the current value and its position in the original JSON
result := gjson.Get(content, path)
if !result.Exists() {
jsonLogger.Debug("Path %s does not exist, skipping", path)
continue // Skip if path doesn't exist
}
// Get the exact byte positions of this value
startPos := result.Index
endPos := startPos + len(result.Raw)
jsonLogger.Debug("Found value at pos %d-%d: %q", startPos, endPos, result.Raw)
// Convert the new value to JSON string
newValueStr := convertValueToJSONString(newValue)
jsonLogger.Debug("Converting to: %q", newValueStr)
// Create a replacement command for this specific value
commands = append(commands, utils.ReplaceCommand{
From: int(startPos),
To: int(endPos),
With: newValueStr,
})
jsonLogger.Debug("Added command: From=%d, To=%d, With=%q", int(startPos), int(endPos), newValueStr)
}
return commands, nil
}
// extractIndexFromRemovalPath extracts the array index from a removal path like "Rows.0.Inputs.1@remove"
func extractIndexFromRemovalPath(path string) int {
parts := strings.Split(strings.TrimSuffix(path, "@remove"), ".")
if len(parts) > 0 {
lastPart := parts[len(parts)-1]
if index, err := strconv.Atoi(lastPart); err == nil {
return index
}
}
return -1
}
// getArrayPathFromElementPath converts "Rows.0.Inputs.1" to "Rows.0.Inputs"
func getArrayPathFromElementPath(elementPath string) string {
parts := strings.Split(elementPath, ".")
if len(parts) > 0 {
return strings.Join(parts[:len(parts)-1], ".")
}
return ""
}
// getParentPath extracts the parent path from a full path like "Rows.0.Inputs.1"
func getParentPath(fullPath string) string {
parts := strings.Split(fullPath, ".")
if len(parts) > 0 {
return strings.Join(parts[:len(parts)-1], ".")
}
return ""
}
// getFieldName extracts the field name from a full path like "Rows.0.Inputs.1"
func getFieldName(fullPath string) string {
parts := strings.Split(fullPath, ".")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
// convertValueToJSONString converts a Go interface{} to a JSON string representation
func convertValueToJSONString(value interface{}) string {
switch v := value.(type) {
case string:
return `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
case float64:
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
return strconv.FormatBool(v)
case nil:
return "null"
case map[string]interface{}:
// Handle maps specially to avoid double-escaping of keys
var pairs []string
for key, val := range v {
// The key might already have escaped quotes from Lua, so we need to be careful
// If the key already contains escaped quotes, we need to unescape them first
keyStr := key
if strings.Contains(key, `\"`) {
// Key already has escaped quotes, use it as-is
keyStr = `"` + key + `"`
} else {
// Normal key, escape quotes
keyStr = `"` + strings.ReplaceAll(key, `"`, `\"`) + `"`
}
valStr := convertValueToJSONString(val)
pairs = append(pairs, keyStr+":"+valStr)
}
return "{" + strings.Join(pairs, ",") + "}"
default:
// For other complex types (arrays), we need to use json.Marshal
jsonBytes, err := json.Marshal(v)
if err != nil {
return "null" // Fallback to null if marshaling fails
}
return string(jsonBytes)
}
}
// findArrayElementRemovalRange finds the exact byte range to remove for an array element
func findArrayElementRemovalRange(content, arrayPath string, elementIndex int) (int, int) {
// Get the array using gjson
arrayResult := gjson.Get(content, arrayPath)
if !arrayResult.Exists() || !arrayResult.IsArray() {
return -1, -1
}
// Get all array elements
elements := arrayResult.Array()
if elementIndex >= len(elements) {
return -1, -1
}
// Get the target element
elementResult := elements[elementIndex]
startPos := int(elementResult.Index)
endPos := int(elementResult.Index + len(elementResult.Raw))
// Handle comma removal properly
if elementIndex == 0 && len(elements) > 1 {
// First element but not the only one - remove comma after
for i := endPos; i < len(content) && i < endPos+50; i++ {
if content[i] == ',' {
endPos = i + 1
break
}
}
} else if elementIndex == len(elements)-1 && len(elements) > 1 {
// Last element and not the only one - remove comma before
prevElementEnd := int(elements[elementIndex-1].Index + len(elements[elementIndex-1].Raw))
for i := prevElementEnd; i < startPos && i < len(content); i++ {
if content[i] == ',' {
startPos = i
break
}
}
}
// If it's the only element, don't remove any commas
return startPos, endPos
}
// findDeepChanges recursively finds all paths that need to be changed
func findDeepChanges(basePath string, original, modified interface{}) map[string]interface{} {
changes := make(map[string]interface{})
switch orig := original.(type) {
case map[string]interface{}:
if mod, ok := modified.(map[string]interface{}); ok {
// Check for new keys added in modified data
for key, modValue := range mod {
var currentPath string
if basePath == "" {
currentPath = key
} else {
currentPath = basePath + "." + key
}
if origValue, exists := orig[key]; exists {
// Key exists in both, check if value changed
switch modValue.(type) {
case map[string]interface{}, []interface{}:
// Recursively check nested structures
nestedChanges := findDeepChanges(currentPath, origValue, modValue)
for nestedPath, nestedValue := range nestedChanges {
changes[nestedPath] = nestedValue
}
default:
// Primitive value - check if changed
if !deepEqual(origValue, modValue) {
changes[currentPath] = modValue
}
}
} else {
// New key added - mark for addition
changes[currentPath+"@add"] = modValue
}
}
}
case []interface{}:
if mod, ok := modified.([]interface{}); ok {
// Handle array changes by detecting specific element operations
if len(orig) != len(mod) {
// Array length changed - detect if it's element removal
if len(orig) > len(mod) {
// Element(s) removed - find which ones by comparing content
removedIndices := findRemovedArrayElements(orig, mod)
for _, removedIndex := range removedIndices {
var currentPath string
if basePath == "" {
currentPath = fmt.Sprintf("%d@remove", removedIndex)
} else {
currentPath = fmt.Sprintf("%s.%d@remove", basePath, removedIndex)
}
changes[currentPath] = nil // Mark for removal
}
} else {
// Elements added - more complex, skip for now
}
} else {
// Same length - check individual elements for value changes
for i, modValue := range mod {
var currentPath string
if basePath == "" {
currentPath = strconv.Itoa(i)
} else {
currentPath = basePath + "." + strconv.Itoa(i)
}
if i < len(orig) {
// Index exists in both, check if value changed
switch modValue.(type) {
case map[string]interface{}, []interface{}:
// Recursively check nested structures
nestedChanges := findDeepChanges(currentPath, orig[i], modValue)
for nestedPath, nestedValue := range nestedChanges {
changes[nestedPath] = nestedValue
}
default:
// Primitive value - check if changed
if !deepEqual(orig[i], modValue) {
changes[currentPath] = modValue
}
}
}
}
}
}
default:
// For primitive types, compare directly
if !deepEqual(original, modified) {
if basePath == "" {
changes[""] = modified
} else {
changes[basePath] = modified
}
}
}
return changes
}
// findRemovedArrayElements compares two arrays and returns indices of removed elements
func findRemovedArrayElements(original, modified []interface{}) []int {
var removedIndices []int
// Simple approach: find elements in original that don't exist in modified
for i, origElement := range original {
found := false
for _, modElement := range modified {
if deepEqual(origElement, modElement) {
found = true
break
}
}
if !found {
removedIndices = append(removedIndices, i)
}
}
return removedIndices
}
// deepEqual performs deep comparison of two values
func deepEqual(a, b interface{}) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
switch av := a.(type) {
case map[string]interface{}:
if bv, ok := b.(map[string]interface{}); ok {
if len(av) != len(bv) {
return false
}
for k, v := range av {
if !deepEqual(v, bv[k]) {
return false
}
}
return true
}
return false
case []interface{}:
if bv, ok := b.([]interface{}); ok {
if len(av) != len(bv) {
return false
}
for i, v := range av {
if !deepEqual(v, bv[i]) {
return false
}
}
return true
}
return false
default:
return a == b
}
}
// ToLuaTable converts a Go interface{} to a Lua table recursively
func ToLuaTable(L *lua.LState, data interface{}) (*lua.LTable, error) {
toLuaTableLogger := jsonLogger.WithPrefix("ToLuaTable")
toLuaTableLogger.Debug("Converting Go interface to Lua table")
toLuaTableLogger.Trace("Input data type: %T", data)
switch v := data.(type) {
case map[string]interface{}:
toLuaTableLogger.Debug("Converting map to Lua table")
table := L.CreateTable(0, len(v))
for key, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaTableLogger.Error("Failed to convert map value for key %q: %v", key, err)
return nil, err
}
table.RawSetString(key, luaValue)
}
return table, nil
case []interface{}:
toLuaTableLogger.Debug("Converting slice to Lua table")
table := L.CreateTable(len(v), 0)
for i, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaTableLogger.Error("Failed to convert slice value at index %d: %v", i, err)
return nil, err
}
table.RawSetInt(i+1, luaValue) // Lua arrays are 1-indexed
}
return table, nil
case string:
toLuaTableLogger.Debug("Converting string to Lua string")
return nil, fmt.Errorf("expected table or array, got string")
case float64:
toLuaTableLogger.Debug("Converting float64 to Lua number")
return nil, fmt.Errorf("expected table or array, got number")
case bool:
toLuaTableLogger.Debug("Converting bool to Lua boolean")
return nil, fmt.Errorf("expected table or array, got boolean")
case nil:
toLuaTableLogger.Debug("Converting nil to Lua nil")
return nil, fmt.Errorf("expected table or array, got nil")
default:
toLuaTableLogger.Error("Unsupported type for Lua table conversion: %T", v)
return nil, fmt.Errorf("unsupported type for Lua table conversion: %T", v)
}
}
// ToLuaValue converts a Go interface{} to a Lua value
func ToLuaValue(L *lua.LState, data interface{}) (lua.LValue, error) {
toLuaValueLogger := jsonLogger.WithPrefix("ToLuaValue")
toLuaValueLogger.Debug("Converting Go interface to Lua value")
toLuaValueLogger.Trace("Input data type: %T", data)
switch v := data.(type) {
case map[string]interface{}:
toLuaValueLogger.Debug("Converting map to Lua table")
table := L.CreateTable(0, len(v))
for key, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaValueLogger.Error("Failed to convert map value for key %q: %v", key, err)
return lua.LNil, err
}
table.RawSetString(key, luaValue)
}
return table, nil
case []interface{}:
toLuaValueLogger.Debug("Converting slice to Lua table")
table := L.CreateTable(len(v), 0)
for i, value := range v {
luaValue, err := ToLuaValue(L, value)
if err != nil {
toLuaValueLogger.Error("Failed to convert slice value at index %d: %v", i, err)
return lua.LNil, err
}
table.RawSetInt(i+1, luaValue) // Lua arrays are 1-indexed
}
return table, nil
case string:
toLuaValueLogger.Debug("Converting string to Lua string")
return lua.LString(v), nil
case float64:
toLuaValueLogger.Debug("Converting float64 to Lua number")
return lua.LNumber(v), nil
case bool:
toLuaValueLogger.Debug("Converting bool to Lua boolean")
return lua.LBool(v), nil
case nil:
toLuaValueLogger.Debug("Converting nil to Lua nil")
return lua.LNil, nil
default:
toLuaValueLogger.Error("Unsupported type for Lua value conversion: %T", v)
return lua.LNil, fmt.Errorf("unsupported type for Lua value conversion: %T", v)
}
}

96
processor/json_test.go Normal file
View File

@@ -0,0 +1,96 @@
package processor
import (
"cook/utils"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessJSON(t *testing.T) {
tests := []struct {
name string
input string
luaExpression string
expectedOutput string
expectedMods int
}{
{
name: "Basic JSON object modification",
input: `{"name": "test", "value": 42}`,
luaExpression: `data.value = data.value * 2; return true`,
expectedOutput: `{"name": "test", "value": 84}`,
expectedMods: 1,
},
{
name: "JSON array modification",
input: `{"items": [{"name": "item1", "value": 10}, {"name": "item2", "value": 20}]}`,
luaExpression: `for i, item in ipairs(data.items) do item.value = item.value * 2 end modified = true`,
expectedOutput: `{"items": [{"name": "item1", "value": 20}, {"name": "item2", "value": 40}]}`,
expectedMods: 2,
},
{
name: "JSON nested object modification",
input: `{"config": {"setting1": {"enabled": true, "value": 5}, "setting2": {"enabled": false, "value": 10}}}`,
luaExpression: `data.config.setting1.enabled = false data.config.setting2.value = 15 modified = true`,
expectedOutput: `{"config": {"setting1": {"enabled": false, "value": 5}, "setting2": {"enabled": false, "value": 15}}}`,
expectedMods: 2,
},
{
name: "JSON no modification",
input: `{"name": "test", "value": 42}`,
luaExpression: `return false`,
expectedOutput: `{"name": "test", "value": 42}`,
expectedMods: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
command := utils.ModifyCommand{
Name: tt.name,
JSON: true,
Lua: tt.luaExpression,
}
modifications, err := ProcessJSON(tt.input, command, "test.json")
assert.NoError(t, err, "ProcessJSON failed: %v", err)
if len(modifications) > 0 {
// Execute modifications
result, count := utils.ExecuteModifications(modifications, tt.input)
assert.Equal(t, tt.expectedMods, count, "Expected %d modifications, got %d", tt.expectedMods, count)
assert.Equal(t, tt.expectedOutput, result, "Expected output: %s, got: %s", tt.expectedOutput, result)
} else {
assert.Equal(t, 0, tt.expectedMods, "Expected no modifications but got some")
}
})
}
}
func TestToLuaValue(t *testing.T) {
L, err := NewLuaState()
if err != nil {
t.Fatalf("Failed to create Lua state: %v", err)
}
defer L.Close()
tests := []struct {
name string
input interface{}
expected string
}{
{"string", "hello", "hello"},
{"number", 42.0, "42"},
{"boolean", true, "true"},
{"nil", nil, "nil"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ToLuaValue(L, tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result.String())
})
}
}

View File

@@ -4,153 +4,145 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
"cook/utils"
logger "git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
) )
// processorLogger is a scoped logger for the processor package.
var processorLogger = logger.Default.WithPrefix("processor")
// Maybe we make this an interface again for the shits and giggles // Maybe we make this an interface again for the shits and giggles
// We will see, it could easily be... // We will see, it could easily be...
var globalVariables = map[string]interface{}{}
func SetVariables(vars map[string]interface{}) {
for k, v := range vars {
globalVariables[k] = v
}
}
func NewLuaState() (*lua.LState, error) { func NewLuaState() (*lua.LState, error) {
newLStateLogger := processorLogger.WithPrefix("NewLuaState")
newLStateLogger.Debug("Creating new Lua state")
L := lua.NewState() L := lua.NewState()
// defer L.Close() // defer L.Close()
// Load math library // Load math library
logger.Debug("Loading Lua math library") newLStateLogger.Debug("Loading Lua math library")
L.Push(L.GetGlobal("require")) L.Push(L.GetGlobal("require"))
L.Push(lua.LString("math")) L.Push(lua.LString("math"))
if err := L.PCall(1, 1, nil); err != nil { if err := L.PCall(1, 1, nil); err != nil {
logger.Error("Failed to load Lua math library: %v", err) newLStateLogger.Error("Failed to load Lua math library: %v", err)
return nil, fmt.Errorf("error loading Lua math library: %v", err) return nil, fmt.Errorf("error loading Lua math library: %v", err)
} }
newLStateLogger.Debug("Lua math library loaded")
// Initialize helper functions // Initialize helper functions
logger.Debug("Initializing Lua helper functions") newLStateLogger.Debug("Initializing Lua helper functions")
if err := InitLuaHelpers(L); err != nil { if err := InitLuaHelpers(L); err != nil {
logger.Error("Failed to initialize Lua helper functions: %v", err) newLStateLogger.Error("Failed to initialize Lua helper functions: %v", err)
return nil, err return nil, err
} }
newLStateLogger.Debug("Lua helper functions initialized")
// Inject global variables
if len(globalVariables) > 0 {
newLStateLogger.Debug("Injecting %d global variables into Lua state", len(globalVariables))
for k, v := range globalVariables {
switch val := v.(type) {
case int:
L.SetGlobal(k, lua.LNumber(float64(val)))
case int64:
L.SetGlobal(k, lua.LNumber(float64(val)))
case float32:
L.SetGlobal(k, lua.LNumber(float64(val)))
case float64:
L.SetGlobal(k, lua.LNumber(val))
case string:
L.SetGlobal(k, lua.LString(val))
case bool:
if val {
L.SetGlobal(k, lua.LTrue)
} else {
L.SetGlobal(k, lua.LFalse)
}
default:
// Fallback to string representation
L.SetGlobal(k, lua.LString(fmt.Sprintf("%v", val)))
}
}
}
newLStateLogger.Debug("New Lua state created successfully")
return L, nil return L, nil
} }
// func Process(filename string, pattern string, luaExpr string) (int, int, error) {
// logger.Debug("Processing file %q with pattern %q", filename, pattern)
//
// // Read file content
// cwd, err := os.Getwd()
// if err != nil {
// logger.Error("Failed to get current working directory: %v", err)
// return 0, 0, fmt.Errorf("error getting current working directory: %v", err)
// }
//
// fullPath := filepath.Join(cwd, filename)
// logger.Trace("Reading file from: %s", fullPath)
//
// stat, err := os.Stat(fullPath)
// if err != nil {
// logger.Error("Failed to stat file %s: %v", fullPath, err)
// return 0, 0, fmt.Errorf("error getting file info: %v", err)
// }
// logger.Debug("File size: %d bytes, modified: %s", stat.Size(), stat.ModTime().Format(time.RFC3339))
//
// content, err := os.ReadFile(fullPath)
// if err != nil {
// logger.Error("Failed to read file %s: %v", fullPath, err)
// return 0, 0, fmt.Errorf("error reading file: %v", err)
// }
//
// fileContent := string(content)
// logger.Trace("File read successfully: %d bytes, hash: %x", len(content), md5sum(content))
//
// // Detect and log file type
// fileType := detectFileType(filename, fileContent)
// if fileType != "" {
// logger.Debug("Detected file type: %s", fileType)
// }
//
// // Process the content
// logger.Debug("Starting content processing")
// modifiedContent, modCount, matchCount, err := ProcessContent(fileContent, pattern, luaExpr)
// if err != nil {
// logger.Error("Processing error: %v", err)
// return 0, 0, err
// }
//
// logger.Debug("Processing results: %d matches, %d modifications", matchCount, modCount)
//
// // If we made modifications, save the file
// if modCount > 0 {
// // Calculate changes summary
// changePercent := float64(len(modifiedContent)) / float64(len(fileContent)) * 100
// logger.Info("File size change: %d → %d bytes (%.1f%%)",
// len(fileContent), len(modifiedContent), changePercent)
//
// logger.Debug("Writing modified content to %s", fullPath)
// err = os.WriteFile(fullPath, []byte(modifiedContent), 0644)
// if err != nil {
// logger.Error("Failed to write to file %s: %v", fullPath, err)
// return 0, 0, fmt.Errorf("error writing file: %v", err)
// }
// logger.Debug("File written successfully, new hash: %x", md5sum([]byte(modifiedContent)))
// } else if matchCount > 0 {
// logger.Debug("No content modifications needed for %d matches", matchCount)
// } else {
// logger.Debug("No matches found in file")
// }
//
// return modCount, matchCount, nil
// }
// FromLua converts a Lua table to a struct or map recursively // FromLua converts a Lua table to a struct or map recursively
func FromLua(L *lua.LState, luaValue lua.LValue) (interface{}, error) { func FromLua(L *lua.LState, luaValue lua.LValue) (interface{}, error) {
fromLuaLogger := processorLogger.WithPrefix("FromLua").WithField("luaType", luaValue.Type().String())
fromLuaLogger.Debug("Converting Lua value to Go interface")
switch v := luaValue.(type) { switch v := luaValue.(type) {
// Well shit...
// Tables in lua are both maps and arrays
// As arrays they are ordered and as maps, obviously, not
// So when we parse them to a go map we fuck up the order for arrays
// We have to find a better way....
case *lua.LTable: case *lua.LTable:
fromLuaLogger.Debug("Processing Lua table")
isArray, err := IsLuaTableArray(L, v) isArray, err := IsLuaTableArray(L, v)
if err != nil { if err != nil {
fromLuaLogger.Error("Failed to determine if Lua table is array: %v", err)
return nil, err return nil, err
} }
fromLuaLogger.Debug("Lua table is array: %t", isArray)
if isArray { if isArray {
fromLuaLogger.Debug("Converting Lua table to Go array")
result := make([]interface{}, 0) result := make([]interface{}, 0)
v.ForEach(func(key lua.LValue, value lua.LValue) { v.ForEach(func(key lua.LValue, value lua.LValue) {
converted, _ := FromLua(L, value) converted, _ := FromLua(L, value)
result = append(result, converted) result = append(result, converted)
}) })
fromLuaLogger.Trace("Converted Go array: %v", result)
return result, nil return result, nil
} else { } else {
fromLuaLogger.Debug("Converting Lua table to Go map")
result := make(map[string]interface{}) result := make(map[string]interface{})
v.ForEach(func(key lua.LValue, value lua.LValue) { v.ForEach(func(key lua.LValue, value lua.LValue) {
converted, _ := FromLua(L, value) converted, _ := FromLua(L, value)
result[key.String()] = converted result[key.String()] = converted
}) })
fromLuaLogger.Trace("Converted Go map: %v", result)
return result, nil return result, nil
} }
case lua.LString: case lua.LString:
fromLuaLogger.Debug("Converting Lua string to Go string")
fromLuaLogger.Trace("Lua string: %q", string(v))
return string(v), nil return string(v), nil
case lua.LBool: case lua.LBool:
fromLuaLogger.Debug("Converting Lua boolean to Go boolean")
fromLuaLogger.Trace("Lua boolean: %t", bool(v))
return bool(v), nil return bool(v), nil
case lua.LNumber: case lua.LNumber:
fromLuaLogger.Debug("Converting Lua number to Go float64")
fromLuaLogger.Trace("Lua number: %f", float64(v))
return float64(v), nil return float64(v), nil
default: default:
fromLuaLogger.Debug("Unsupported Lua type, returning nil")
return nil, nil return nil, nil
} }
} }
func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) { func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) {
logger.Trace("Checking if Lua table is an array") isLuaTableArrayLogger := processorLogger.WithPrefix("IsLuaTableArray")
isLuaTableArrayLogger.Debug("Checking if Lua table is an array")
isLuaTableArrayLogger.Trace("Lua table input: %v", v)
L.SetGlobal("table_to_check", v) L.SetGlobal("table_to_check", v)
// Use our predefined helper function from InitLuaHelpers // Use our predefined helper function from InitLuaHelpers
err := L.DoString(`is_array = isArray(table_to_check)`) err := L.DoString(`is_array = isArray(table_to_check)`)
if err != nil { if err != nil {
logger.Error("Error determining if table is an array: %v", err) isLuaTableArrayLogger.Error("Error determining if table is an array: %v", err)
return false, fmt.Errorf("error determining if table is array: %w", err) return false, fmt.Errorf("error determining if table is array: %w", err)
} }
@@ -158,13 +150,15 @@ func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) {
isArray := L.GetGlobal("is_array") isArray := L.GetGlobal("is_array")
// LVIsFalse returns true if a given LValue is a nil or false otherwise false. // LVIsFalse returns true if a given LValue is a nil or false otherwise false.
result := !lua.LVIsFalse(isArray) result := !lua.LVIsFalse(isArray)
logger.Trace("Lua table is array: %v", result) isLuaTableArrayLogger.Debug("Lua table is array: %t", result)
isLuaTableArrayLogger.Trace("isArray result Lua value: %v", isArray)
return result, nil return result, nil
} }
// InitLuaHelpers initializes common Lua helper functions // InitLuaHelpers initializes common Lua helper functions
func InitLuaHelpers(L *lua.LState) error { func InitLuaHelpers(L *lua.LState) error {
logger.Debug("Loading Lua helper functions") initLuaHelpersLogger := processorLogger.WithPrefix("InitLuaHelpers")
initLuaHelpersLogger.Debug("Loading Lua helper functions")
helperScript := ` helperScript := `
-- Custom Lua helpers for math operations -- Custom Lua helpers for math operations
@@ -245,26 +239,22 @@ end
modified = false modified = false
` `
if err := L.DoString(helperScript); err != nil { if err := L.DoString(helperScript); err != nil {
logger.Error("Failed to load Lua helper functions: %v", err) initLuaHelpersLogger.Error("Failed to load Lua helper functions: %v", err)
return fmt.Errorf("error loading helper functions: %v", err) return fmt.Errorf("error loading helper functions: %v", err)
} }
initLuaHelpersLogger.Debug("Lua helper functions loaded")
logger.Debug("Setting up Lua print function to Go") initLuaHelpersLogger.Debug("Setting up Lua print function to Go")
L.SetGlobal("print", L.NewFunction(printToGo)) L.SetGlobal("print", L.NewFunction(printToGo))
L.SetGlobal("fetch", L.NewFunction(fetch)) L.SetGlobal("fetch", L.NewFunction(fetch))
L.SetGlobal("re", L.NewFunction(EvalRegex))
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
return nil return nil
} }
// LimitString truncates a string to maxLen and adds "..." if truncated
func LimitString(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", "\\n")
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func PrependLuaAssignment(luaExpr string) string { func PrependLuaAssignment(luaExpr string) string {
prependLuaAssignmentLogger := processorLogger.WithPrefix("PrependLuaAssignment").WithField("originalLuaExpr", luaExpr)
prependLuaAssignmentLogger.Debug("Prepending Lua assignment if necessary")
// Auto-prepend v1 for expressions starting with operators // Auto-prepend v1 for expressions starting with operators
if strings.HasPrefix(luaExpr, "*") || if strings.HasPrefix(luaExpr, "*") ||
strings.HasPrefix(luaExpr, "/") || strings.HasPrefix(luaExpr, "/") ||
@@ -273,30 +263,32 @@ func PrependLuaAssignment(luaExpr string) string {
strings.HasPrefix(luaExpr, "^") || strings.HasPrefix(luaExpr, "^") ||
strings.HasPrefix(luaExpr, "%") { strings.HasPrefix(luaExpr, "%") {
luaExpr = "v1 = v1" + luaExpr luaExpr = "v1 = v1" + luaExpr
prependLuaAssignmentLogger.Debug("Prepended 'v1 = v1' due to operator prefix")
} else if strings.HasPrefix(luaExpr, "=") { } else if strings.HasPrefix(luaExpr, "=") {
// Handle direct assignment with = operator // Handle direct assignment with = operator
luaExpr = "v1 " + luaExpr luaExpr = "v1 " + luaExpr
prependLuaAssignmentLogger.Debug("Prepended 'v1' due to direct assignment operator")
} }
// Add assignment if needed // Add assignment if needed
if !strings.Contains(luaExpr, "=") { if !strings.Contains(luaExpr, "=") {
luaExpr = "v1 = " + luaExpr luaExpr = "v1 = " + luaExpr
prependLuaAssignmentLogger.Debug("Prepended 'v1 =' as no assignment was found")
} }
prependLuaAssignmentLogger.Trace("Final Lua expression after prepending: %q", luaExpr)
return luaExpr return luaExpr
} }
// BuildLuaScript prepares a Lua expression from shorthand notation // BuildLuaScript prepares a Lua expression from shorthand notation
func BuildLuaScript(luaExpr string) string { func BuildLuaScript(luaExpr string) string {
logger.Debug("Building Lua script from expression: %s", luaExpr) buildLuaScriptLogger := processorLogger.WithPrefix("BuildLuaScript").WithField("inputLuaExpr", luaExpr)
buildLuaScriptLogger.Debug("Building full Lua script from expression")
// Perform $var substitutions from globalVariables
luaExpr = replaceVariables(luaExpr)
luaExpr = PrependLuaAssignment(luaExpr) luaExpr = PrependLuaAssignment(luaExpr)
// This allows the user to specify whether or not they modified a value
// If they do nothing we assume they did modify (no return at all)
// If they return before our return then they themselves specify what they did
// If nothing is returned lua assumes nil
// So we can say our value was modified if the return value is either nil or true
// If the return value is false then the user wants to keep the original
fullScript := fmt.Sprintf(` fullScript := fmt.Sprintf(`
function run() function run()
%s %s
@@ -304,11 +296,60 @@ func BuildLuaScript(luaExpr string) string {
local res = run() local res = run()
modified = res == nil or res modified = res == nil or res
`, luaExpr) `, luaExpr)
buildLuaScriptLogger.Trace("Generated full Lua script: %q", utils.LimitString(fullScript, 200))
return fullScript return fullScript
} }
// BuildJSONLuaScript prepares a Lua expression for JSON mode
func BuildJSONLuaScript(luaExpr string) string {
buildJsonLuaScriptLogger := processorLogger.WithPrefix("BuildJSONLuaScript").WithField("inputLuaExpr", luaExpr)
buildJsonLuaScriptLogger.Debug("Building full Lua script for JSON mode from expression")
// Perform $var substitutions from globalVariables
luaExpr = replaceVariables(luaExpr)
fullScript := fmt.Sprintf(`
function run()
%s
end
local res = run()
modified = res == nil or res
`, luaExpr)
buildJsonLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
return fullScript
}
func replaceVariables(expr string) string {
// $varName -> literal value
varNameRe := regexp.MustCompile(`\$(\w+)`)
return varNameRe.ReplaceAllStringFunc(expr, func(m string) string {
name := varNameRe.FindStringSubmatch(m)[1]
if v, ok := globalVariables[name]; ok {
switch val := v.(type) {
case int, int64, float32, float64:
return fmt.Sprintf("%v", val)
case bool:
if val {
return "true"
} else {
return "false"
}
case string:
// Quote strings for Lua literal
return fmt.Sprintf("%q", val)
default:
return fmt.Sprintf("%q", fmt.Sprintf("%v", val))
}
}
return m
})
}
func printToGo(L *lua.LState) int { func printToGo(L *lua.LState) int {
printToGoLogger := processorLogger.WithPrefix("printToGo")
printToGoLogger.Debug("Lua print function called, redirecting to Go logger")
top := L.GetTop() top := L.GetTop()
args := make([]interface{}, top) args := make([]interface{}, top)
@@ -322,20 +363,26 @@ func printToGo(L *lua.LState) int {
parts = append(parts, fmt.Sprintf("%v", arg)) parts = append(parts, fmt.Sprintf("%v", arg))
} }
message := strings.Join(parts, " ") message := strings.Join(parts, " ")
printToGoLogger.Trace("Lua print message: %q", message)
// Use the LUA log level with a script tag // Use the LUA log level with a script tag
logger.Lua("%s", message) logger.Lua("%s", message)
printToGoLogger.Debug("Message logged from Lua")
return 0 return 0
} }
func fetch(L *lua.LState) int { func fetch(L *lua.LState) int {
fetchLogger := processorLogger.WithPrefix("fetch")
fetchLogger.Debug("Lua fetch function called")
// Get URL from first argument // Get URL from first argument
url := L.ToString(1) url := L.ToString(1)
if url == "" { if url == "" {
fetchLogger.Error("Fetch failed: URL is required")
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString("URL is required")) L.Push(lua.LString("URL is required"))
return 2 return 2
} }
fetchLogger.Debug("Fetching URL: %q", url)
// Get options from second argument if provided // Get options from second argument if provided
var method string = "GET" var method string = "GET"
@@ -345,30 +392,38 @@ func fetch(L *lua.LState) int {
if L.GetTop() > 1 { if L.GetTop() > 1 {
options := L.ToTable(2) options := L.ToTable(2)
if options != nil { if options != nil {
fetchLogger.Debug("Processing fetch options")
// Get method // Get method
if methodVal := options.RawGetString("method"); methodVal != lua.LNil { if methodVal := options.RawGetString("method"); methodVal != lua.LNil {
method = methodVal.String() method = methodVal.String()
fetchLogger.Trace("Method from options: %q", method)
} }
// Get headers // Get headers
if headersVal := options.RawGetString("headers"); headersVal != lua.LNil { if headersVal := options.RawGetString("headers"); headersVal != lua.LNil {
if headersTable, ok := headersVal.(*lua.LTable); ok { if headersTable, ok := headersVal.(*lua.LTable); ok {
fetchLogger.Trace("Processing headers table")
headersTable.ForEach(func(key lua.LValue, value lua.LValue) { headersTable.ForEach(func(key lua.LValue, value lua.LValue) {
headers[key.String()] = value.String() headers[key.String()] = value.String()
fetchLogger.Trace("Header: %q = %q", key.String(), value.String())
}) })
} }
fetchLogger.Trace("All headers: %v", headers)
} }
// Get body // Get body
if bodyVal := options.RawGetString("body"); bodyVal != lua.LNil { if bodyVal := options.RawGetString("body"); bodyVal != lua.LNil {
body = bodyVal.String() body = bodyVal.String()
fetchLogger.Trace("Body from options: %q", utils.LimitString(body, 100))
} }
} }
} }
fetchLogger.Debug("Fetch request details: Method=%q, URL=%q, BodyLength=%d, Headers=%v", method, url, len(body), headers)
// Create HTTP request // Create HTTP request
req, err := http.NewRequest(method, url, strings.NewReader(body)) req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil { if err != nil {
fetchLogger.Error("Error creating HTTP request: %v", err)
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("Error creating request: %v", err))) L.Push(lua.LString(fmt.Sprintf("Error creating request: %v", err)))
return 2 return 2
@@ -378,24 +433,33 @@ func fetch(L *lua.LState) int {
for key, value := range headers { for key, value := range headers {
req.Header.Set(key, value) req.Header.Set(key, value)
} }
fetchLogger.Debug("HTTP request created and headers set")
fetchLogger.Trace("HTTP Request: %+v", req)
// Make request // Make request
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
fetchLogger.Error("Error making HTTP request: %v", err)
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("Error making request: %v", err))) L.Push(lua.LString(fmt.Sprintf("Error making request: %v", err)))
return 2 return 2
} }
defer resp.Body.Close() defer func() {
fetchLogger.Debug("Closing HTTP response body")
resp.Body.Close()
}()
fetchLogger.Debug("HTTP request executed. Status Code: %d", resp.StatusCode)
// Read response body // Read response body
bodyBytes, err := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
fetchLogger.Error("Error reading response body: %v", err)
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("Error reading response: %v", err))) L.Push(lua.LString(fmt.Sprintf("Error reading response: %v", err)))
return 2 return 2
} }
fetchLogger.Trace("Response body length: %d", len(bodyBytes))
// Create response table // Create response table
responseTable := L.NewTable() responseTable := L.NewTable()
@@ -403,14 +467,101 @@ func fetch(L *lua.LState) int {
responseTable.RawSetString("statusText", lua.LString(resp.Status)) responseTable.RawSetString("statusText", lua.LString(resp.Status))
responseTable.RawSetString("ok", lua.LBool(resp.StatusCode >= 200 && resp.StatusCode < 300)) responseTable.RawSetString("ok", lua.LBool(resp.StatusCode >= 200 && resp.StatusCode < 300))
responseTable.RawSetString("body", lua.LString(string(bodyBytes))) responseTable.RawSetString("body", lua.LString(string(bodyBytes)))
fetchLogger.Debug("Created Lua response table")
// Set headers in response // Set headers in response
headersTable := L.NewTable() headersTable := L.NewTable()
for key, values := range resp.Header { for key, values := range resp.Header {
headersTable.RawSetString(key, lua.LString(values[0])) headersTable.RawSetString(key, lua.LString(values[0]))
fetchLogger.Trace("Response header: %q = %q", key, values[0])
} }
responseTable.RawSetString("headers", headersTable) responseTable.RawSetString("headers", headersTable)
fetchLogger.Trace("Full response table: %v", responseTable)
L.Push(responseTable) L.Push(responseTable)
fetchLogger.Debug("Pushed response table to Lua stack")
return 1 return 1
} }
func EvalRegex(L *lua.LState) int {
evalRegexLogger := processorLogger.WithPrefix("evalRegex")
evalRegexLogger.Debug("Lua evalRegex function called")
defer func() {
if r := recover(); r != nil {
evalRegexLogger.Error("Panic in EvalRegex: %v", r)
// Push empty table on panic
emptyTable := L.NewTable()
L.Push(emptyTable)
}
}()
pattern := L.ToString(1)
input := L.ToString(2)
evalRegexLogger.Debug("Pattern: %q, Input: %q", pattern, input)
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(input)
evalRegexLogger.Debug("Go regex matches: %v (count: %d)", matches, len(matches))
matchesTable := L.NewTable()
for i, match := range matches {
matchesTable.RawSetInt(i, lua.LString(match))
evalRegexLogger.Debug("Set table[%d] = %q", i, match)
}
L.Push(matchesTable)
evalRegexLogger.Debug("Pushed matches table to Lua stack")
return 1
}
// GetLuaFunctionsHelp returns a comprehensive help string for all available Lua functions
func GetLuaFunctionsHelp() string {
return `Lua Functions Available in Global Environment:
MATH FUNCTIONS:
min(a, b) - Returns the minimum of two numbers
max(a, b) - Returns the maximum of two numbers
round(x, n) - Rounds x to n decimal places (default 0)
floor(x) - Returns the floor of x
ceil(x) - Returns the ceiling of x
STRING FUNCTIONS:
upper(s) - Converts string to uppercase
lower(s) - Converts string to lowercase
format(s, ...) - Formats string using Lua string.format
trim(s) - Removes leading/trailing whitespace
strsplit(inputstr, sep) - Splits string by separator (default: whitespace)
num(str) - Converts string to number (returns 0 if invalid)
str(num) - Converts number to string
is_number(str) - Returns true if string is numeric
TABLE FUNCTIONS:
DumpTable(table, depth) - Prints table structure recursively
isArray(t) - Returns true if table is a sequential array
HTTP FUNCTIONS:
fetch(url, options) - Makes HTTP request, returns response table
options: {method="GET", headers={}, body=""}
returns: {status, statusText, ok, body, headers}
REGEX FUNCTIONS:
re(pattern, input) - Applies regex pattern to input string
returns: table with matches (index 0 = full match, 1+ = groups)
UTILITY FUNCTIONS:
print(...) - Prints arguments to Go logger
EXAMPLES:
round(3.14159, 2) -> 3.14
strsplit("a,b,c", ",") -> {"a", "b", "c"}
upper("hello") -> "HELLO"
min(5, 3) -> 3
num("123") -> 123
is_number("abc") -> false
fetch("https://api.example.com/data")
re("(\\w+)@(\\w+)", "user@domain.com") -> {"user@domain.com", "user", "domain.com"}`
}

162
processor/processor_test.go Normal file
View File

@@ -0,0 +1,162 @@
package processor_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
lua "github.com/yuin/gopher-lua"
"cook/processor"
)
// Happy Path: Function correctly returns all regex capture groups as Lua table when given valid pattern and input.
func TestEvalRegex_CaptureGroupsReturned(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `(\w+)-(\d+)`
input := "test-42"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result, "Expected return value to be 0")
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
expected := []string{"test-42", "test", "42"}
for i, v := range expected {
val := tbl.RawGetString(fmt.Sprintf("%d", i))
assert.Equal(t, lua.LString(v), val, "Expected index %d to be %q", i, v)
}
}
// Happy Path: Function returns an empty Lua table when regex pattern does not match input string.
func TestEvalRegex_NoMatchReturnsEmptyTable(t *testing.T) {
L := lua.NewState()
defer L.Close()
L.Push(lua.LString(`(foo)(bar)`))
L.Push(lua.LString("no-match-here"))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Zero(t, count, "Expected no items in the table for non-matching input")
}
// Happy Path: Function handles patterns with no capture groups by returning the full match in the Lua table.
func TestEvalRegex_NoCaptureGroups(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `foo\d+`
input := "foo123"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
fullMatch := tbl.RawGetString("0")
assert.Equal(t, lua.LString("foo123"), fullMatch)
// There should be only the full match (index 0)
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Equal(t, 1, count)
}
// Edge Case: Function panics or errors when given an invalid regex pattern.
func TestEvalRegex_InvalidPattern(t *testing.T) {
L := lua.NewState()
defer L.Close()
pattern := `([a-z` // invalid regex
L.Push(lua.LString(pattern))
L.Push(lua.LString("someinput"))
defer func() {
if r := recover(); r == nil {
t.Error("Expected panic for invalid regex pattern, but did not panic")
}
}()
processor.EvalRegex(L)
}
// Edge Case: Function returns an empty Lua table when input string is empty.
func TestEvalRegex_EmptyInputString(t *testing.T) {
L := lua.NewState()
defer L.Close()
L.Push(lua.LString(`(foo)`))
L.Push(lua.LString(""))
result := processor.EvalRegex(L)
assert.Equal(t, 0, result)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
// Should be empty
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Zero(t, count, "Expected empty table when input is empty")
}
// Edge Case: Function handles nil or missing arguments gracefully without causing a runtime panic.
func TestEvalRegex_MissingArguments(t *testing.T) {
L := lua.NewState()
defer L.Close()
defer func() {
if r := recover(); r != nil {
t.Errorf("Did not expect panic when arguments are missing, got: %v", r)
}
}()
// No arguments pushed at all
processor.EvalRegex(L)
// Should just not match anything or produce empty table, but must not panic
}
func TestEvalComplexRegex(t *testing.T) {
// 23:47:35.567068 processor.go:369 [g:22 ] [LUA] Pistol_Round ^((Bulk_)?(Pistol|Rifle).*?Round.*?)$
L := lua.NewState()
defer L.Close()
pattern := `^((Bulk_)?(Pistol|Rifle).*?Round.*?)$`
input := "Pistol_Round"
L.Push(lua.LString(pattern))
L.Push(lua.LString(input))
processor.EvalRegex(L)
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
count := 0
tbl.ForEach(func(k, v lua.LValue) {
fmt.Println(k, v)
count++
})
assert.Equal(t, 1, count)
}

View File

@@ -12,6 +12,9 @@ import (
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
) )
// regexLogger is a scoped logger for the processor/regex package.
var regexLogger = logger.Default.WithPrefix("processor/regex")
type CaptureGroup struct { type CaptureGroup struct {
Name string Name string
Value string Value string
@@ -23,52 +26,59 @@ type CaptureGroup struct {
// The filename here exists ONLY so we can pass it to the lua environment // The filename here exists ONLY so we can pass it to the lua environment
// It's not used for anything else // It's not used for anything else
func ProcessRegex(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) { func ProcessRegex(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) {
var commands []utils.ReplaceCommand processRegexLogger := regexLogger.WithPrefix("ProcessRegex").WithField("commandName", command.Name).WithField("file", filename)
logger.Trace("Processing regex: %q", command.Regex) processRegexLogger.Debug("Starting regex processing for file")
processRegexLogger.Trace("Initial file content length: %d", len(content))
processRegexLogger.Trace("Command details: %+v", command)
var commands []utils.ReplaceCommand
// Start timing the regex processing // Start timing the regex processing
startTime := time.Now() startTime := time.Now()
// We don't HAVE to do this multiple times for a pattern // We don't HAVE to do this multiple times for a pattern
// But it's quick enough for us to not care // But it's quick enough for us to not care
pattern := resolveRegexPlaceholders(command.Regex) pattern := resolveRegexPlaceholders(command.Regex)
processRegexLogger.Debug("Resolved regex placeholders. Pattern: %s", pattern)
// I'm not too happy about having to trim regex, we could have meaningful whitespace or newlines // I'm not too happy about having to trim regex, we could have meaningful whitespace or newlines
// But it's a compromise that allows us to use | in yaml // But it's a compromise that allows us to use | in yaml
// Otherwise we would have to escape every god damn pair of quotation marks // Otherwise we would have to escape every god damn pair of quotation marks
// And a bunch of other shit // And a bunch of other shit
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
logger.Debug("Compiling regex pattern: %s", pattern) processRegexLogger.Debug("Trimmed regex pattern: %s", pattern)
patternCompileStart := time.Now() patternCompileStart := time.Now()
compiledPattern, err := regexp.Compile(pattern) compiledPattern, err := regexp.Compile(pattern)
if err != nil { if err != nil {
logger.Error("Error compiling pattern: %v", err) processRegexLogger.Error("Error compiling pattern %q: %v", pattern, err)
return commands, fmt.Errorf("error compiling pattern: %v", err) return commands, fmt.Errorf("error compiling pattern: %v", err)
} }
logger.Debug("Compiled pattern successfully in %v: %s", time.Since(patternCompileStart), pattern) processRegexLogger.Debug("Compiled pattern successfully in %v", time.Since(patternCompileStart))
// Same here, it's just string concatenation, it won't kill us // Same here, it's just string concatenation, it won't kill us
// More important is that we don't fuck up the command // More important is that we don't fuck up the command
// But we shouldn't be able to since it's passed by value // But we shouldn't be able to since it's passed by value
previous := command.Lua previousLuaExpr := command.Lua
luaExpr := BuildLuaScript(command.Lua) luaExpr := BuildLuaScript(command.Lua)
logger.Debug("Transformed Lua expression: %q → %q", previous, luaExpr) processRegexLogger.Debug("Transformed Lua expression: %q → %q", previousLuaExpr, luaExpr)
processRegexLogger.Trace("Full Lua script: %q", utils.LimitString(luaExpr, 200))
// Process all regex matches // Process all regex matches
matchFindStart := time.Now() matchFindStart := time.Now()
indices := compiledPattern.FindAllStringSubmatchIndex(content, -1) indices := compiledPattern.FindAllStringSubmatchIndex(content, -1)
matchFindDuration := time.Since(matchFindStart) matchFindDuration := time.Since(matchFindStart)
logger.Debug("Found %d matches in content of length %d (search took %v)", processRegexLogger.Debug("Found %d matches in content of length %d (search took %v)",
len(indices), len(content), matchFindDuration) len(indices), len(content), matchFindDuration)
processRegexLogger.Trace("Match indices: %v", indices)
// Log pattern complexity metrics // Log pattern complexity metrics
patternComplexity := estimatePatternComplexity(pattern) patternComplexity := estimatePatternComplexity(pattern)
logger.Debug("Pattern complexity estimate: %d", patternComplexity) processRegexLogger.Debug("Pattern complexity estimate: %d", patternComplexity)
if len(indices) == 0 { if len(indices) == 0 {
logger.Warning("No matches found for regex: %q", pattern) processRegexLogger.Warning("No matches found for regex: %q", pattern)
logger.Debug("Total regex processing time: %v", time.Since(startTime)) processRegexLogger.Debug("Total regex processing time: %v", time.Since(startTime))
return commands, nil return commands, nil
} }
@@ -77,12 +87,13 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
// By going backwards we fuck up all the indices to the end of the file that we don't care about // By going backwards we fuck up all the indices to the end of the file that we don't care about
// Because there either aren't any (last match) or they're already modified (subsequent matches) // Because there either aren't any (last match) or they're already modified (subsequent matches)
for i, matchIndices := range indices { for i, matchIndices := range indices {
logger.Debug("Processing match %d of %d", i+1, len(indices)) matchLogger := processRegexLogger.WithField("matchNum", i+1)
logger.Trace("Match indices: %v (match position %d-%d)", matchIndices, matchIndices[0], matchIndices[1]) matchLogger.Debug("Processing match %d of %d", i+1, len(indices))
matchLogger.Trace("Match indices: %v (match position %d-%d)", matchIndices, matchIndices[0], matchIndices[1])
L, err := NewLuaState() L, err := NewLuaState()
if err != nil { if err != nil {
logger.Error("Error creating Lua state: %v", err) matchLogger.Error("Error creating Lua state: %v", err)
return commands, fmt.Errorf("error creating Lua state: %v", err) return commands, fmt.Errorf("error creating Lua state: %v", err)
} }
L.SetGlobal("file", lua.LString(filename)) L.SetGlobal("file", lua.LString(filename))
@@ -90,7 +101,7 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
// Maybe we want to close them every iteration // Maybe we want to close them every iteration
// We'll leave it as is for now // We'll leave it as is for now
defer L.Close() defer L.Close()
logger.Trace("Lua state created successfully for match %d", i+1) matchLogger.Trace("Lua state created successfully for match %d", i+1)
// Why we're doing this whole song and dance of indices is to properly handle empty matches // Why we're doing this whole song and dance of indices is to properly handle empty matches
// Plus it's a little cleaner to surgically replace our matches // Plus it's a little cleaner to surgically replace our matches
@@ -99,20 +110,17 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
// So when we're cutting open the array we say 0:7 + modified + 7:end // So when we're cutting open the array we say 0:7 + modified + 7:end
// As if concatenating in the middle of the array // As if concatenating in the middle of the array
// Plus it supports lookarounds // Plus it supports lookarounds
match := content[matchIndices[0]:matchIndices[1]] matchContent := content[matchIndices[0]:matchIndices[1]]
matchPreview := match matchPreview := utils.LimitString(matchContent, 50)
if len(match) > 50 { matchLogger.Trace("Matched content: %q (length: %d)", matchPreview, len(matchContent))
matchPreview = match[:47] + "..."
}
logger.Trace("Matched content: %q (length: %d)", matchPreview, len(match))
groups := matchIndices[2:] groups := matchIndices[2:]
if len(groups) <= 0 { if len(groups) <= 0 {
logger.Warning("No capture groups found for match %q and regex %q", matchPreview, pattern) matchLogger.Warning("No capture groups found for match %q and regex %q", matchPreview, pattern)
continue continue
} }
if len(groups)%2 == 1 { if len(groups)%2 == 1 {
logger.Warning("Invalid number of group indices (%d), should be even: %v", len(groups), groups) matchLogger.Warning("Invalid number of group indices (%d), should be even: %v", len(groups), groups)
continue continue
} }
@@ -123,11 +131,11 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
validGroups++ validGroups++
} }
} }
logger.Debug("Found %d valid capture groups in match", validGroups) matchLogger.Debug("Found %d valid capture groups in match", validGroups)
for _, index := range groups { for _, index := range groups {
if index == -1 { if index == -1 {
logger.Warning("Negative index encountered in match indices %v. This may indicate an issue with the regex pattern or an empty/optional capture group.", matchIndices) matchLogger.Warning("Negative index encountered in match indices %v. This may indicate an issue with the regex pattern or an empty/optional capture group.", matchIndices)
continue continue
} }
} }
@@ -142,6 +150,7 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
start := groups[i*2] start := groups[i*2]
end := groups[i*2+1] end := groups[i*2+1]
if start == -1 || end == -1 { if start == -1 || end == -1 {
matchLogger.Debug("Skipping empty or unmatched capture group #%d (name: %q)", i+1, name)
continue continue
} }
@@ -154,75 +163,80 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
// Include name info in log if available // Include name info in log if available
if name != "" { if name != "" {
logger.Trace("Capture group '%s': %q (pos %d-%d)", name, value, start, end) matchLogger.Trace("Capture group '%s': %q (pos %d-%d)", name, value, start, end)
} else { } else {
logger.Trace("Capture group #%d: %q (pos %d-%d)", i+1, value, start, end) matchLogger.Trace("Capture group #%d: %q (pos %d-%d)", i+1, value, start, end)
} }
} }
// Use the DeduplicateGroups flag to control whether to deduplicate capture groups // Use the DeduplicateGroups flag to control whether to deduplicate capture groups
if !command.NoDedup { if !command.NoDedup {
logger.Debug("Deduplicating capture groups as specified in command settings") matchLogger.Debug("Deduplicating capture groups as specified in command settings")
captureGroups = deduplicateGroups(captureGroups) captureGroups = deduplicateGroups(captureGroups)
matchLogger.Trace("Capture groups after deduplication: %v", captureGroups)
} else {
matchLogger.Debug("Skipping deduplication of capture groups (NoDedup is true)")
} }
if err := toLua(L, captureGroups); err != nil { if err := toLua(L, captureGroups); err != nil {
logger.Error("Failed to set Lua variables: %v", err) matchLogger.Error("Failed to set Lua variables for capture groups: %v", err)
continue continue
} }
logger.Trace("Set %d capture groups as Lua variables", len(captureGroups)) matchLogger.Debug("Set %d capture groups as Lua variables", len(captureGroups))
matchLogger.Trace("Lua globals set for capture groups")
if err := L.DoString(luaExpr); err != nil { if err := L.DoString(luaExpr); err != nil {
logger.Error("Lua script execution failed: %v\nScript: %s\nCapture Groups: %+v", matchLogger.Error("Lua script execution failed: %v\nScript: %s\nCapture Groups: %+v",
err, luaExpr, captureGroups) err, utils.LimitString(luaExpr, 200), captureGroups)
continue continue
} }
logger.Trace("Lua script executed successfully") matchLogger.Debug("Lua script executed successfully")
// Get modifications from Lua // Get modifications from Lua
captureGroups, err = fromLua(L, captureGroups) updatedCaptureGroups, err := fromLua(L, captureGroups)
if err != nil { if err != nil {
logger.Error("Failed to retrieve modifications from Lua: %v", err) matchLogger.Error("Failed to retrieve modifications from Lua: %v", err)
continue continue
} }
logger.Trace("Retrieved updated values from Lua") matchLogger.Debug("Retrieved updated values from Lua")
matchLogger.Trace("Updated capture groups from Lua: %v", updatedCaptureGroups)
replacement := "" replacement := ""
replacementVar := L.GetGlobal("replacement") replacementVar := L.GetGlobal("replacement")
if replacementVar.Type() != lua.LTNil { if replacementVar.Type() != lua.LTNil {
replacement = replacementVar.String() replacement = replacementVar.String()
logger.Debug("Using global replacement: %q", replacement) matchLogger.Debug("Using global replacement variable from Lua: %q", replacement)
} }
// Check if modification flag is set // Check if modification flag is set
modifiedVal := L.GetGlobal("modified") modifiedVal := L.GetGlobal("modified")
if modifiedVal.Type() != lua.LTBool || !lua.LVAsBool(modifiedVal) { if modifiedVal.Type() != lua.LTBool || !lua.LVAsBool(modifiedVal) {
logger.Debug("Skipping match - no modifications made by Lua script") matchLogger.Debug("Skipping match - no modifications indicated by Lua script")
continue continue
} }
if replacement == "" { if replacement == "" {
// Apply the modifications to the original match // Apply the modifications to the original match
replacement = match replacement = matchContent
// Count groups that were actually modified // Count groups that were actually modified
modifiedGroups := 0 modifiedGroupsCount := 0
for _, capture := range captureGroups { for _, capture := range updatedCaptureGroups {
if capture.Value != capture.Updated { if capture.Value != capture.Updated {
modifiedGroups++ modifiedGroupsCount++
} }
} }
logger.Info("%d of %d capture groups identified for modification", modifiedGroups, len(captureGroups)) matchLogger.Info("%d of %d capture groups identified for modification", modifiedGroupsCount, len(updatedCaptureGroups))
for _, capture := range captureGroups { for _, capture := range updatedCaptureGroups {
if capture.Value == capture.Updated { if capture.Value == capture.Updated {
logger.Info("Capture group unchanged: %s", LimitString(capture.Value, 50)) matchLogger.Debug("Capture group unchanged: %s", utils.LimitString(capture.Value, 50))
continue continue
} }
// Log what changed with context // Log what changed with context
logger.Debug("Capture group %s scheduled for modification: %q → %q", matchLogger.Debug("Capture group %q scheduled for modification: %q → %q",
capture.Name, capture.Value, capture.Updated) capture.Name, utils.LimitString(capture.Value, 50), utils.LimitString(capture.Updated, 50))
// Indices of the group are relative to content // Indices of the group are relative to content
// To relate them to match we have to subtract the match start index // To relate them to match we have to subtract the match start index
@@ -232,42 +246,57 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
To: capture.Range[1], To: capture.Range[1],
With: capture.Updated, With: capture.Updated,
}) })
matchLogger.Trace("Added replacement command: %+v", commands[len(commands)-1])
} }
} else { } else {
matchLogger.Debug("Using full replacement string from Lua: %q", utils.LimitString(replacement, 50))
commands = append(commands, utils.ReplaceCommand{ commands = append(commands, utils.ReplaceCommand{
From: matchIndices[0], From: matchIndices[0],
To: matchIndices[1], To: matchIndices[1],
With: replacement, With: replacement,
}) })
matchLogger.Trace("Added full replacement command: %+v", commands[len(commands)-1])
} }
} }
logger.Debug("Total regex processing time: %v", time.Since(startTime)) processRegexLogger.Debug("Total regex processing time: %v", time.Since(startTime))
processRegexLogger.Debug("Generated %d total modifications", len(commands))
return commands, nil return commands, nil
} }
func deduplicateGroups(captureGroups []*CaptureGroup) []*CaptureGroup { func deduplicateGroups(captureGroups []*CaptureGroup) []*CaptureGroup {
deduplicatedGroups := make([]*CaptureGroup, 0) deduplicateGroupsLogger := regexLogger.WithPrefix("deduplicateGroups")
deduplicateGroupsLogger.Debug("Starting deduplication of capture groups")
deduplicateGroupsLogger.Trace("Input capture groups: %v", captureGroups)
// Preserve input order and drop any group that overlaps with an already accepted group
accepted := make([]*CaptureGroup, 0, len(captureGroups))
for _, group := range captureGroups { for _, group := range captureGroups {
groupLogger := deduplicateGroupsLogger.WithField("groupName", group.Name).WithField("groupRange", group.Range)
groupLogger.Debug("Processing capture group")
overlaps := false overlaps := false
logger.Debug("Checking capture group: %s with range %v", group.Name, group.Range) for _, kept := range accepted {
for _, existingGroup := range deduplicatedGroups { // Overlap if start < keptEnd and end > keptStart (adjacent is allowed)
logger.Debug("Comparing with existing group: %s with range %v", existingGroup.Name, existingGroup.Range) if group.Range[0] < kept.Range[1] && group.Range[1] > kept.Range[0] {
if group.Range[0] < existingGroup.Range[1] && group.Range[1] > existingGroup.Range[0] {
overlaps = true overlaps = true
logger.Warning("Detected overlap between capture group '%s' and existing group '%s' in range %v-%v and %v-%v", group.Name, existingGroup.Name, group.Range[0], group.Range[1], existingGroup.Range[0], existingGroup.Range[1])
break break
} }
} }
if overlaps { if overlaps {
// We CAN just continue despite this fuckup groupLogger.Warning("Overlapping capture group detected and skipped.")
logger.Warning("Overlapping capture group: %s", group.Name)
continue continue
} }
logger.Debug("No overlap detected for capture group: %s. Adding to deduplicated groups.", group.Name)
deduplicatedGroups = append(deduplicatedGroups, group) groupLogger.Debug("Capture group does not overlap with previously accepted groups. Adding.")
accepted = append(accepted, group)
} }
return deduplicatedGroups
deduplicateGroupsLogger.Debug("Finished deduplication. Original %d groups, %d deduplicated.", len(captureGroups), len(accepted))
deduplicateGroupsLogger.Trace("Deduplicated groups: %v", accepted)
return accepted
} }
// The order of these replaces is important // The order of these replaces is important
@@ -276,105 +305,183 @@ func deduplicateGroups(captureGroups []*CaptureGroup) []*CaptureGroup {
// Expand to another capture group in the capture group // Expand to another capture group in the capture group
// We really only want one (our named) capture group // We really only want one (our named) capture group
func resolveRegexPlaceholders(pattern string) string { func resolveRegexPlaceholders(pattern string) string {
resolveLogger := regexLogger.WithPrefix("resolveRegexPlaceholders").WithField("originalPattern", utils.LimitString(pattern, 100))
resolveLogger.Debug("Resolving regex placeholders in pattern")
// Handle special pattern modifications // Handle special pattern modifications
if !strings.HasPrefix(pattern, "(?s)") { if !strings.HasPrefix(pattern, "(?s)") {
pattern = "(?s)" + pattern pattern = "(?s)" + pattern
resolveLogger.Debug("Prepended '(?s)' to pattern for single-line mode")
} }
namedGroupNum := regexp.MustCompile(`(?:(\?<[^>]+>)(!num))`) namedGroupNum := regexp.MustCompile(`(?:(\?<[^>]+>)(!num))`)
pattern = namedGroupNum.ReplaceAllStringFunc(pattern, func(match string) string { pattern = namedGroupNum.ReplaceAllStringFunc(pattern, func(match string) string {
funcLogger := resolveLogger.WithPrefix("namedGroupNumReplace").WithField("match", utils.LimitString(match, 50))
funcLogger.Debug("Processing named group !num placeholder")
parts := namedGroupNum.FindStringSubmatch(match) parts := namedGroupNum.FindStringSubmatch(match)
if len(parts) != 3 { if len(parts) != 3 {
funcLogger.Warning("Unexpected number of submatches for namedGroupNum: %d. Returning original match.", len(parts))
return match return match
} }
replacement := `-?\d*\.?\d+` replacement := `-?\d*\.?\d+`
funcLogger.Trace("Replacing !num in named group with: %q", replacement)
return parts[1] + replacement return parts[1] + replacement
}) })
resolveLogger.Debug("Handled named group !num placeholders")
pattern = strings.ReplaceAll(pattern, "!num", `(-?\d*\.?\d+)`) pattern = strings.ReplaceAll(pattern, "!num", `(-?\d*\.?\d+)`)
resolveLogger.Debug("Replaced !num with numeric capture group")
pattern = strings.ReplaceAll(pattern, "!any", `.*?`) pattern = strings.ReplaceAll(pattern, "!any", `.*?`)
resolveLogger.Debug("Replaced !any with non-greedy wildcard")
repPattern := regexp.MustCompile(`!rep\(([^,]+),\s*(\d+)\)`) repPattern := regexp.MustCompile(`!rep\(([^,]+),\s*(\d+)\)`)
// !rep(pattern, count) repeats the pattern n times // !rep(pattern, count) repeats the pattern n times
// Inserting !any between each repetition // Inserting !any between each repetition
pattern = repPattern.ReplaceAllStringFunc(pattern, func(match string) string { pattern = repPattern.ReplaceAllStringFunc(pattern, func(match string) string {
funcLogger := resolveLogger.WithPrefix("repPatternReplace").WithField("match", utils.LimitString(match, 50))
funcLogger.Debug("Processing !rep placeholder")
parts := repPattern.FindStringSubmatch(match) parts := repPattern.FindStringSubmatch(match)
if len(parts) != 3 { if len(parts) != 3 {
funcLogger.Warning("Unexpected number of submatches for repPattern: %d. Returning original match.", len(parts))
return match return match
} }
repeatedPattern := parts[1] repeatedPattern := parts[1]
count := parts[2] countStr := parts[2]
repetitions, _ := strconv.Atoi(count) repetitions, err := strconv.Atoi(countStr)
return strings.Repeat(repeatedPattern+".*?", repetitions-1) + repeatedPattern if err != nil {
funcLogger.Error("Failed to parse repetition count %q: %v. Returning original match.", countStr, err)
return match
}
var finalReplacement string
if repetitions > 0 {
finalReplacement = strings.Repeat(repeatedPattern+".*?", repetitions-1) + repeatedPattern
} else {
finalReplacement = ""
}
funcLogger.Trace("Replaced !rep with %d repetitions of %q: %q", repetitions, utils.LimitString(repeatedPattern, 30), utils.LimitString(finalReplacement, 100))
return finalReplacement
}) })
resolveLogger.Debug("Handled !rep placeholders")
resolveLogger.Debug("Finished resolving regex placeholders")
resolveLogger.Trace("Final resolved pattern: %q", utils.LimitString(pattern, 100))
return pattern return pattern
} }
// ToLua sets capture groups as Lua variables (v1, v2, etc. for numeric values and s1, s2, etc. for strings) // ToLua sets capture groups as Lua variables (v1, v2, etc. for numeric values and s1, s2, etc. for strings)
func toLua(L *lua.LState, data interface{}) error { func toLua(L *lua.LState, data interface{}) error {
toLuaLogger := regexLogger.WithPrefix("toLua")
toLuaLogger.Debug("Setting capture groups as Lua variables")
captureGroups, ok := data.([]*CaptureGroup) captureGroups, ok := data.([]*CaptureGroup)
if !ok { if !ok {
toLuaLogger.Error("Invalid data type for toLua. Expected []*CaptureGroup, got %T", data)
return fmt.Errorf("expected []*CaptureGroup for captures, got %T", data) return fmt.Errorf("expected []*CaptureGroup for captures, got %T", data)
} }
toLuaLogger.Trace("Input capture groups: %v", captureGroups)
groupindex := 0 groupindex := 0
for _, capture := range captureGroups { for _, capture := range captureGroups {
groupLogger := toLuaLogger.WithField("captureGroup", capture.Name).WithField("value", utils.LimitString(capture.Value, 50))
groupLogger.Debug("Processing capture group for Lua")
if capture.Name == "" { if capture.Name == "" {
// We don't want to change the name of the capture group // We don't want to change the name of the capture group
// Even if it's empty // Even if it's empty
tempName := fmt.Sprintf("%d", groupindex+1) tempName := fmt.Sprintf("%d", groupindex+1)
groupindex++ groupindex++
groupLogger.Debug("Unnamed capture group, assigning temporary name: %q", tempName)
L.SetGlobal("s"+tempName, lua.LString(capture.Value)) L.SetGlobal("s"+tempName, lua.LString(capture.Value))
groupLogger.Trace("Set Lua global s%s = %q", tempName, capture.Value)
val, err := strconv.ParseFloat(capture.Value, 64) val, err := strconv.ParseFloat(capture.Value, 64)
if err == nil { if err == nil {
L.SetGlobal("v"+tempName, lua.LNumber(val)) L.SetGlobal("v"+tempName, lua.LNumber(val))
groupLogger.Trace("Set Lua global v%s = %f", tempName, val)
} else {
groupLogger.Trace("Value %q is not numeric, skipping v%s assignment", capture.Value, tempName)
} }
} else { } else {
val, err := strconv.ParseFloat(capture.Value, 64) val, err := strconv.ParseFloat(capture.Value, 64)
if err == nil { if err == nil {
L.SetGlobal(capture.Name, lua.LNumber(val)) L.SetGlobal(capture.Name, lua.LNumber(val))
groupLogger.Trace("Set Lua global %s = %f (numeric)", capture.Name, val)
} else { } else {
L.SetGlobal(capture.Name, lua.LString(capture.Value)) L.SetGlobal(capture.Name, lua.LString(capture.Value))
groupLogger.Trace("Set Lua global %s = %q (string)", capture.Name, capture.Value)
} }
} }
} }
toLuaLogger.Debug("Finished setting capture groups as Lua variables")
return nil return nil
} }
// FromLua implements the Processor interface for RegexProcessor // FromLua implements the Processor interface for RegexProcessor
func fromLua(L *lua.LState, captureGroups []*CaptureGroup) ([]*CaptureGroup, error) { func fromLua(L *lua.LState, captureGroups []*CaptureGroup) ([]*CaptureGroup, error) {
fromLuaLogger := regexLogger.WithPrefix("fromLua")
fromLuaLogger.Debug("Retrieving modifications from Lua for capture groups")
fromLuaLogger.Trace("Initial capture groups: %v", captureGroups)
captureIndex := 0 captureIndex := 0
for _, capture := range captureGroups { for _, capture := range captureGroups {
if capture.Name == "" { groupLogger := fromLuaLogger.WithField("originalCaptureName", capture.Name).WithField("originalValue", utils.LimitString(capture.Value, 50))
capture.Name = fmt.Sprintf("%d", captureIndex+1) groupLogger.Debug("Processing capture group to retrieve updated value")
vVarName := fmt.Sprintf("v%s", capture.Name) if capture.Name == "" {
sVarName := fmt.Sprintf("s%s", capture.Name) // This case means it was an unnamed capture group originally.
// We need to reconstruct the original temporary name to fetch its updated value.
// The name will be set to an integer if it was empty, then incremented.
// So, we use the captureIndex to get the correct 'vX' and 'sX' variables.
tempName := fmt.Sprintf("%d", captureIndex+1)
groupLogger.Debug("Retrieving updated value for unnamed group (temp name: %q)", tempName)
vVarName := fmt.Sprintf("v%s", tempName)
sVarName := fmt.Sprintf("s%s", tempName)
captureIndex++ captureIndex++
vLuaVal := L.GetGlobal(vVarName) vLuaVal := L.GetGlobal(vVarName)
sLuaVal := L.GetGlobal(sVarName) sLuaVal := L.GetGlobal(sVarName)
groupLogger.Trace("Lua values for unnamed group: v=%v, s=%v", vLuaVal, sLuaVal)
if sLuaVal.Type() == lua.LTString { if sLuaVal.Type() == lua.LTString {
capture.Updated = sLuaVal.String() capture.Updated = sLuaVal.String()
groupLogger.Trace("Updated value from s%s (string): %q", tempName, capture.Updated)
} }
// Numbers have priority // Numbers have priority
if vLuaVal.Type() == lua.LTNumber { if vLuaVal.Type() == lua.LTNumber {
capture.Updated = vLuaVal.String() capture.Updated = vLuaVal.String()
groupLogger.Trace("Updated value from v%s (numeric): %q", tempName, capture.Updated)
} }
} else { } else {
// Easy shit // Easy shit, directly use the named capture group
capture.Updated = L.GetGlobal(capture.Name).String() updatedValue := L.GetGlobal(capture.Name)
if updatedValue.Type() != lua.LTNil {
capture.Updated = updatedValue.String()
groupLogger.Trace("Updated value for named group %q: %q", capture.Name, capture.Updated)
} else {
groupLogger.Debug("Named capture group %q not found in Lua globals or is nil. Keeping original value.", capture.Name)
capture.Updated = capture.Value // Keep original if not found or nil
}
} }
groupLogger.Debug("Finished processing capture group. Original: %q, Updated: %q", utils.LimitString(capture.Value, 50), utils.LimitString(capture.Updated, 50))
} }
fromLuaLogger.Debug("Finished retrieving modifications from Lua")
fromLuaLogger.Trace("Final updated capture groups: %v", captureGroups)
return captureGroups, nil return captureGroups, nil
} }
// estimatePatternComplexity gives a rough estimate of regex pattern complexity // estimatePatternComplexity gives a rough estimate of regex pattern complexity
// This can help identify potentially problematic patterns // This can help identify potentially problematic patterns
func estimatePatternComplexity(pattern string) int { func estimatePatternComplexity(pattern string) int {
estimateComplexityLogger := regexLogger.WithPrefix("estimatePatternComplexity").WithField("pattern", utils.LimitString(pattern, 100))
estimateComplexityLogger.Debug("Estimating regex pattern complexity")
complexity := len(pattern) complexity := len(pattern)
// Add complexity for potentially expensive operations // Add complexity for potentially expensive operations
@@ -387,5 +494,6 @@ func estimatePatternComplexity(pattern string) int {
complexity += strings.Count(pattern, "\\1") * 3 // Backreferences complexity += strings.Count(pattern, "\\1") * 3 // Backreferences
complexity += strings.Count(pattern, "{") * 2 // Counted repetition complexity += strings.Count(pattern, "{") * 2 // Counted repetition
estimateComplexityLogger.Debug("Estimated pattern complexity: %d", complexity)
return complexity return complexity
} }

View File

@@ -0,0 +1,847 @@
package processor
import (
"cook/utils"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestSurgicalJSONEditing(t *testing.T) {
tests := []struct {
name string
content string
luaCode string
expected string
}{
{
name: "Modify single field",
content: `{
"name": "test",
"value": 42,
"description": "original"
}`,
luaCode: `
data.value = 84
modified = true
`,
expected: `{
"name": "test",
"value": 84,
"description": "original"
}`,
},
{
name: "Add new field",
content: `{
"name": "test",
"value": 42
}`,
luaCode: `
data.newField = "added"
modified = true
`,
expected: `{
"name": "test",
"value": 42
,"newField": "added"}`, // sjson.Set() adds new fields in compact format
},
{
name: "Modify nested field",
content: `{
"config": {
"settings": {
"enabled": false,
"timeout": 30
}
}
}`,
luaCode: `
data.config.settings.enabled = true
data.config.settings.timeout = 60
modified = true
`,
expected: `{
"config": {
"settings": {
"enabled": true,
"timeout": 60
}
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
command := utils.ModifyCommand{
Name: "test",
Lua: tt.luaCode,
}
commands, err := ProcessJSON(tt.content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := tt.content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, tt.expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check the actual result matches expected
if result != tt.expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", tt.expected, result)
}
})
}
}
func TestSurgicalJSONPreservesFormatting(t *testing.T) {
// Test that surgical editing preserves the original formatting structure
content := `{
"Defaults": {
"Behaviour": "None",
"Description": "",
"DisplayName": "",
"FlavorText": "",
"Icon": "None",
"MaxStack": 1,
"Override_Glow_Icon": "None",
"Weight": 0,
"bAllowZeroWeight": false
},
"RowStruct": "/Script/Icarus.ItemableData",
"Rows": [
{
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"MaxStack": 1000000,
"Name": "Item_Fiber",
"Weight": 10
}
]
}`
expected := `{
"Defaults": {
"Behaviour": "None",
"Description": "",
"DisplayName": "",
"FlavorText": "",
"Icon": "None",
"MaxStack": 1,
"Override_Glow_Icon": "None",
"Weight": 0,
"bAllowZeroWeight": false
},
"RowStruct": "/Script/Icarus.ItemableData",
"Rows": [
{
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"MaxStack": 1000000,
"Name": "Item_Fiber",
"Weight": 500
}
]
}`
command := utils.ModifyCommand{
Name: "test",
Lua: `
-- Modify the weight of the first item
data.Rows[1].Weight = 500
modified = true
`,
}
commands, err := ProcessJSON(content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the result matches expected (preserves formatting and changes weight)
if result != expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestSurgicalJSONPreservesFormatting2(t *testing.T) {
// Test that surgical editing preserves the original formatting structure
content := `
{
"RowStruct": "/Script/Icarus.ProcessorRecipe",
"Defaults": {
"bForceDisableRecipe": false,
"Requirement": {
"RowName": "None",
"DataTableName": "D_Talents"
},
"SessionRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"CharacterRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"RequiredMillijoules": 2500,
"RecipeSets": [],
"ResourceCostMultipliers": [],
"Inputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Container": {
"Value": "None"
},
"ResourceInputs": [],
"bSelectOutputItemRandomly": false,
"bContainsContainer": false,
"ItemIconOverride": {
"ItemStaticData": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"ItemDynamicData": [],
"ItemCustomStats": [],
"CustomProperties": {
"StaticWorldStats": [],
"StaticWorldHeldStats": [],
"Stats": [],
"Alterations": [],
"LivingItemSlots": []
},
"DatabaseGUID": "",
"ItemOwnerLookupId": -1,
"RuntimeTags": {
"GameplayTags": []
}
},
"Outputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemTemplate"
},
"Count": 1,
"DynamicProperties": []
}
],
"ResourceOutputs": [],
"Refundable": "Inherit",
"ExperienceMultiplier": 1,
"Audio": {
"RowName": "None",
"DataTableName": "D_CraftingAudioData"
}
},
"Rows": [
{
"Name": "Biofuel1",
"RecipeSets": [
{
"RowName": "Composter",
"DataTableName": "D_RecipeSets"
}
],
"Inputs": [
{
"Element": {
"RowName": "Raw_Meat",
"DataTableName": "D_ItemsStatic"
},
"Count": 2,
"DynamicProperties": []
},
{
"Element": {
"RowName": "Tree_Sap",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Outputs": [],
"Audio": {
"RowName": "Composter"
},
"ResourceOutputs": [
{
"Type": {
"Value": "Biofuel"
},
"RequiredUnits": 100
}
]
}
]
}
`
expected := `
{
"RowStruct": "/Script/Icarus.ProcessorRecipe",
"Defaults": {
"bForceDisableRecipe": false,
"Requirement": {
"RowName": "None",
"DataTableName": "D_Talents"
},
"SessionRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"CharacterRequirement": {
"RowName": "None",
"DataTableName": "D_CharacterFlags"
},
"RequiredMillijoules": 2500,
"RecipeSets": [],
"ResourceCostMultipliers": [],
"Inputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"Count": 1,
"DynamicProperties": []
}
],
"Container": {
"Value": "None"
},
"ResourceInputs": [],
"bSelectOutputItemRandomly": false,
"bContainsContainer": false,
"ItemIconOverride": {
"ItemStaticData": {
"RowName": "None",
"DataTableName": "D_ItemsStatic"
},
"ItemDynamicData": [],
"ItemCustomStats": [],
"CustomProperties": {
"StaticWorldStats": [],
"StaticWorldHeldStats": [],
"Stats": [],
"Alterations": [],
"LivingItemSlots": []
},
"DatabaseGUID": "",
"ItemOwnerLookupId": -1,
"RuntimeTags": {
"GameplayTags": []
}
},
"Outputs": [
{
"Element": {
"RowName": "None",
"DataTableName": "D_ItemTemplate"
},
"Count": 1,
"DynamicProperties": []
}
],
"ResourceOutputs": [],
"Refundable": "Inherit",
"ExperienceMultiplier": 1,
"Audio": {
"RowName": "None",
"DataTableName": "D_CraftingAudioData"
}
},
"Rows": [
{
"Name": "Biofuel1",
"RecipeSets": [
{
"RowName": "Composter",
"DataTableName": "D_RecipeSets"
}
],
"Inputs": [
{
"Element": {
"RowName": "Raw_Meat",
"DataTableName": "D_ItemsStatic"
},
"Count": 2,
"DynamicProperties": []
}
],
"Outputs": [],
"Audio": {
"RowName": "Composter"
},
"ResourceOutputs": [
{
"Type": {
"Value": "Biofuel"
},
"RequiredUnits": 100
}
]
}
]
}
`
command := utils.ModifyCommand{
Name: "test",
Lua: `
-- Define regex patterns for matching recipe names
local function matchesPattern(name, pattern)
local matches = re(pattern, name)
-- Check if matches table has any content (index 0 or 1 should exist if there's a match)
return matches and (matches[0] or matches[1])
end
-- Selection pattern for recipes that get multiplied
local selectionPattern = "(?-s)(Bulk_)?(Pistol|Rifle).*?Round.*?|(Carbon|Composite)_Paste.*|(Gold|Copper)_Wire|(Ironw|Copper)_Nail|(Platinum|Steel|Cold_Steel|Titanium)_Ingot|.*?Shotgun_Shell.*?|.*_Arrow|.*_Bolt|.*_Fertilizer_?\\d*|.*_Grenade|.*_Pill|.*_Tonic|Aluminum|Ammo_Casing|Animal_Fat|Carbon_Fiber|Composites|Concrete_Mix|Cured_Leather_?\\d?|Electronics|Epoxy_?\\d?|Glass\\d?|Gunpowder\\w*|Health_.*|Titanium_Plate|Organic_Resin|Platinum_Sheath|Refined_[a-zA-Z]+|Rope|Shotgun_Casing|Steel_Bloom\\d?|Tree_Sap\\w*"
-- Ingot pattern for recipes that get count set to 1
local ingotPattern = "(?-s)(Platinum|Steel|Cold_Steel|Titanium)_Ingot|Aluminum|Refined_[a-zA-Z]+|Glass\\d?"
local factor = 16
local bonus = 0.5
for _, row in ipairs(data.Rows) do
local recipeName = row.Name
-- Special case: Biofuel recipes - remove Tree_Sap input
if string.find(recipeName, "Biofuel") then
if row.Inputs then
for i = #row.Inputs, 1, -1 do
local input = row.Inputs[i]
if input.Element and input.Element.RowName and string.find(input.Element.RowName, "Tree_Sap") then
table.remove(row.Inputs, i)
print("Removing input 'Tree_Sap' from processor recipe '" .. recipeName .. "'")
end
end
end
end
-- Ingot recipes: set input and output counts to 1
if matchesPattern(recipeName, ingotPattern) then
if row.Inputs then
for _, input in ipairs(row.Inputs) do
input.Count = 1
end
end
if row.Outputs then
for _, output in ipairs(row.Outputs) do
output.Count = 1
end
end
end
-- Selected recipes: multiply inputs by factor, outputs by factor * (1 + bonus)
if matchesPattern(recipeName, selectionPattern) then
if row.Inputs then
for _, input in ipairs(row.Inputs) do
local oldCount = input.Count
input.Count = input.Count * factor
print("Recipe " .. recipeName .. " Input.Count: " .. oldCount .. " -> " .. input.Count)
end
end
if row.Outputs then
for _, output in ipairs(row.Outputs) do
local oldCount = output.Count
output.Count = math.floor(output.Count * factor * (1 + bonus))
print("Recipe " .. recipeName .. " Output.Count: " .. oldCount .. " -> " .. output.Count)
end
end
end
end
`,
}
commands, err := ProcessJSON(content, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := content
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the result matches expected (preserves formatting and changes weight)
if result != expected {
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestRetardedJSONEditing(t *testing.T) {
original := `{
"RowStruct": "/Script/Icarus.ItemableData",
"Defaults": {
"Behaviour": "None",
"DisplayName": "",
"Icon": "None",
"Override_Glow_Icon": "None",
"Description": "",
"FlavorText": "",
"Weight": 0,
"bAllowZeroWeight": false,
"MaxStack": 1
},
"Rows": [
{
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Weight": 10,
"MaxStack": 200,
"Name": "Item_Fiber"
}
]
}`
expected := `{
"RowStruct": "/Script/Icarus.ItemableData",
"Defaults": {
"Behaviour": "None",
"DisplayName": "",
"Icon": "None",
"Override_Glow_Icon": "None",
"Description": "",
"FlavorText": "",
"Weight": 0,
"bAllowZeroWeight": false,
"MaxStack": 1
},
"Rows": [
{
"DisplayName": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-DisplayName\", \"Fiber\")",
"Icon": "/Game/Assets/2DArt/UI/Items/Item_Icons/Resources/ITEM_Fibre.ITEM_Fibre",
"Description": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-Description\", \"A bundle of soft fiber, highly useful.\")",
"FlavorText": "NSLOCTEXT(\"D_Itemable\", \"Item_Fiber-FlavorText\", \"Fiber is collected from bast, the strong inner bark of certain flowering plants.\")",
"Weight": 10,
"MaxStack": 1000000,
"Name": "Item_Fiber"
}
]
}`
command := utils.ModifyCommand{
Name: "test",
Lua: `
for _, row in ipairs(data.Rows) do
if row.MaxStack then
if string.find(row.Name, "Carrot") or string.find(row.Name, "Potato") then
row.MaxStack = 25
else
row.MaxStack = row.MaxStack * 10000
if row.MaxStack > 1000000 then
row.MaxStack = 1000000
end
end
end
end
`,
}
commands, err := ProcessJSON(original, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := original
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
// Check that the weight was changed
if result != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, result)
}
}
func TestRetardedJSONEditing2(t *testing.T) {
original := `
{
"Rows": [
{
"Name": "Deep_Mining_Drill_Biofuel",
"Meshable": {
"RowName": "Mesh_Deep_Mining_Drill_Biofuel"
},
"Itemable": {
"RowName": "Item_Deep_Mining_Drill_Biofuel"
},
"Interactable": {
"RowName": "Deployable"
},
"Focusable": {
"RowName": "Focusable_1H"
},
"Highlightable": {
"RowName": "Generic"
},
"Actionable": {
"RowName": "Deployable"
},
"Usable": {
"RowName": "Place"
},
"Deployable": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Durable": {
"RowName": "Deployable_750"
},
"Inventory": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Decayable": {
"RowName": "Decay_MetaItem"
},
"Generator": {
"RowName": "Deep_Mining_Biofuel_Drill"
},
"Resource": {
"RowName": "Simple_Internal_Flow_Only"
},
"Manual_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
}
]
},
"Generated_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
},
{
"TagName": "Traits.Meshable"
},
{
"TagName": "Traits.Itemable"
},
{
"TagName": "Traits.Interactable"
},
{
"TagName": "Traits.Highlightable"
},
{
"TagName": "Traits.Actionable"
},
{
"TagName": "Traits.Usable"
},
{
"TagName": "Traits.Deployable"
},
{
"TagName": "Traits.Durable"
},
{
"TagName": "Traits.Inventory"
}
],
"ParentTags": []
}
}
]
}
`
expected := `
{
"Rows": [
{
"Name": "Deep_Mining_Drill_Biofuel",
"Meshable": {
"RowName": "Mesh_Deep_Mining_Drill_Biofuel"
},
"Itemable": {
"RowName": "Item_Deep_Mining_Drill_Biofuel"
},
"Interactable": {
"RowName": "Deployable"
},
"Focusable": {
"RowName": "Focusable_1H"
},
"Highlightable": {
"RowName": "Generic"
},
"Actionable": {
"RowName": "Deployable"
},
"Usable": {
"RowName": "Place"
},
"Deployable": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Durable": {
"RowName": "Deployable_750"
},
"Inventory": {
"RowName": "Deep_Mining_Drill_Biofuel"
},
"Decayable": {
"RowName": "Decay_MetaItem"
},
"Generator": {
"RowName": "Deep_Mining_Biofuel_Drill"
},
"Resource": {
"RowName": "Simple_Internal_Flow_Only"
},
"Manual_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
}
]
},
"Generated_Tags": {
"GameplayTags": [
{
"TagName": "Item.Machine"
},
{
"TagName": "Traits.Meshable"
},
{
"TagName": "Traits.Itemable"
},
{
"TagName": "Traits.Interactable"
},
{
"TagName": "Traits.Highlightable"
},
{
"TagName": "Traits.Actionable"
},
{
"TagName": "Traits.Usable"
},
{
"TagName": "Traits.Deployable"
},
{
"TagName": "Traits.Durable"
},
{
"TagName": "Traits.Inventory"
}
],
"ParentTags": []
}
,"AdditionalStats": {"(Value=\"BaseDeepMiningDrillSpeed_+%\")":4000}}
]
}
`
command := utils.ModifyCommand{
Name: "test",
Lua: `
for i, row in ipairs(data.Rows) do
-- Special case: Deep_Mining_Drill_Biofuel
if string.find(row.Name, "Deep_Mining_Drill_Biofuel") then
print("[DEBUG] Special case: Deep_Mining_Drill_Biofuel")
if not row.AdditionalStats then
print("[DEBUG] Creating AdditionalStats table for Deep_Mining_Drill_Biofuel")
row.AdditionalStats = {}
end
print("[DEBUG] Setting BaseDeepMiningDrillSpeed_+% to 4000")
row.AdditionalStats["(Value=\\\"BaseDeepMiningDrillSpeed_+%\\\")"] = 4000
end
end
`,
}
commands, err := ProcessJSON(original, command, "test.json")
if err != nil {
t.Fatalf("ProcessJSON failed: %v", err)
}
if len(commands) == 0 {
t.Fatal("Expected at least one command")
}
// Apply the commands
result := original
for _, cmd := range commands {
result = result[:cmd.From] + cmd.With + result[cmd.To:]
}
diff := cmp.Diff(result, expected)
if diff != "" {
t.Errorf("Differences:\n%s", diff)
}
if result != expected {
t.Errorf("Expected:\n%s\nGot:\n%s", expected, result)
}
}

11
test_surgical.yml Normal file
View File

@@ -0,0 +1,11 @@
- name: SurgicalWeightTest
json: true
lua: |
-- This demonstrates surgical JSON editing
-- Only the Weight field of Item_Fiber will be modified
data.Rows[1].Weight = 999
modified = true
files:
- 'D_Itemable.json'
reset: false
loglevel: INFO

View File

@@ -1,15 +1,18 @@
package utils package utils
import ( import (
"fmt"
"path/filepath" "path/filepath"
"time" "time"
"git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
) )
// dbLogger is a scoped logger for the utils/db package.
var dbLogger = logger.Default.WithPrefix("utils/db")
type DB interface { type DB interface {
DB() *gorm.DB DB() *gorm.DB
Raw(sql string, args ...any) *gorm.DB Raw(sql string, args ...any) *gorm.DB
@@ -29,92 +32,126 @@ type DBWrapper struct {
db *gorm.DB db *gorm.DB
} }
var db *DBWrapper var globalDB *DBWrapper
func GetDB() (DB, error) { func GetDB() (DB, error) {
getDBLogger := dbLogger.WithPrefix("GetDB")
getDBLogger.Debug("Attempting to get database connection")
var err error var err error
dbFile := filepath.Join("data.sqlite") dbFile := filepath.Join("data.sqlite")
getDBLogger.Debug("Opening database file: %q", dbFile)
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
// SkipDefaultTransaction: true, // SkipDefaultTransaction: true,
PrepareStmt: true, PrepareStmt: true,
// Logger: gormlogger.Default.LogMode(gormlogger.Silent), Logger: gormlogger.Default.LogMode(gormlogger.Silent),
}) })
if err != nil { if err != nil {
getDBLogger.Error("Failed to open database: %v", err)
return nil, err return nil, err
} }
db.AutoMigrate(&FileSnapshot{}) getDBLogger.Debug("Database opened successfully, running auto migration")
if err := db.AutoMigrate(&FileSnapshot{}); err != nil {
getDBLogger.Error("Auto migration failed: %v", err)
return nil, err
}
getDBLogger.Debug("Auto migration completed")
return &DBWrapper{db: db}, nil globalDB = &DBWrapper{db: db}
getDBLogger.Debug("Database wrapper initialized")
return globalDB, nil
} }
// Just a wrapper // Just a wrapper
func (db *DBWrapper) Raw(sql string, args ...any) *gorm.DB { func (db *DBWrapper) Raw(sql string, args ...any) *gorm.DB {
rawLogger := dbLogger.WithPrefix("Raw").WithField("sql", sql)
rawLogger.Debug("Executing raw SQL query with args: %v", args)
return db.db.Raw(sql, args...) return db.db.Raw(sql, args...)
} }
func (db *DBWrapper) DB() *gorm.DB { func (db *DBWrapper) DB() *gorm.DB {
dbLogger.WithPrefix("DB").Debug("Returning GORM DB instance")
return db.db return db.db
} }
func (db *DBWrapper) FileExists(filePath string) (bool, error) { func (db *DBWrapper) FileExists(filePath string) (bool, error) {
fileExistsLogger := dbLogger.WithPrefix("FileExists").WithField("filePath", filePath)
fileExistsLogger.Debug("Checking if file exists in database")
var count int64 var count int64
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).Count(&count).Error err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).Count(&count).Error
if err != nil {
fileExistsLogger.Error("Error checking if file exists: %v", err)
return false, err
}
fileExistsLogger.Debug("File exists: %t", count > 0)
return count > 0, err return count > 0, err
} }
func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error { func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
log := cylogger.Default.WithPrefix(fmt.Sprintf("SaveFile: %q", filePath)) saveFileLogger := dbLogger.WithPrefix("SaveFile").WithField("filePath", filePath)
saveFileLogger.Debug("Attempting to save file to database")
saveFileLogger.Trace("File data length: %d", len(fileData))
exists, err := db.FileExists(filePath) exists, err := db.FileExists(filePath)
if err != nil { if err != nil {
log.Error("Error checking if file exists: %v", err) saveFileLogger.Error("Error checking if file exists: %v", err)
return err return err
} }
log.Debug("File exists: %t", exists)
// Nothing to do, file already exists
if exists { if exists {
log.Debug("File already exists, skipping save") saveFileLogger.Debug("File already exists, skipping save")
return nil return nil
} }
log.Debug("Saving file to database") saveFileLogger.Debug("Creating new file snapshot in database")
return db.db.Create(&FileSnapshot{ err = db.db.Create(&FileSnapshot{
Date: time.Now(), Date: time.Now(),
FilePath: filePath, FilePath: filePath,
FileData: fileData, FileData: fileData,
}).Error }).Error
if err != nil {
saveFileLogger.Error("Failed to create file snapshot: %v", err)
} else {
saveFileLogger.Debug("File saved successfully to database")
}
return err
} }
func (db *DBWrapper) GetFile(filePath string) ([]byte, error) { func (db *DBWrapper) GetFile(filePath string) ([]byte, error) {
log := cylogger.Default.WithPrefix(fmt.Sprintf("GetFile: %q", filePath)) getFileLogger := dbLogger.WithPrefix("GetFile").WithField("filePath", filePath)
log.Debug("Getting file from database") getFileLogger.Debug("Getting file from database")
var fileSnapshot FileSnapshot var fileSnapshot FileSnapshot
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error
if err != nil { if err != nil {
// Downgrade not-found to warning to avoid noisy errors during first run
getFileLogger.Warning("Failed to get file from database: %v", err)
return nil, err return nil, err
} }
log.Debug("File found in database") getFileLogger.Debug("File found in database")
getFileLogger.Trace("Retrieved file data length: %d", len(fileSnapshot.FileData))
return fileSnapshot.FileData, nil return fileSnapshot.FileData, nil
} }
func (db *DBWrapper) GetAllFiles() ([]FileSnapshot, error) { func (db *DBWrapper) GetAllFiles() ([]FileSnapshot, error) {
log := cylogger.Default.WithPrefix("GetAllFiles") getAllFilesLogger := dbLogger.WithPrefix("GetAllFiles")
log.Debug("Getting all files from database") getAllFilesLogger.Debug("Getting all files from database")
var fileSnapshots []FileSnapshot var fileSnapshots []FileSnapshot
err := db.db.Model(&FileSnapshot{}).Find(&fileSnapshots).Error err := db.db.Model(&FileSnapshot{}).Find(&fileSnapshots).Error
if err != nil { if err != nil {
getAllFilesLogger.Error("Failed to get all files from database: %v", err)
return nil, err return nil, err
} }
log.Debug("Found %d files in database", len(fileSnapshots)) getAllFilesLogger.Debug("Found %d files in database", len(fileSnapshots))
getAllFilesLogger.Trace("File snapshots retrieved: %v", fileSnapshots)
return fileSnapshots, nil return fileSnapshots, nil
} }
func (db *DBWrapper) RemoveAllFiles() error { func (db *DBWrapper) RemoveAllFiles() error {
log := cylogger.Default.WithPrefix("RemoveAllFiles") removeAllFilesLogger := dbLogger.WithPrefix("RemoveAllFiles")
log.Debug("Removing all files from database") removeAllFilesLogger.Debug("Removing all files from database")
err := db.db.Exec("DELETE FROM file_snapshots").Error err := db.db.Exec("DELETE FROM file_snapshots").Error
if err != nil { if err != nil {
return err removeAllFilesLogger.Error("Failed to remove all files from database: %v", err)
} else {
removeAllFilesLogger.Debug("All files removed from database")
} }
log.Debug("All files removed from database") return err
return nil
} }

View File

@@ -1,96 +1,152 @@
package utils package utils
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
) )
// fileLogger is a scoped logger for the utils/file package.
var fileLogger = logger.Default.WithPrefix("utils/file")
func CleanPath(path string) string { func CleanPath(path string) string {
log := cylogger.Default.WithPrefix(fmt.Sprintf("CleanPath: %q", path)) cleanPathLogger := fileLogger.WithPrefix("CleanPath")
log.Trace("Start") cleanPathLogger.Debug("Cleaning path: %q", path)
cleanPathLogger.Trace("Original path: %q", path)
path = filepath.Clean(path) path = filepath.Clean(path)
path = strings.ReplaceAll(path, "\\", "/") path = strings.ReplaceAll(path, "\\", "/")
log.Trace("Done: %q", path) cleanPathLogger.Trace("Cleaned path result: %q", path)
return path return path
} }
func ToAbs(path string) string { func ToAbs(path string) string {
log := cylogger.Default.WithPrefix(fmt.Sprintf("ToAbs: %q", path)) toAbsLogger := fileLogger.WithPrefix("ToAbs")
log.Trace("Start") toAbsLogger.Debug("Converting path to absolute: %q", path)
toAbsLogger.Trace("Input path: %q", path)
if filepath.IsAbs(path) { if filepath.IsAbs(path) {
log.Trace("Path is already absolute: %q", path) toAbsLogger.Debug("Path is already absolute, cleaning it.")
return CleanPath(path) cleanedPath := CleanPath(path)
toAbsLogger.Trace("Already absolute path after cleaning: %q", cleanedPath)
return cleanedPath
} }
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
log.Error("Error getting cwd: %v", err) toAbsLogger.Error("Error getting current working directory: %v", err)
return CleanPath(path) return CleanPath(path)
} }
log.Trace("Cwd: %q", cwd) toAbsLogger.Trace("Current working directory: %q", cwd)
return CleanPath(filepath.Join(cwd, path)) cleanedPath := CleanPath(filepath.Join(cwd, path))
toAbsLogger.Trace("Converted absolute path result: %q", cleanedPath)
return cleanedPath
}
// LimitString truncates a string to maxLen and adds "..." if truncated
func LimitString(s string, maxLen int) string {
limitStringLogger := fileLogger.WithPrefix("LimitString").WithField("originalLength", len(s)).WithField("maxLength", maxLen)
limitStringLogger.Debug("Limiting string length")
s = strings.ReplaceAll(s, "\n", "\\n")
if len(s) <= maxLen {
limitStringLogger.Trace("String length (%d) is within max length (%d), no truncation", len(s), maxLen)
return s
}
limited := s[:maxLen-3] + "..."
limitStringLogger.Trace("String truncated from %d to %d characters: %q", len(s), len(limited), limited)
return limited
}
// StrToFloat converts a string to a float64, returning 0 on error.
func StrToFloat(s string) float64 {
strToFloatLogger := fileLogger.WithPrefix("StrToFloat").WithField("inputString", s)
strToFloatLogger.Debug("Attempting to convert string to float")
f, err := strconv.ParseFloat(s, 64)
if err != nil {
strToFloatLogger.Warning("Failed to convert string %q to float, returning 0: %v", s, err)
return 0
}
strToFloatLogger.Trace("Successfully converted %q to float: %f", s, f)
return f
} }
func ResetWhereNecessary(associations map[string]FileCommandAssociation, db DB) error { func ResetWhereNecessary(associations map[string]FileCommandAssociation, db DB) error {
log := cylogger.Default.WithPrefix("ResetWhereNecessary") resetWhereNecessaryLogger := fileLogger.WithPrefix("ResetWhereNecessary")
log.Debug("Start") resetWhereNecessaryLogger.Debug("Starting reset where necessary operation")
resetWhereNecessaryLogger.Trace("File-command associations input: %v", associations)
dirtyFiles := make(map[string]struct{}) dirtyFiles := make(map[string]struct{})
for _, association := range associations { for _, association := range associations {
resetWhereNecessaryLogger.Debug("Processing association for file: %q", association.File)
for _, command := range association.Commands { for _, command := range association.Commands {
log.Debug("Checking command %q for file %q", command.Name, association.File) resetWhereNecessaryLogger.Debug("Checking command %q for reset requirement", command.Name)
resetWhereNecessaryLogger.Trace("Command details: %v", command)
if command.Reset { if command.Reset {
log.Debug("Command %q requires reset for file %q", command.Name, association.File) resetWhereNecessaryLogger.Debug("Command %q requires reset for file %q, marking as dirty", command.Name, association.File)
dirtyFiles[association.File] = struct{}{} dirtyFiles[association.File] = struct{}{}
} }
} }
for _, command := range association.IsolateCommands { for _, command := range association.IsolateCommands {
log.Debug("Checking isolate command %q for file %q", command.Name, association.File) resetWhereNecessaryLogger.Debug("Checking isolate command %q for reset requirement", command.Name)
resetWhereNecessaryLogger.Trace("Isolate command details: %v", command)
if command.Reset { if command.Reset {
log.Debug("Isolate command %q requires reset for file %q", command.Name, association.File) resetWhereNecessaryLogger.Debug("Isolate command %q requires reset for file %q, marking as dirty", command.Name, association.File)
dirtyFiles[association.File] = struct{}{} dirtyFiles[association.File] = struct{}{}
} }
} }
} }
log.Debug("Dirty files: %v", dirtyFiles) resetWhereNecessaryLogger.Debug("Identified %d files that need to be reset", len(dirtyFiles))
resetWhereNecessaryLogger.Trace("Dirty files identified: %v", dirtyFiles)
for file := range dirtyFiles { for file := range dirtyFiles {
log.Debug("Resetting file %q", file) resetWhereNecessaryLogger.Debug("Resetting file %q", file)
fileData, err := db.GetFile(file) fileData, err := db.GetFile(file)
if err != nil { if err != nil {
log.Warning("Failed to get file %q: %v", file, err) resetWhereNecessaryLogger.Warning("Failed to get original content for file %q from database: %v", file, err)
continue // Seed the snapshot from current disk content if missing, then use it as fallback
currentData, readErr := os.ReadFile(file)
if readErr != nil {
resetWhereNecessaryLogger.Warning("Additionally failed to read current file content for %q: %v", file, readErr)
continue
}
// Best-effort attempt to save baseline; ignore errors to avoid blocking reset
if saveErr := db.SaveFile(file, currentData); saveErr != nil {
resetWhereNecessaryLogger.Warning("Failed to seed baseline snapshot for %q: %v", file, saveErr)
}
fileData = currentData
} }
log.Debug("Writing file %q to disk", file) resetWhereNecessaryLogger.Trace("Retrieved original file data length for %q: %d", file, len(fileData))
resetWhereNecessaryLogger.Debug("Writing original content back to file %q", file)
err = os.WriteFile(file, fileData, 0644) err = os.WriteFile(file, fileData, 0644)
if err != nil { if err != nil {
log.Warning("Failed to write file %q: %v", file, err) resetWhereNecessaryLogger.Warning("Failed to write original content back to file %q: %v", file, err)
continue continue
} }
log.Debug("File %q written to disk", file) resetWhereNecessaryLogger.Debug("Successfully reset file %q", file)
} }
log.Debug("Done") resetWhereNecessaryLogger.Debug("Finished reset where necessary operation")
return nil return nil
} }
func ResetAllFiles(db DB) error { func ResetAllFiles(db DB) error {
log := cylogger.Default.WithPrefix("ResetAllFiles") resetAllFilesLogger := fileLogger.WithPrefix("ResetAllFiles")
log.Debug("Start") resetAllFilesLogger.Debug("Starting reset all files operation")
fileSnapshots, err := db.GetAllFiles() fileSnapshots, err := db.GetAllFiles()
if err != nil { if err != nil {
resetAllFilesLogger.Error("Failed to get all file snapshots from database: %v", err)
return err return err
} }
log.Debug("Found %d files in database", len(fileSnapshots)) resetAllFilesLogger.Debug("Found %d files in database to reset", len(fileSnapshots))
resetAllFilesLogger.Trace("File snapshots retrieved: %v", fileSnapshots)
for _, fileSnapshot := range fileSnapshots { for _, fileSnapshot := range fileSnapshots {
log.Debug("Resetting file %q", fileSnapshot.FilePath) resetAllFilesLogger.Debug("Resetting file %q", fileSnapshot.FilePath)
err = os.WriteFile(fileSnapshot.FilePath, fileSnapshot.FileData, 0644) err = os.WriteFile(fileSnapshot.FilePath, fileSnapshot.FileData, 0644)
if err != nil { if err != nil {
log.Warning("Failed to write file %q: %v", fileSnapshot.FilePath, err) resetAllFilesLogger.Warning("Failed to write file %q to disk: %v", fileSnapshot.FilePath, err)
continue continue
} }
log.Debug("File %q written to disk", fileSnapshot.FilePath) resetAllFilesLogger.Debug("File %q written to disk successfully", fileSnapshot.FilePath)
} }
log.Debug("Done") resetAllFilesLogger.Debug("Finished reset all files operation")
return nil return nil
} }

View File

@@ -2,9 +2,20 @@ package utils
import ( import (
"flag" "flag"
logger "git.site.quack-lab.dev/dave/cylogger"
) )
// flagsLogger is a scoped logger for the utils/flags package.
var flagsLogger = logger.Default.WithPrefix("utils/flags")
var ( var (
ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel") ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel")
Filter = flag.String("f", "", "Filter commands before running them") Filter = flag.String("f", "", "Filter commands before running them")
JSON = flag.Bool("json", false, "Enable JSON mode for processing JSON files")
) )
func init() {
flagsLogger.Debug("Initializing flags")
flagsLogger.Trace("ParallelFiles initial value: %d, Filter initial value: %q, JSON initial value: %t", *ParallelFiles, *Filter, *JSON)
}

View File

@@ -11,32 +11,51 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// modifyCommandLogger is a scoped logger for the utils/modifycommand package.
var modifyCommandLogger = logger.Default.WithPrefix("utils/modifycommand")
type ModifyCommand struct { type ModifyCommand struct {
Name string `yaml:"name"` Name string `yaml:"name,omitempty"`
Regex string `yaml:"regex"` Regex string `yaml:"regex,omitempty"`
Lua string `yaml:"lua"` Regexes []string `yaml:"regexes,omitempty"`
Files []string `yaml:"files"` Lua string `yaml:"lua,omitempty"`
Reset bool `yaml:"reset"` Files []string `yaml:"files,omitempty"`
LogLevel string `yaml:"loglevel"` Reset bool `yaml:"reset,omitempty"`
Isolate bool `yaml:"isolate"` LogLevel string `yaml:"loglevel,omitempty"`
NoDedup bool `yaml:"nodedup"` Isolate bool `yaml:"isolate,omitempty"`
Disabled bool `yaml:"disable"` NoDedup bool `yaml:"nodedup,omitempty"`
Disabled bool `yaml:"disable,omitempty"`
JSON bool `yaml:"json,omitempty"`
Modifiers map[string]interface{} `yaml:"modifiers,omitempty"`
} }
type CookFile []ModifyCommand type CookFile []ModifyCommand
func (c *ModifyCommand) Validate() error { func (c *ModifyCommand) Validate() error {
if c.Regex == "" { validateLogger := modifyCommandLogger.WithPrefix("Validate").WithField("commandName", c.Name)
return fmt.Errorf("pattern is required") validateLogger.Debug("Validating command")
// For JSON mode, regex patterns are not required
if !c.JSON {
if c.Regex == "" && len(c.Regexes) == 0 {
validateLogger.Error("Validation failed: Regex pattern is required for non-JSON mode")
return fmt.Errorf("pattern is required for non-JSON mode")
}
} }
if c.Lua == "" { if c.Lua == "" {
validateLogger.Error("Validation failed: Lua expression is required")
return fmt.Errorf("lua expression is required") return fmt.Errorf("lua expression is required")
} }
if len(c.Files) == 0 { if len(c.Files) == 0 {
validateLogger.Error("Validation failed: At least one file is required")
return fmt.Errorf("at least one file is required") return fmt.Errorf("at least one file is required")
} }
if c.LogLevel == "" { if c.LogLevel == "" {
validateLogger.Debug("LogLevel not specified, defaulting to INFO")
c.LogLevel = "INFO" c.LogLevel = "INFO"
} }
validateLogger.Debug("Command validated successfully")
return nil return nil
} }
@@ -44,34 +63,47 @@ func (c *ModifyCommand) Validate() error {
var matchesMemoTable map[string]bool = make(map[string]bool) var matchesMemoTable map[string]bool = make(map[string]bool)
func Matches(path string, glob string) (bool, error) { func Matches(path string, glob string) (bool, error) {
matchesLogger := modifyCommandLogger.WithPrefix("Matches").WithField("path", path).WithField("glob", glob)
matchesLogger.Debug("Checking if path matches glob")
key := fmt.Sprintf("%s:%s", path, glob) key := fmt.Sprintf("%s:%s", path, glob)
if matches, ok := matchesMemoTable[key]; ok { if matches, ok := matchesMemoTable[key]; ok {
logger.Debug("Found match for file %q and glob %q in memo table", path, glob) matchesLogger.Debug("Found match in memo table: %t", matches)
return matches, nil return matches, nil
} }
matches, err := doublestar.Match(glob, path) matches, err := doublestar.Match(glob, path)
if err != nil { if err != nil {
matchesLogger.Error("Failed to match glob: %v", err)
return false, fmt.Errorf("failed to match glob %s with file %s: %w", glob, path, err) return false, fmt.Errorf("failed to match glob %s with file %s: %w", glob, path, err)
} }
matchesMemoTable[key] = matches matchesMemoTable[key] = matches
matchesLogger.Debug("Match result: %t, storing in memo table", matches)
return matches, nil return matches, nil
} }
func SplitPattern(pattern string) (string, string) { func SplitPattern(pattern string) (string, string) {
splitPatternLogger := modifyCommandLogger.WithPrefix("SplitPattern").WithField("pattern", pattern)
splitPatternLogger.Debug("Splitting pattern")
splitPatternLogger.Trace("Original pattern: %q", pattern)
static, pattern := doublestar.SplitPattern(pattern) static, pattern := doublestar.SplitPattern(pattern)
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
splitPatternLogger.Error("Error getting current working directory: %v", err)
return "", "" return "", ""
} }
splitPatternLogger.Trace("Current working directory: %q", cwd)
if static == "" { if static == "" {
splitPatternLogger.Debug("Static part is empty, defaulting to current working directory")
static = cwd static = cwd
} }
if !filepath.IsAbs(static) { if !filepath.IsAbs(static) {
splitPatternLogger.Debug("Static part is not absolute, joining with current working directory")
static = filepath.Join(cwd, static) static = filepath.Join(cwd, static)
static = filepath.Clean(static) static = filepath.Clean(static)
splitPatternLogger.Trace("Static path after joining and cleaning: %q", static)
} }
static = strings.ReplaceAll(static, "\\", "/") static = strings.ReplaceAll(static, "\\", "/")
splitPatternLogger.Trace("Final static path: %q, Remaining pattern: %q", static, pattern)
return static, pattern return static, pattern
} }
@@ -82,180 +114,262 @@ type FileCommandAssociation struct {
} }
func AssociateFilesWithCommands(files []string, commands []ModifyCommand) (map[string]FileCommandAssociation, error) { func AssociateFilesWithCommands(files []string, commands []ModifyCommand) (map[string]FileCommandAssociation, error) {
associateFilesLogger := modifyCommandLogger.WithPrefix("AssociateFilesWithCommands")
associateFilesLogger.Debug("Associating files with commands")
associateFilesLogger.Trace("Input files: %v", files)
associateFilesLogger.Trace("Input commands: %v", commands)
associationCount := 0 associationCount := 0
fileCommands := make(map[string]FileCommandAssociation) fileCommands := make(map[string]FileCommandAssociation)
for _, file := range files { for _, file := range files {
file = strings.ReplaceAll(file, "\\", "/") file = strings.ReplaceAll(file, "\\", "/")
associateFilesLogger.Debug("Processing file: %q", file)
fileCommands[file] = FileCommandAssociation{ fileCommands[file] = FileCommandAssociation{
File: file, File: file,
IsolateCommands: []ModifyCommand{}, IsolateCommands: []ModifyCommand{},
Commands: []ModifyCommand{}, Commands: []ModifyCommand{},
} }
for _, command := range commands { for _, command := range commands {
associateFilesLogger.Debug("Checking command %q for file %q", command.Name, file)
for _, glob := range command.Files { for _, glob := range command.Files {
glob = strings.ReplaceAll(glob, "\\", "/") glob = strings.ReplaceAll(glob, "\\", "/")
static, pattern := SplitPattern(glob) static, pattern := SplitPattern(glob)
patternFile := strings.Replace(file, static+`/`, "", 1) associateFilesLogger.Trace("Glob parts for %q → static=%q pattern=%q", glob, static, pattern)
// Build absolute path for the current file to compare with static
cwd, err := os.Getwd()
if err != nil {
associateFilesLogger.Warning("Failed to get CWD when matching %q for file %q: %v", glob, file, err)
continue
}
var absFile string
if filepath.IsAbs(file) {
absFile = filepath.Clean(file)
} else {
absFile = filepath.Clean(filepath.Join(cwd, file))
}
absFile = strings.ReplaceAll(absFile, "\\", "/")
associateFilesLogger.Trace("Absolute file path resolved for matching: %q", absFile)
// Only match if the file is under the static root
if !(strings.HasPrefix(absFile, static+"/") || absFile == static) {
associateFilesLogger.Trace("Skipping glob %q for file %q because file is outside static root %q", glob, file, static)
continue
}
patternFile := strings.TrimPrefix(absFile, static+`/`)
associateFilesLogger.Trace("Pattern-relative path used for match: %q", patternFile)
matches, err := Matches(patternFile, pattern) matches, err := Matches(patternFile, pattern)
if err != nil { if err != nil {
logger.Trace("Failed to match glob %s with file %s: %v", glob, file, err) associateFilesLogger.Warning("Failed to match glob %q with file %q: %v", glob, file, err)
continue continue
} }
if matches { if matches {
logger.Debug("Found match for file %q and command %q", file, command.Regex) associateFilesLogger.Debug("File %q matches glob %q. Associating with command %q", file, glob, command.Name)
association := fileCommands[file] association := fileCommands[file]
if command.Isolate { if command.Isolate {
associateFilesLogger.Debug("Command %q is an isolate command, adding to isolate list", command.Name)
association.IsolateCommands = append(association.IsolateCommands, command) association.IsolateCommands = append(association.IsolateCommands, command)
} else { } else {
associateFilesLogger.Debug("Command %q is a regular command, adding to regular list", command.Name)
association.Commands = append(association.Commands, command) association.Commands = append(association.Commands, command)
} }
fileCommands[file] = association fileCommands[file] = association
associationCount++ associationCount++
} else {
associateFilesLogger.Trace("File %q did not match glob %q (pattern=%q, rel=%q)", file, glob, pattern, patternFile)
} }
} }
} }
logger.Debug("Found %d commands for file %q", len(fileCommands[file].Commands), file) currentFileCommands := fileCommands[file]
if len(fileCommands[file].Commands) == 0 { associateFilesLogger.Debug("Finished processing file %q. Found %d regular commands and %d isolate commands", file, len(currentFileCommands.Commands), len(currentFileCommands.IsolateCommands))
logger.Info("No commands found for file %q", file) associateFilesLogger.Trace("Commands for file %q: %v", file, currentFileCommands.Commands)
} associateFilesLogger.Trace("Isolate commands for file %q: %v", file, currentFileCommands.IsolateCommands)
if len(fileCommands[file].IsolateCommands) > 0 {
logger.Info("Found %d isolate commands for file %q", len(fileCommands[file].IsolateCommands), file)
}
} }
logger.Info("Found %d associations between %d files and %d commands", associationCount, len(files), len(commands)) associateFilesLogger.Info("Completed association. Found %d total associations for %d files and %d commands", associationCount, len(files), len(commands))
return fileCommands, nil return fileCommands, nil
} }
func AggregateGlobs(commands []ModifyCommand) map[string]struct{} { func AggregateGlobs(commands []ModifyCommand) map[string]struct{} {
logger.Info("Aggregating globs for %d commands", len(commands)) aggregateGlobsLogger := modifyCommandLogger.WithPrefix("AggregateGlobs")
aggregateGlobsLogger.Debug("Aggregating glob patterns from commands")
aggregateGlobsLogger.Trace("Input commands for aggregation: %v", commands)
globs := make(map[string]struct{}) globs := make(map[string]struct{})
for _, command := range commands { for _, command := range commands {
aggregateGlobsLogger.Debug("Processing command %q for glob patterns", command.Name)
for _, glob := range command.Files { for _, glob := range command.Files {
glob = strings.Replace(glob, "~", os.Getenv("HOME"), 1) resolvedGlob := strings.Replace(glob, "~", os.Getenv("HOME"), 1)
glob = strings.ReplaceAll(glob, "\\", "/") resolvedGlob = strings.ReplaceAll(resolvedGlob, "\\", "/")
globs[glob] = struct{}{} aggregateGlobsLogger.Trace("Adding glob: %q (resolved to %q)", glob, resolvedGlob)
globs[resolvedGlob] = struct{}{}
} }
} }
logger.Info("Found %d unique globs", len(globs)) aggregateGlobsLogger.Debug("Finished aggregating globs. Found %d unique glob patterns", len(globs))
aggregateGlobsLogger.Trace("Aggregated unique globs: %v", globs)
return globs return globs
} }
func ExpandGLobs(patterns map[string]struct{}) ([]string, error) { func ExpandGLobs(patterns map[string]struct{}) ([]string, error) {
expandGlobsLogger := modifyCommandLogger.WithPrefix("ExpandGLobs")
expandGlobsLogger.Debug("Expanding glob patterns to actual files")
expandGlobsLogger.Trace("Input patterns for expansion: %v", patterns)
var files []string var files []string
filesMap := make(map[string]bool) filesMap := make(map[string]bool)
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
expandGlobsLogger.Error("Failed to get current working directory: %v", err)
return nil, fmt.Errorf("failed to get current working directory: %w", err) return nil, fmt.Errorf("failed to get current working directory: %w", err)
} }
expandGlobsLogger.Debug("Current working directory: %q", cwd)
logger.Debug("Expanding patterns from directory: %s", cwd)
for pattern := range patterns { for pattern := range patterns {
logger.Trace("Processing pattern: %s", pattern) expandGlobsLogger.Debug("Processing glob pattern: %q", pattern)
static, pattern := SplitPattern(pattern) static, pattern := SplitPattern(pattern)
matches, _ := doublestar.Glob(os.DirFS(static), pattern) matches, err := doublestar.Glob(os.DirFS(static), pattern)
logger.Debug("Found %d matches for pattern %s", len(matches), pattern) if err != nil {
expandGlobsLogger.Warning("Error expanding glob %q in %q: %v", pattern, static, err)
continue
}
expandGlobsLogger.Debug("Found %d matches for pattern %q", len(matches), pattern)
expandGlobsLogger.Trace("Raw matches for pattern %q: %v", pattern, matches)
for _, m := range matches { for _, m := range matches {
m = filepath.Join(static, m) m = filepath.Join(static, m)
info, err := os.Stat(m) info, err := os.Stat(m)
if err != nil { if err != nil {
logger.Warning("Error getting file info for %s: %v", m, err) expandGlobsLogger.Warning("Error getting file info for %q: %v", m, err)
continue continue
} }
if !info.IsDir() && !filesMap[m] { if !info.IsDir() && !filesMap[m] {
logger.Trace("Adding file to process list: %s", m) expandGlobsLogger.Trace("Adding unique file to list: %q", m)
filesMap[m], files = true, append(files, m) filesMap[m], files = true, append(files, m)
} }
} }
} }
if len(files) > 0 { if len(files) > 0 {
logger.Debug("Found %d files to process: %v", len(files), files) expandGlobsLogger.Debug("Finished expanding globs. Found %d unique files to process", len(files))
expandGlobsLogger.Trace("Unique files to process: %v", files)
} else {
expandGlobsLogger.Warning("No files found after expanding glob patterns.")
} }
return files, nil return files, nil
} }
func LoadCommands(args []string) ([]ModifyCommand, error) { func LoadCommands(args []string) ([]ModifyCommand, error) {
loadCommandsLogger := modifyCommandLogger.WithPrefix("LoadCommands")
loadCommandsLogger.Debug("Loading commands from arguments (cook files or direct patterns)")
loadCommandsLogger.Trace("Input arguments: %v", args)
commands := []ModifyCommand{} commands := []ModifyCommand{}
logger.Info("Loading commands from cook files: %s", args)
for _, arg := range args { for _, arg := range args {
newcommands, err := LoadCommandsFromCookFiles(arg) loadCommandsLogger.Debug("Processing argument for commands: %q", arg)
newCommands, err := LoadCommandsFromCookFiles(arg)
if err != nil { if err != nil {
loadCommandsLogger.Error("Failed to load commands from argument %q: %v", arg, err)
return nil, fmt.Errorf("failed to load commands from cook files: %w", err) return nil, fmt.Errorf("failed to load commands from cook files: %w", err)
} }
logger.Info("Successfully loaded %d commands from cook files", len(newcommands)) loadCommandsLogger.Debug("Successfully loaded %d commands from %q", len(newCommands), arg)
for _, cmd := range newcommands { for _, cmd := range newCommands {
if cmd.Disabled { if cmd.Disabled {
logger.Info("Skipping disabled command: %s", cmd.Name) loadCommandsLogger.Debug("Skipping disabled command: %q", cmd.Name)
continue continue
} }
commands = append(commands, cmd) commands = append(commands, cmd)
loadCommandsLogger.Trace("Added command %q. Current total commands: %d", cmd.Name, len(commands))
} }
logger.Info("Now total commands: %d", len(commands))
} }
logger.Info("Loaded %d commands from all cook file", len(commands)) loadCommandsLogger.Info("Finished loading commands. Total %d commands loaded", len(commands))
return commands, nil return commands, nil
} }
func LoadCommandsFromCookFiles(pattern string) ([]ModifyCommand, error) { func LoadCommandsFromCookFiles(pattern string) ([]ModifyCommand, error) {
loadCookFilesLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromCookFiles").WithField("pattern", pattern)
loadCookFilesLogger.Debug("Loading commands from cook files based on pattern")
loadCookFilesLogger.Trace("Input pattern: %q", pattern)
static, pattern := SplitPattern(pattern) static, pattern := SplitPattern(pattern)
commands := []ModifyCommand{} commands := []ModifyCommand{}
cookFiles, err := doublestar.Glob(os.DirFS(static), pattern) cookFiles, err := doublestar.Glob(os.DirFS(static), pattern)
if err != nil { if err != nil {
loadCookFilesLogger.Error("Failed to glob cook files for pattern %q: %v", pattern, err)
return nil, fmt.Errorf("failed to glob cook files: %w", err) return nil, fmt.Errorf("failed to glob cook files: %w", err)
} }
loadCookFilesLogger.Debug("Found %d cook files for pattern %q", len(cookFiles), pattern)
loadCookFilesLogger.Trace("Cook files found: %v", cookFiles)
for _, cookFile := range cookFiles { for _, cookFile := range cookFiles {
cookFile = filepath.Join(static, cookFile) cookFile = filepath.Join(static, cookFile)
cookFile = filepath.Clean(cookFile) cookFile = filepath.Clean(cookFile)
cookFile = strings.ReplaceAll(cookFile, "\\", "/") cookFile = strings.ReplaceAll(cookFile, "\\", "/")
logger.Info("Loading commands from cook file: %s", cookFile) loadCookFilesLogger.Debug("Loading commands from individual cook file: %q", cookFile)
cookFileData, err := os.ReadFile(cookFile) cookFileData, err := os.ReadFile(cookFile)
if err != nil { if err != nil {
loadCookFilesLogger.Error("Failed to read cook file %q: %v", cookFile, err)
return nil, fmt.Errorf("failed to read cook file: %w", err) return nil, fmt.Errorf("failed to read cook file: %w", err)
} }
newcommands, err := LoadCommandsFromCookFile(cookFileData) loadCookFilesLogger.Trace("Read %d bytes from cook file %q", len(cookFileData), cookFile)
newCommands, err := LoadCommandsFromCookFile(cookFileData)
if err != nil { if err != nil {
loadCookFilesLogger.Error("Failed to load commands from cook file data for %q: %v", cookFile, err)
return nil, fmt.Errorf("failed to load commands from cook file: %w", err) return nil, fmt.Errorf("failed to load commands from cook file: %w", err)
} }
commands = append(commands, newcommands...) commands = append(commands, newCommands...)
loadCookFilesLogger.Debug("Added %d commands from cook file %q. Total commands now: %d", len(newCommands), cookFile, len(commands))
} }
loadCookFilesLogger.Debug("Finished loading commands from cook files. Total %d commands", len(commands))
return commands, nil return commands, nil
} }
func LoadCommandsFromCookFile(cookFileData []byte) ([]ModifyCommand, error) { func LoadCommandsFromCookFile(cookFileData []byte) ([]ModifyCommand, error) {
loadCommandLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromCookFile")
loadCommandLogger.Debug("Unmarshaling commands from cook file data")
loadCommandLogger.Trace("Cook file data length: %d", len(cookFileData))
commands := []ModifyCommand{} commands := []ModifyCommand{}
err := yaml.Unmarshal(cookFileData, &commands) err := yaml.Unmarshal(cookFileData, &commands)
if err != nil { if err != nil {
loadCommandLogger.Error("Failed to unmarshal cook file data: %v", err)
return nil, fmt.Errorf("failed to unmarshal cook file: %w", err) return nil, fmt.Errorf("failed to unmarshal cook file: %w", err)
} }
loadCommandLogger.Debug("Successfully unmarshaled %d commands", len(commands))
loadCommandLogger.Trace("Unmarshaled commands: %v", commands)
return commands, nil return commands, nil
} }
// CountGlobsBeforeDedup counts the total number of glob patterns across all commands before deduplication // CountGlobsBeforeDedup counts the total number of glob patterns across all commands before deduplication
func CountGlobsBeforeDedup(commands []ModifyCommand) int { func CountGlobsBeforeDedup(commands []ModifyCommand) int {
countGlobsLogger := modifyCommandLogger.WithPrefix("CountGlobsBeforeDedup")
countGlobsLogger.Debug("Counting glob patterns before deduplication")
count := 0 count := 0
for _, cmd := range commands { for _, cmd := range commands {
countGlobsLogger.Trace("Processing command %q, adding %d globs", cmd.Name, len(cmd.Files))
count += len(cmd.Files) count += len(cmd.Files)
} }
countGlobsLogger.Debug("Total glob patterns before deduplication: %d", count)
return count return count
} }
func FilterCommands(commands []ModifyCommand, filter string) []ModifyCommand { func FilterCommands(commands []ModifyCommand, filter string) []ModifyCommand {
filterCommandsLogger := modifyCommandLogger.WithPrefix("FilterCommands").WithField("filter", filter)
filterCommandsLogger.Debug("Filtering commands")
filterCommandsLogger.Trace("Input commands: %v", commands)
filteredCommands := []ModifyCommand{} filteredCommands := []ModifyCommand{}
filters := strings.Split(filter, ",") filters := strings.Split(filter, ",")
filterCommandsLogger.Trace("Split filters: %v", filters)
for _, cmd := range commands { for _, cmd := range commands {
for _, filter := range filters { filterCommandsLogger.Debug("Checking command %q against filters", cmd.Name)
if strings.Contains(cmd.Name, filter) { for _, f := range filters {
if strings.Contains(cmd.Name, f) {
filterCommandsLogger.Debug("Command %q matches filter %q, adding to filtered list", cmd.Name, f)
filteredCommands = append(filteredCommands, cmd) filteredCommands = append(filteredCommands, cmd)
break // Command matches, no need to check other filters
} }
} }
} }
filterCommandsLogger.Debug("Finished filtering commands. Found %d filtered commands", len(filteredCommands))
filterCommandsLogger.Trace("Filtered commands: %v", filteredCommands)
return filteredCommands return filteredCommands
} }

View File

@@ -7,6 +7,9 @@ import (
logger "git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
) )
// replaceCommandLogger is a scoped logger for the utils/replacecommand package.
var replaceCommandLogger = logger.Default.WithPrefix("utils/replacecommand")
type ReplaceCommand struct { type ReplaceCommand struct {
From int From int
To int To int
@@ -14,45 +17,63 @@ type ReplaceCommand struct {
} }
func ExecuteModifications(modifications []ReplaceCommand, fileData string) (string, int) { func ExecuteModifications(modifications []ReplaceCommand, fileData string) (string, int) {
executeModificationsLogger := replaceCommandLogger.WithPrefix("ExecuteModifications")
executeModificationsLogger.Debug("Executing a batch of text modifications")
executeModificationsLogger.Trace("Number of modifications: %d, Original file data length: %d", len(modifications), len(fileData))
var err error var err error
sort.Slice(modifications, func(i, j int) bool { sort.Slice(modifications, func(i, j int) bool {
return modifications[i].From > modifications[j].From return modifications[i].From > modifications[j].From
}) })
logger.Trace("Preparing to apply %d replacement commands in reverse order", len(modifications)) executeModificationsLogger.Debug("Modifications sorted in reverse order for safe replacement")
executeModificationsLogger.Trace("Sorted modifications: %v", modifications)
executed := 0 executed := 0
for _, modification := range modifications { for idx, modification := range modifications {
executeModificationsLogger.Debug("Applying modification %d/%d", idx+1, len(modifications))
executeModificationsLogger.Trace("Current modification details: From=%d, To=%d, With=%q", modification.From, modification.To, modification.With)
fileData, err = modification.Execute(fileData) fileData, err = modification.Execute(fileData)
if err != nil { if err != nil {
logger.Error("Failed to execute replacement: %v", err) executeModificationsLogger.Error("Failed to execute replacement for modification %+v: %v", modification, err)
continue continue
} }
executed++ executed++
executeModificationsLogger.Trace("File data length after modification: %d", len(fileData))
} }
logger.Info("Successfully applied %d text replacements", executed) executeModificationsLogger.Info("Successfully applied %d text replacements", executed)
return fileData, executed return fileData, executed
} }
func (m *ReplaceCommand) Execute(fileDataStr string) (string, error) { func (m *ReplaceCommand) Execute(fileDataStr string) (string, error) {
executeLogger := replaceCommandLogger.WithPrefix("Execute").WithField("modification", fmt.Sprintf("From:%d,To:%d,With:%q", m.From, m.To, m.With))
executeLogger.Debug("Attempting to execute single replacement")
err := m.Validate(len(fileDataStr)) err := m.Validate(len(fileDataStr))
if err != nil { if err != nil {
executeLogger.Error("Failed to validate modification: %v", err)
return fileDataStr, fmt.Errorf("failed to validate modification: %v", err) return fileDataStr, fmt.Errorf("failed to validate modification: %v", err)
} }
logger.Trace("Replace pos %d-%d with %q", m.From, m.To, m.With) executeLogger.Trace("Applying replacement: fileDataStr[:%d] + %q + fileDataStr[%d:]", m.From, m.With, m.To)
return fileDataStr[:m.From] + m.With + fileDataStr[m.To:], nil result := fileDataStr[:m.From] + m.With + fileDataStr[m.To:]
executeLogger.Trace("Replacement executed. Result length: %d", len(result))
return result, nil
} }
func (m *ReplaceCommand) Validate(maxsize int) error { func (m *ReplaceCommand) Validate(maxsize int) error {
validateLogger := replaceCommandLogger.WithPrefix("Validate").WithField("modification", fmt.Sprintf("From:%d,To:%d,With:%q", m.From, m.To, m.With)).WithField("maxSize", maxsize)
validateLogger.Debug("Validating replacement command against max size")
if m.To < m.From { if m.To < m.From {
validateLogger.Error("Validation failed: 'To' (%d) is less than 'From' (%d)", m.To, m.From)
return fmt.Errorf("command to is less than from: %v", m) return fmt.Errorf("command to is less than from: %v", m)
} }
if m.From > maxsize || m.To > maxsize { if m.From > maxsize || m.To > maxsize {
validateLogger.Error("Validation failed: 'From' (%d) or 'To' (%d) is greater than max size (%d)", m.From, m.To, maxsize)
return fmt.Errorf("command from or to is greater than replacement length: %v", m) return fmt.Errorf("command from or to is greater than replacement length: %v", m)
} }
if m.From < 0 || m.To < 0 { if m.From < 0 || m.To < 0 {
validateLogger.Error("Validation failed: 'From' (%d) or 'To' (%d) is less than 0", m.From, m.To)
return fmt.Errorf("command from or to is less than 0: %v", m) return fmt.Errorf("command from or to is less than 0: %v", m)
} }
validateLogger.Debug("Modification command validated successfully")
return nil return nil
} }