20 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
15 changed files with 2252 additions and 71 deletions

1
.gitignore vendored
View File

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

13
.vscode/launch.json vendored
View File

@@ -98,6 +98,19 @@
"args": [
"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 (
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/jinzhu/inflection v1.0.0 // 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/pmezard/go-difflib v1.0.0 // 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/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect
@@ -29,4 +30,8 @@ require (
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=

299
main.go
View File

@@ -44,9 +44,13 @@ func main() {
fmt.Fprintf(os.Stderr, " Reset files to their original state\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, " -json\n")
fmt.Fprintf(os.Stderr, " Enable JSON mode for processing JSON files\n")
fmt.Fprintf(os.Stderr, "\nExamples:\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, " 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, " 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")
@@ -54,6 +58,8 @@ func main() {
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, " 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
flag.Parse()
@@ -96,6 +102,23 @@ func main() {
flag.Usage()
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 != "" {
@@ -111,7 +134,11 @@ func main() {
for _, command := range commands {
mainLogger.Trace("Command: %s", command.Name)
mainLogger.Trace("Regex: %s", command.Regex)
if len(command.Regexes) > 0 {
mainLogger.Trace("Regexes: %v", command.Regexes)
} else {
mainLogger.Trace("Regex: %s", command.Regex)
}
mainLogger.Trace("Files: %v", command.Files)
mainLogger.Trace("Lua: %s", command.Lua)
mainLogger.Trace("Reset: %t", command.Reset)
@@ -370,29 +397,115 @@ func CreateExampleConfig() {
createExampleConfigLogger := logger.Default.WithPrefix("CreateExampleConfig")
createExampleConfigLogger.Debug("Creating example configuration file")
commands := []utils.ModifyCommand{
// Global modifiers only entry (no name/regex/lua/files)
{
Name: "DoubleNumericValues",
Regex: "<value>(\\d+)<\\/value>",
Lua: "v1 * 2",
Files: []string{"data/*.xml"},
LogLevel: "INFO",
Modifiers: map[string]interface{}{
"foobar": 4,
"multiply": 1.5,
"prefix": "NEW_",
"enabled": true,
},
},
// Multi-regex example using $variable in Lua
{
Name: "UpdatePrices",
Regex: "price=\"(\\d+)\"",
Lua: "if num(v1) < 100 then return v1 * 1.5 else return v1 end",
Files: []string{"items/*.xml", "shop/*.xml"},
Name: "RFToolsMultiply",
Regexes: []string{"generatePerTick = !num", "ticksPer\\w+ = !num", "generatorRFPerTick = !num"},
Lua: "* $foobar",
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",
},
// Use equals operator shorthand and boolean variable
{
Name: "IsolatedTagUpdate",
Regex: "<tag>(.*?)<\\/tag>",
Lua: "string.upper(s1)",
Files: []string{"config.xml"},
Name: "EnableFlags",
Regex: `enabled\s*=\s*(true|false)`,
Lua: `= $enabled`,
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,
NoDedup: true,
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)
@@ -418,41 +531,86 @@ func RunOtherCommands(file string, fileDataStr string, association utils.FileCom
runOtherCommandsLogger.Debug("Running other commands for file")
runOtherCommandsLogger.Trace("File data before modifications: %s", utils.LimitString(fileDataStr, 200))
// Aggregate all the modifications and execute them
modifications := []utils.ReplaceCommand{}
numCommandsConsidered := 0
// Separate JSON and regex commands for different processing approaches
jsonCommands := []utils.ModifyCommand{}
regexCommands := []utils.ModifyCommand{}
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
if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog
}
cmdLogger.Debug("Begin processing file with command %q", command.Regex)
numCommandsConsidered++
newModifications, err := processor.ProcessRegex(fileDataStr, command, file)
cmdLogger.Debug("Processing file with JSON mode for command %q", command.Name)
newModifications, err := processor.ProcessJSON(fileDataStr, command, file)
if err != nil {
runOtherCommandsLogger.Error("Failed to process file with command %q: %v", command.Regex, err)
runOtherCommandsLogger.Error("Failed to process file with JSON command %q: %v", command.Name, err)
continue
}
modifications = append(modifications, newModifications...)
// It is not guranteed that all the commands will be executed...
// TODO: Make this better
// We'd have to pass the map to executemodifications or something...
// Apply JSON modifications immediately
if len(newModifications) > 0 {
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)
if !ok {
count = 0
}
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
}
cmdLogger.Debug("Command %q generated %d modifications", command.Name, len(newModifications))
cmdLogger.Trace("Modifications generated by command %q: %v", command.Name, newModifications)
if len(newModifications) == 0 {
cmdLogger.Debug("No modifications yielded by command %q", command.Name)
// 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 commands", len(modifications), numCommandsConsidered)
runOtherCommandsLogger.Debug("Aggregated %d modifications from %d command-pattern runs", len(modifications), numCommandsConsidered)
runOtherCommandsLogger.Trace("All aggregated modifications: %v", modifications)
if len(modifications) == 0 {
@@ -479,28 +637,63 @@ func RunIsolateCommands(association utils.FileCommandAssociation, file string, f
anythingDone := false
for _, isolateCommand := range association.IsolateCommands {
runIsolateCommandsLogger.Debug("Begin processing file with isolate command %q", isolateCommand.Regex)
modifications, err := processor.ProcessRegex(fileDataStr, isolateCommand, file)
if err != nil {
runIsolateCommandsLogger.Error("Failed to process file with isolate command %q: %v", isolateCommand.Regex, err)
continue
// Check if this isolate command should use JSON mode
if isolateCommand.JSON || *utils.JSON {
runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
modifications, err := processor.ProcessJSON(fileDataStr, isolateCommand, file)
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 {
runIsolateCommandsLogger.Debug("Isolate command %q produced no modifications", isolateCommand.Name)
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 !anythingDone {
runIsolateCommandsLogger.Debug("No isolate modifications were made for file")

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,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"cook/utils"
@@ -18,6 +19,14 @@ var processorLogger = logger.Default.WithPrefix("processor")
// Maybe we make this an interface again for the shits and giggles
// 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) {
newLStateLogger := processorLogger.WithPrefix("NewLuaState")
newLStateLogger.Debug("Creating new Lua state")
@@ -42,6 +51,34 @@ func NewLuaState() (*lua.LState, error) {
}
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
}
@@ -210,6 +247,7 @@ modified = false
initLuaHelpersLogger.Debug("Setting up Lua print function to Go")
L.SetGlobal("print", L.NewFunction(printToGo))
L.SetGlobal("fetch", L.NewFunction(fetch))
L.SetGlobal("re", L.NewFunction(EvalRegex))
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
return nil
}
@@ -246,6 +284,9 @@ func BuildLuaScript(luaExpr string) string {
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)
fullScript := fmt.Sprintf(`
@@ -260,6 +301,52 @@ func BuildLuaScript(luaExpr string) string {
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 {
printToGoLogger := processorLogger.WithPrefix("printToGo")
printToGoLogger.Debug("Lua print function called, redirecting to Go logger")
@@ -395,3 +482,86 @@ func fetch(L *lua.LState) int {
fetchLogger.Debug("Pushed response table to Lua stack")
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

@@ -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

@@ -7,6 +7,7 @@ import (
logger "git.site.quack-lab.dev/dave/cylogger"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// dbLogger is a scoped logger for the utils/db package.
@@ -43,7 +44,7 @@ func GetDB() (DB, error) {
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
// SkipDefaultTransaction: true,
PrepareStmt: true,
// Logger: gormlogger.Default.LogMode(gormlogger.Silent),
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
getDBLogger.Error("Failed to open database: %v", err)
@@ -120,7 +121,8 @@ func (db *DBWrapper) GetFile(filePath string) ([]byte, error) {
var fileSnapshot FileSnapshot
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error
if err != nil {
getFileLogger.Error("Failed to get file from database: %v", err)
// 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
}
getFileLogger.Debug("File found in database")

View File

@@ -102,7 +102,17 @@ func ResetWhereNecessary(associations map[string]FileCommandAssociation, db DB)
fileData, err := db.GetFile(file)
if err != nil {
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
}
resetWhereNecessaryLogger.Trace("Retrieved original file data length for %q: %d", file, len(fileData))
resetWhereNecessaryLogger.Debug("Writing original content back to file %q", file)

View File

@@ -12,9 +12,10 @@ var flagsLogger = logger.Default.WithPrefix("utils/flags")
var (
ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel")
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", *ParallelFiles, *Filter)
flagsLogger.Trace("ParallelFiles initial value: %d, Filter initial value: %q, JSON initial value: %t", *ParallelFiles, *Filter, *JSON)
}

View File

@@ -15,15 +15,18 @@ import (
var modifyCommandLogger = logger.Default.WithPrefix("utils/modifycommand")
type ModifyCommand struct {
Name string `yaml:"name"`
Regex string `yaml:"regex"`
Lua string `yaml:"lua"`
Files []string `yaml:"files"`
Reset bool `yaml:"reset"`
LogLevel string `yaml:"loglevel"`
Isolate bool `yaml:"isolate"`
NoDedup bool `yaml:"nodedup"`
Disabled bool `yaml:"disable"`
Name string `yaml:"name,omitempty"`
Regex string `yaml:"regex,omitempty"`
Regexes []string `yaml:"regexes,omitempty"`
Lua string `yaml:"lua,omitempty"`
Files []string `yaml:"files,omitempty"`
Reset bool `yaml:"reset,omitempty"`
LogLevel string `yaml:"loglevel,omitempty"`
Isolate bool `yaml:"isolate,omitempty"`
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
@@ -31,10 +34,15 @@ type CookFile []ModifyCommand
func (c *ModifyCommand) Validate() error {
validateLogger := modifyCommandLogger.WithPrefix("Validate").WithField("commandName", c.Name)
validateLogger.Debug("Validating command")
if c.Regex == "" {
validateLogger.Error("Validation failed: Regex pattern is required")
return fmt.Errorf("pattern is required")
// 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 == "" {
validateLogger.Error("Validation failed: Lua expression is required")
return fmt.Errorf("lua expression is required")