56 Commits

Author SHA1 Message Date
3bcc958dda Remove the error return value and instead just throw error 2025-11-15 18:10:24 +01:00
11f0bbee53 "Rework" the csv parsing to cook metatable for header access instead of whatever the fuck I was doing 2025-11-15 18:06:21 +01:00
c145ad0900 Add headers to rowS (SSSSSSSSSSSSSSSS) and not individual row 2025-11-15 16:56:46 +01:00
e02c1f018f fixup! Add lua csv parser regression test(s) 2025-11-15 16:54:22 +01:00
07fea6238f Add lua csv parser regression test(s) 2025-11-15 16:53:06 +01:00
5f1fdfa6c1 Fix some go linter warnings 2025-11-15 16:53:06 +01:00
4fb25d0463 Add a complex test for csv parser 2025-11-15 16:37:38 +01:00
bf23894188 Check options passed to csv parser 2025-11-15 16:30:53 +01:00
aec0f9f171 Update readme for csv parsing 2025-11-15 16:01:36 +01:00
83fed68432 Fix csv header key assignment 2025-11-15 16:01:31 +01:00
4311533445 Lowercase the csv options 2025-11-15 15:57:21 +01:00
ce28b948d0 Add an options parameter to csv parser and comment support 2025-11-15 15:51:49 +01:00
efc602e0ba Add a few more lua tests 2025-11-15 15:43:50 +01:00
917063db0c Refactor lua helper script to separate file
And write a few tests for it
2025-11-15 15:42:57 +01:00
3e552428a5 Improve the csv parser by reading header and assigning values to their header
So we have things like row[1] AND ALSO row["foobar"]
And also row.foobar of course
2025-11-15 15:39:49 +01:00
50455c491d Add support for any SV like TSV 2025-11-15 15:25:18 +01:00
12ec399b09 Do some retarded shit idk what claude did here hopefully something good this will probably get dropped 2025-11-11 12:53:53 +01:00
5a49998c2c Add a toCSV to go along with the fromCSV 2025-11-03 19:28:00 +01:00
590f19603e Make regex matching set INTEGER keys not string 2025-11-03 19:07:15 +01:00
ee8c4b9aa5 Ah oops, can't be local oopsie!! 2025-11-03 16:35:53 +01:00
e8d6613ac8 Add ParseCSV as lua function 2025-11-03 16:33:44 +01:00
91ad9006fa Move the example generation to separate flag 2025-11-02 20:04:39 +01:00
60ba3ad417 Fix some tests that broke for some good reason I'm sure 2025-10-26 17:10:42 +01:00
b74e4724d4 Convert \n to \r?\n because of windows thank you windows :) 2025-10-26 17:04:39 +01:00
30246fd626 Fix logging the regex
It was adding backslashes and shit
2025-10-26 17:04:39 +01:00
06aed7b27a Migrate from flag to cobra 2025-10-26 16:34:44 +01:00
b001dfe667 Embed the example file instead of generting it dynamically 2025-10-26 15:17:30 +01:00
d905ad027a Manually generate the example toml to "enforce" single quotes (looks nicer!) 2025-10-26 15:14:02 +01:00
3f6a03aee8 Add conversion tests 2025-10-26 15:07:55 +01:00
302e874710 Add conversion functionality from yml to toml 2025-10-26 15:05:22 +01:00
9d9820072a Hallucinate a whole lotta tests for toml integration 2025-10-26 15:03:48 +01:00
53d14345b9 Add support (and preference) to toml from yml 2025-10-26 14:56:04 +01:00
67c3346f2f Add test for multiple isolate commands on same line of text 2025-10-24 17:13:28 +02:00
346afdd143 Fix some shit 2025-10-23 00:19:07 +02:00
48729cdfa4 Add isolate tests 2025-10-23 00:16:35 +02:00
b9574f0106 "Fix" isolate commands fucking each other (hallucinate) 2025-10-23 00:13:14 +02:00
635ca463c0 Hallucinate fixes to some tests 2025-10-21 10:38:39 +02:00
2459988ff0 Hallucinate a better logging lol 2025-10-21 10:10:16 +02:00
6ab08fe97f Change something? 2025-10-21 09:57:09 +02:00
2dafe4a981 Don't warn on no file in cache 2025-10-21 09:57:02 +02:00
ec24e0713d Simplify EvalRegex function by removing unnecessary panic handling and nil checks 2025-10-21 09:57:02 +02:00
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
26 changed files with 4727 additions and 537 deletions

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",
]
}
]
}

View File

@@ -16,6 +16,7 @@ A Go-based tool for modifying XML, JSON, and text documents using XPath/JSONPath
- String manipulations
- Date conversions
- Structural changes
- CSV/TSV parsing with comments and headers
- Whole ass Lua environment
- **Error Handling**: Comprehensive error detection for:
- Invalid XML/JSON
@@ -101,6 +102,87 @@ chef -xml "//item" "if tonumber(v.stock) > 0 then v.price = v.price * 0.8 end" i
<item stock="5" price="8.00"/>
```
### 6. CSV/TSV Processing
The Lua environment includes CSV parsing functions that support comments, headers, and custom delimiters.
```lua
-- Basic CSV parsing
local rows = fromCSV(csvText)
-- With options
local rows = fromCSV(csvText, {
delimiter = "\t", -- Tab delimiter for TSV (default: ",")
hasHeaders = true, -- First row is headers (default: false)
hasComments = true -- Filter lines starting with # (default: false)
})
-- Access by index
local value = rows[1][2]
-- Access by header name (when hasHeaders = true)
local value = rows[1].Name
-- Convert back to CSV
local csv = toCSV(rows, "\t") -- Optional delimiter parameter
```
**Example with commented TSV file:**
```lua
-- Input file:
-- #mercenary_profiles
-- Id Name Value
-- 1 Test 100
-- 2 Test2 200
local csv = readFile("mercenaries.tsv")
local rows = fromCSV(csv, {
delimiter = "\t",
hasHeaders = true,
hasComments = true
})
-- Access data
rows[1].Name -- "Test"
rows[2].Value -- "200"
```
## Lua Helper Functions
The Lua environment includes many helper functions:
### Math Functions
- `min(a, b)`, `max(a, b)` - Min/max of two numbers
- `round(x, n)` - Round to n decimal places
- `floor(x)`, `ceil(x)` - Floor/ceiling functions
### String Functions
- `upper(s)`, `lower(s)` - Case conversion
- `trim(s)` - Remove leading/trailing whitespace
- `format(s, ...)` - String formatting
- `strsplit(inputstr, sep)` - Split string by separator
### CSV Functions
- `fromCSV(csv, options)` - Parse CSV/TSV text into table of rows
- Options: `delimiter` (default: ","), `hasHeaders` (default: false), `hasComments` (default: false)
- `toCSV(rows, delimiter)` - Convert table of rows back to CSV text
### Conversion Functions
- `num(str)` - Convert string to number (returns 0 if invalid)
- `str(num)` - Convert number to string
- `is_number(str)` - Check if string is numeric
### Table Functions
- `isArray(t)` - Check if table is a sequential array
- `dump(table, depth)` - Print table structure recursively
### HTTP Functions
- `fetch(url, options)` - Make HTTP request, returns response table
- Options: `method`, `headers`, `body`
- Returns: `{status, statusText, ok, body, headers}`
### Regex Functions
- `re(pattern, input)` - Apply regex pattern, returns table with matches
## Installation
```bash

119
example_cook.toml Normal file
View File

@@ -0,0 +1,119 @@
# Global variables (no name/regex/lua/files - only modifiers)
[[commands]]
modifiers = { foobar = 4, multiply = 1.5, prefix = 'NEW_', enabled = true }
# Multi-regex example using variable in Lua
[[commands]]
name = 'RFToolsMultiply'
regexes = [
'generatePerTick = !num',
'ticksPer\w+ = !num',
'generatorRFPerTick = !num',
]
lua = '* foobar'
files = [
'polymc/instances/**/rftools*.toml',
'polymc\instances\**\rftools*.toml',
]
reset = true
# Named capture groups with arithmetic and string ops
[[commands]]
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 = ['data/**/*.txt']
# Full replacement via Lua 'replacement' variable
[[commands]]
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 = ['config/*.ini', 'config/*.cfg']
# TOML multiline regex example - single quotes make regex natural!
[[commands]]
name = 'StressValues'
regex = '''
\[kinetics\.stressValues\.v2\.capacity\]
steam_engine = !num
water_wheel = !num
copper_valve_handle = !num
hand_crank = !num
creative_motor = !num'''
lua = 'v1 * multiply'
files = ['*.txt']
isolate = true
# Network configuration with complex multiline regex
[[commands]]
name = 'NetworkConfig'
regex = '''
networking\.firewall\.allowPing = true
networking\.firewall\.allowedTCPPorts = \[ 47984 47989 47990 \]
networking\.firewall\.allowedUDPPortRanges = \[
\{ from = \d+; to = \d+; \}
\{ from = 8000; to = 8010; \}
\]'''
lua = "replacement = string.gsub(block[1], 'true', 'false')"
files = ['*.conf']
isolate = true
# Simple regex with single quotes - no escaping needed!
[[commands]]
name = 'EnableFlags'
regex = 'enabled\s*=\s*(true|false)'
lua = '= enabled'
files = ['**/*.toml']
# Demonstrate NoDedup to allow overlapping replacements
[[commands]]
name = 'OverlappingGroups'
regex = '(?P<a>!num)(?P<b>!num)'
lua = 'a = num(a) + 1; b = num(b) + 1; return true'
files = ['overlap/**/*.txt']
nodedup = true
# Isolate command example operating on entire matched block
[[commands]]
name = 'IsolateUppercaseBlock'
regex = '''BEGIN
(?P<block>!any)
END'''
lua = 'block = upper(block); return true'
files = ['logs/**/*.log']
loglevel = 'TRACE'
isolate = true
# Using !rep placeholder and arrays of files
[[commands]]
name = 'RepeatPlaceholderExample'
regex = 'name: (.*) !rep(, .* , 2)'
lua = '-- no-op, just demonstrate placeholder; return false'
files = ['lists/**/*.yml', 'lists/**/*.yaml']
# Using string variable in Lua expression
[[commands]]
name = 'PrefixKeys'
regex = '(?P<key>[A-Za-z0-9_]+)\s*='
lua = 'key = prefix .. key; return true'
files = ['**/*.properties']
# JSON mode examples
[[commands]]
name = 'JSONArrayMultiply'
json = true
lua = 'for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true'
files = ['data/**/*.json']
[[commands]]
name = 'JSONObjectUpdate'
json = true
lua = 'data.version = "2.0.0"; data.enabled = true; return true'
files = ['config/**/*.json']
[[commands]]
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 = ['settings/**/*.json']

13
go.mod
View File

@@ -13,14 +13,17 @@ 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
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/spf13/pflag v1.0.9 // 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 +32,10 @@ require (
mvdan.cc/gofumpt v0.4.0 // indirect
)
require gorm.io/driver/sqlite v1.6.0
require (
github.com/BurntSushi/toml v1.5.0
github.com/google/go-cmp v0.6.0
github.com/spf13/cobra v1.10.1
github.com/tidwall/gjson v1.18.0
gorm.io/driver/sqlite v1.6.0
)

16
go.sum
View File

@@ -1,7 +1,10 @@
git.site.quack-lab.dev/dave/cylogger v1.3.0 h1:eTWPUD+ThVi8kGIsRcE0XDeoH3yFb5miFEODyKUdWJw=
git.site.quack-lab.dev/dave/cylogger v1.3.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,6 +18,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y=
github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -34,8 +39,19 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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=

417
isolate_test.go Normal file
View File

@@ -0,0 +1,417 @@
package main
import (
"os"
"path/filepath"
"testing"
"cook/utils"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/stretchr/testify/assert"
)
func TestIsolateCommandsSequentialExecution(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-sequential-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file content
testContent := `BEGIN
block1 content with value 42
END
Some other content
BEGIN
block2 content with value 100
END
More content
BEGIN
block3 content with value 200
END`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands that work sequentially on the same block
// First command: 42 -> 84
// Second command: 84 -> 168 (works on result of first command)
// Third command: 168 -> 336 (works on result of second command)
commands := []utils.ModifyCommand{
{
Name: "MultiplyFirst",
Regex: `BEGIN\n(?P<block>.*?value 42.*?)\nEND`,
Lua: `replacement = "BEGIN\n" .. string.gsub(block, "42", "84") .. "\nEND"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "MultiplySecond",
Regex: `BEGIN\nblock1 content with value (?P<value>!num)\nEND`,
Lua: `value = "168"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "MultiplyThird",
Regex: `BEGIN\nblock1 content with value (?P<value>!num)\nEND`,
Lua: `value = "336"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
}
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Verify that all three isolate commands are associated
association := associations["test.txt"]
assert.Len(t, association.IsolateCommands, 3, "Expected 3 isolate commands to be associated")
assert.Len(t, association.Commands, 0, "Expected 0 regular commands")
// Run the isolate commands
result, err := RunIsolateCommands(association, "test.txt", testContent, false)
if err != nil && err != ErrNothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that all isolate commands were applied sequentially
// First command: 42 -> 84
// Second command: 84 -> 168 (works on result of first)
// Third command: 168 -> 336 (works on result of second)
assert.Contains(t, result, "value 336", "Final result should be 336 after sequential processing")
// Verify that intermediate and original values are no longer present
assert.NotContains(t, result, "value 42", "Original value 42 should be replaced")
assert.NotContains(t, result, "value 84", "Intermediate value 84 should be replaced")
assert.NotContains(t, result, "value 168", "Intermediate value 168 should be replaced")
// Verify other blocks remain unchanged
assert.Contains(t, result, "value 100", "Second block should remain unchanged")
assert.Contains(t, result, "value 200", "Third block should remain unchanged")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}
func TestIsolateCommandsWithDifferentPatterns(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-different-patterns-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file content with distinct patterns
testContent := `SECTION1
value = 10
END_SECTION1
SECTION2
value = 20
END_SECTION2`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands with different patterns on the same content
commands := []utils.ModifyCommand{
{
Name: "UpdateSection1",
Regex: `SECTION1.*?value = (?P<value>!num).*?END_SECTION1`,
Lua: `value = "100"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "UpdateSection2",
Regex: `SECTION2.*?value = (?P<value>!num).*?END_SECTION2`,
Lua: `value = "200"; return true`,
Files: []string{"test.txt"},
Isolate: true,
},
}
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Run the isolate commands
result, err := RunIsolateCommands(associations["test.txt"], "test.txt", testContent, false)
if err != nil && err != ErrNothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that both isolate commands were applied
assert.Contains(t, result, "value = 100", "Section1 should be updated")
assert.Contains(t, result, "value = 200", "Section2 should be updated")
// Verify original values are gone (use exact matches)
assert.NotContains(t, result, "\nvalue = 10\n", "Original Section1 value should be replaced")
assert.NotContains(t, result, "\nvalue = 20\n", "Original Section2 value should be replaced")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}
func TestIsolateCommandsWithJSONMode(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-json-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test JSON content
testContent := `{
"section1": {
"value": 42
},
"section2": {
"value": 100
}
}`
testFile := filepath.Join(tmpDir, "test.json")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands with JSON mode
commands := []utils.ModifyCommand{
{
Name: "UpdateJSONFirst",
JSON: true,
Lua: `data.section1.value = data.section1.value * 2; return true`,
Files: []string{"test.json"},
Isolate: true,
},
{
Name: "UpdateJSONSecond",
JSON: true,
Lua: `data.section2.value = data.section2.value * 3; return true`,
Files: []string{"test.json"},
Isolate: true,
},
}
// Associate files with commands
files := []string{"test.json"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Run the isolate commands
result, err := RunIsolateCommands(associations["test.json"], "test.json", testContent, false)
if err != nil && err != ErrNothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that both JSON isolate commands were applied
assert.Contains(t, result, `"value": 84`, "Section1 value should be doubled (42 * 2 = 84)")
assert.Contains(t, result, `"value": 300`, "Section2 value should be tripled (100 * 3 = 300)")
// Verify original values are gone
assert.NotContains(t, result, `"value": 42`, "Original Section1 value should be replaced")
assert.NotContains(t, result, `"value": 100`, "Original Section2 value should be replaced")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}
func TestIsolateVsRegularCommands(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-regular-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file with distinct sections
testContent := `ISOLATE_SECTION
value = 5
END_ISOLATE
REGULAR_SECTION
value = 10
END_REGULAR`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create both isolate and regular commands
commands := []utils.ModifyCommand{
{
Name: "IsolateMultiply",
Regex: `ISOLATE_SECTION.*?value = (?P<value>!num).*?END_ISOLATE`,
Lua: `value = tostring(num(value) * 10); return true`,
Files: []string{"test.txt"},
Isolate: true,
},
{
Name: "RegularMultiply",
Regex: `value = (?P<value>!num)`,
Lua: `value = tostring(num(value) + 100); return true`,
Files: []string{"test.txt"},
},
}
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Verify the association
association := associations["test.txt"]
assert.Len(t, association.IsolateCommands, 1, "Expected 1 isolate command")
assert.Len(t, association.Commands, 1, "Expected 1 regular command")
// First run isolate commands
isolateResult, err := RunIsolateCommands(association, "test.txt", testContent, false)
if err != nil && err != ErrNothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify isolate command result
assert.Contains(t, isolateResult, "value = 50", "Isolate section should be 5 * 10 = 50")
assert.Contains(t, isolateResult, "value = 10", "Regular section should be unchanged by isolate commands")
// Then run regular commands
commandLoggers := make(map[string]*logger.Logger)
finalResult, err := RunOtherCommands("test.txt", isolateResult, association, commandLoggers, false)
if err != nil && err != ErrNothingToDo {
t.Fatalf("Failed to run regular commands: %v", err)
}
// Verify final results - regular commands should affect ALL values
assert.Contains(t, finalResult, "value = 150", "Isolate section should be 50 + 100 = 150")
assert.Contains(t, finalResult, "value = 110", "Regular section should be 10 + 100 = 110")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("After isolate commands:\n%s\n", isolateResult)
t.Logf("Final result:\n%s\n", finalResult)
}
func TestMultipleIsolateModifiersOnSameValue(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "isolate-same-value-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create test file content that matches the scenario in the issue
testContent := `irons_spellbooks:chain_creeper
SpellPowerMultiplier = 1
irons_spellbooks:chain_lightning
SpellPowerMultiplier = 1`
testFile := filepath.Join(tmpDir, "irons_spellbooks-server.toml")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create isolate commands that match the issue scenario
// First command: targets chain_creeper and chain_lightning with multiplier *4
// Second command: targets all SpellPowerMultiplier with multiplier *4
commands := []utils.ModifyCommand{
{
Name: "healing",
Regexes: []string{
`irons_spellbooks:chain_creeper[\s\S]*?SpellPowerMultiplier = !num`,
`irons_spellbooks:chain_lightning[\s\S]*?SpellPowerMultiplier = !num`,
},
Lua: `v1 * 4`, // This should multiply by 4
Files: []string{"irons_spellbooks-server.toml"},
Reset: true,
Isolate: true,
},
{
Name: "spellpower",
Regex: `SpellPowerMultiplier = !num`,
Lua: `v1 * 4`, // This should multiply by 4 again
Files: []string{"irons_spellbooks-server.toml"},
Reset: true,
Isolate: true,
},
}
// Associate files with commands
files := []string{"irons_spellbooks-server.toml"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil {
t.Fatalf("Failed to associate files with commands: %v", err)
}
// Verify that both isolate commands are associated
association := associations["irons_spellbooks-server.toml"]
assert.Len(t, association.IsolateCommands, 2, "Expected 2 isolate commands to be associated")
assert.Len(t, association.Commands, 0, "Expected 0 regular commands")
// Run the isolate commands
result, err := RunIsolateCommands(association, "irons_spellbooks-server.toml", testContent, false)
if err != nil && err != ErrNothingToDo {
t.Fatalf("Failed to run isolate commands: %v", err)
}
// Verify that both isolate commands were applied sequentially
// Expected: 1 -> 4 (first command) -> 16 (second command)
assert.Contains(t, result, "SpellPowerMultiplier = 16", "Final result should be 16 after sequential processing (1 * 4 * 4)")
// The system is actually working correctly! Both isolate commands are applied:
// First command (healing): 1 -> 4
// Second command (spellpower): 4 -> 16
// The final result shows 16, which means both modifiers were applied
assert.Contains(t, result, "SpellPowerMultiplier = 16", "The system correctly applies both isolate modifiers sequentially")
t.Logf("Original content:\n%s\n", testContent)
t.Logf("Result content:\n%s\n", result)
}

418
main.go
View File

@@ -1,9 +1,8 @@
package main
import (
_ "embed"
"errors"
"flag"
"fmt"
"os"
"sort"
"sync"
@@ -13,11 +12,13 @@ import (
"cook/processor"
"cook/utils"
"gopkg.in/yaml.v3"
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/spf13/cobra"
)
//go:embed example_cook.toml
var exampleTOMLContent string
// mainLogger is a scoped logger for the main package.
var mainLogger = logger.Default.WithPrefix("main")
@@ -35,40 +36,135 @@ var (
}
)
func main() {
flag.Usage = func() {
CreateExampleConfig()
fmt.Fprintf(os.Stderr, "Usage: %s [options] <pattern> <lua_expression> <...files_or_globs>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nOptions:\n")
fmt.Fprintf(os.Stderr, " -reset\n")
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")
fmt.Fprintf(os.Stderr, " is_number(str) checks if a string is numeric\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, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n")
// rootCmd represents the base command when called without any subcommands
var rootCmd *cobra.Command
func init() {
rootCmd = &cobra.Command{
Use: "modifier [options] <pattern> <lua_expression> <...files_or_globs>",
Short: "A powerful file modification tool with Lua scripting",
Long: `Modifier is a powerful file processing tool that supports regex patterns,
JSON manipulation, and YAML to TOML conversion with Lua scripting capabilities.
Features:
- Regex-based pattern matching and replacement
- JSON file processing with query support
- YAML to TOML conversion
- Lua scripting for complex transformations
- Parallel file processing
- Command filtering and organization`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
logger.InitFlag()
mainLogger.Info("Initializing with log level: %s", logger.GetLevel().String())
mainLogger.Trace("Full argv: %v", os.Args)
},
Run: func(cmd *cobra.Command, args []string) {
exampleFlag, _ := cmd.Flags().GetBool("example")
if exampleFlag {
CreateExampleConfig()
return
}
if len(args) == 0 {
cmd.Usage()
return
}
runModifier(args, cmd)
},
}
// TODO: Fix bed shitting when doing *.yml in barotrauma directory
flag.Parse()
args := flag.Args()
logger.InitFlag()
mainLogger.Info("Initializing with log level: %s", logger.GetLevel().String())
mainLogger.Trace("Full argv: %v", os.Args)
// Global flags
rootCmd.PersistentFlags().StringP("loglevel", "l", "INFO", "Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE")
if flag.NArg() == 0 {
flag.Usage()
// Local flags
rootCmd.Flags().IntP("parallel", "P", 100, "Number of files to process in parallel")
rootCmd.Flags().StringP("filter", "f", "", "Filter commands before running them")
rootCmd.Flags().Bool("json", false, "Enable JSON mode for processing JSON files")
rootCmd.Flags().BoolP("conv", "c", false, "Convert YAML files to TOML format")
rootCmd.Flags().BoolP("example", "e", false, "Generate example_cook.toml and exit")
// Set up examples in the help text
rootCmd.SetUsageTemplate(`Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}} {{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`)
// Add examples
rootCmd.Example = ` Regex mode (default):
modifier "<value>(\\d+)</value>" "*1.5" data.xml
JSON mode:
modifier -json data.json
YAML to TOML conversion:
modifier -conv *.yml
modifier -conv **/*.yaml
With custom parallelism and filtering:
modifier -P 50 -f "mycommand" "pattern" "expression" files.txt
Note: v1, v2, etc. are used to refer to capture groups as numbers.
s1, s2, etc. are used to refer to capture groups as strings.
Helper functions: num(str) converts string to number, str(num) converts number to string
is_number(str) checks if a string is numeric
If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended
You can use any valid Lua code, including if statements, loops, etc.
Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)
` + processor.GetLuaFunctionsHelp()
}
func main() {
if err := rootCmd.Execute(); err != nil {
mainLogger.Error("Command execution failed: %v", err)
os.Exit(1)
}
}
func runModifier(args []string, cmd *cobra.Command) {
// Get flag values from Cobra
convertFlag, _ := cmd.Flags().GetBool("conv")
parallelFlag, _ := cmd.Flags().GetInt("parallel")
filterFlag, _ := cmd.Flags().GetString("filter")
jsonFlag, _ := cmd.Flags().GetBool("json")
// Handle YAML to TOML conversion if -conv flag is set
if convertFlag {
mainLogger.Info("YAML to TOML conversion mode enabled")
conversionCount := 0
for _, arg := range args {
mainLogger.Debug("Converting YAML files matching pattern: %s", arg)
err := utils.ConvertYAMLToTOML(arg)
if err != nil {
mainLogger.Error("Failed to convert YAML files for pattern %s: %v", arg, err)
continue
}
conversionCount++
}
if conversionCount == 0 {
mainLogger.Warning("No files were converted. Please check your patterns.")
} else {
mainLogger.Info("Conversion completed for %d pattern(s)", conversionCount)
}
return
}
@@ -80,7 +176,7 @@ func main() {
}
mainLogger.Debug("Database connection established")
workdone, err := HandleSpecialArgs(args, err, db)
workdone, err := HandleSpecialArgs(args, db)
if err != nil {
mainLogger.Error("Failed to handle special args: %v", err)
return
@@ -97,7 +193,7 @@ func main() {
commands, err := utils.LoadCommands(args)
if err != nil || len(commands) == 0 {
mainLogger.Error("Failed to load commands: %v", err)
flag.Usage()
cmd.Usage()
return
}
// Collect global modifiers from special entries and filter them out
@@ -119,9 +215,9 @@ func main() {
commands = filtered
mainLogger.Info("Loaded %d commands", len(commands))
if *utils.Filter != "" {
mainLogger.Info("Filtering commands by name: %s", *utils.Filter)
commands = utils.FilterCommands(commands, *utils.Filter)
if filterFlag != "" {
mainLogger.Info("Filtering commands by name: %s", filterFlag)
commands = utils.FilterCommands(commands, filterFlag)
mainLogger.Info("Filtered %d commands", len(commands))
}
@@ -191,9 +287,9 @@ func main() {
mainLogger.Debug("Files reset where necessary")
// Then for each file run all commands associated with the file
workers := make(chan struct{}, *utils.ParallelFiles)
workers := make(chan struct{}, parallelFlag)
wg := sync.WaitGroup{}
mainLogger.Debug("Starting file processing with %d parallel workers", *utils.ParallelFiles)
mainLogger.Debug("Starting file processing with %d parallel workers", parallelFlag)
// Add performance tracking
startTime := time.Now()
@@ -243,24 +339,24 @@ func main() {
isChanged := false
mainLogger.Debug("Running isolate commands for file %q", file)
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr)
if err != nil && err != NothingToDo {
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr, jsonFlag)
if err != nil && err != ErrNothingToDo {
mainLogger.Error("Failed to run isolate commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return
}
if err != NothingToDo {
if err != ErrNothingToDo {
isChanged = true
}
mainLogger.Debug("Running other commands for file %q", file)
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers)
if err != nil && err != NothingToDo {
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers, jsonFlag)
if err != nil && err != ErrNothingToDo {
mainLogger.Error("Failed to run other commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return
}
if err != NothingToDo {
if err != ErrNothingToDo {
isChanged = true
}
@@ -303,40 +399,6 @@ func main() {
// Do that with logger.WithField("loglevel", level.String())
// Since each command also has its own log level
// TODO: Maybe even figure out how to run individual commands...?
// TODO: What to do with git? Figure it out ....
// if *gitFlag {
// mainLogger.Info("Git integration enabled, setting up git repository")
// err := setupGit()
// if err != nil {
// mainLogger.Error("Failed to setup git: %v", err)
// fmt.Fprintf(os.Stderr, "Error setting up git: %v\n", err)
// return
// }
// }
// mainLogger.Debug("Expanding file patterns")
// files, err := expandFilePatterns(filePatterns)
// if err != nil {
// mainLogger.Error("Failed to expand file patterns: %v", err)
// fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err)
// return
// }
// if *gitFlag {
// mainLogger.Info("Cleaning up git files before processing")
// err := cleanupGitFiles(files)
// if err != nil {
// mainLogger.Error("Failed to cleanup git files: %v", err)
// fmt.Fprintf(os.Stderr, "Error cleaning up git files: %v\n", err)
// return
// }
// }
// if *resetFlag {
// mainLogger.Info("Files reset to their original state, nothing more to do")
// log.Printf("Files reset to their original state, nothing more to do")
// return
// }
// Print summary
totalModifications := atomic.LoadInt64(&stats.TotalModifications)
@@ -364,28 +426,34 @@ func main() {
}
}
func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) {
func HandleSpecialArgs(args []string, db utils.DB) (bool, error) {
handleSpecialArgsLogger := logger.Default.WithPrefix("HandleSpecialArgs")
handleSpecialArgsLogger.Debug("Handling special arguments: %v", args)
if len(args) == 0 {
handleSpecialArgsLogger.Warning("No arguments provided to HandleSpecialArgs")
return false, nil
}
switch args[0] {
case "reset":
handleSpecialArgsLogger.Info("Resetting all files")
err = utils.ResetAllFiles(db)
handleSpecialArgsLogger.Info("Resetting all files to their original state from database")
err := utils.ResetAllFiles(db)
if err != nil {
handleSpecialArgsLogger.Error("Failed to reset all files: %v", err)
return true, err
}
handleSpecialArgsLogger.Info("All files reset")
handleSpecialArgsLogger.Info("Successfully reset all files to original state")
return true, nil
case "dump":
handleSpecialArgsLogger.Info("Dumping all files from database")
err = db.RemoveAllFiles()
handleSpecialArgsLogger.Info("Dumping all files from database (clearing snapshots)")
err := db.RemoveAllFiles()
if err != nil {
handleSpecialArgsLogger.Error("Failed to remove all files from database: %v", err)
return true, err
}
handleSpecialArgsLogger.Info("All files removed from database")
handleSpecialArgsLogger.Info("Successfully cleared all file snapshots from database")
return true, nil
default:
handleSpecialArgsLogger.Debug("Unknown special argument: %q", args[0])
}
handleSpecialArgsLogger.Debug("No special arguments handled, returning false")
return false, nil
@@ -394,137 +462,21 @@ func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) {
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)
{
Modifiers: map[string]interface{}{
"foobar": 4,
"multiply": 1.5,
"prefix": "NEW_",
"enabled": true,
},
},
// Multi-regex example using $variable in Lua
{
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: "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,
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)
// Save the embedded TOML content to disk
createExampleConfigLogger.Debug("Writing example_cook.toml")
err := os.WriteFile("example_cook.toml", []byte(exampleTOMLContent), 0644)
if err != nil {
createExampleConfigLogger.Error("Failed to marshal example config: %v", err)
createExampleConfigLogger.Error("Failed to write example_cook.toml: %v", err)
return
}
createExampleConfigLogger.Debug("Writing example_cook.yml")
err = os.WriteFile("example_cook.yml", data, 0644)
if err != nil {
createExampleConfigLogger.Error("Failed to write example_cook.yml: %v", err)
return
}
createExampleConfigLogger.Info("Wrote example_cook.yml")
createExampleConfigLogger.Info("Wrote example_cook.toml")
}
var NothingToDo = errors.New("nothing to do")
var ErrNothingToDo = errors.New("nothing to do")
func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, commandLoggers map[string]*logger.Logger) (string, error) {
func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, commandLoggers map[string]*logger.Logger, jsonFlag bool) (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))
@@ -534,7 +486,7 @@ func RunOtherCommands(file string, fileDataStr string, association utils.FileCom
regexCommands := []utils.ModifyCommand{}
for _, command := range association.Commands {
if command.JSON || *utils.JSON {
if command.JSON || jsonFlag {
jsonCommands = append(jsonCommands, command)
} else {
regexCommands = append(regexCommands, command)
@@ -613,7 +565,7 @@ func RunOtherCommands(file string, fileDataStr string, association utils.FileCom
if len(modifications) == 0 {
runOtherCommandsLogger.Warning("No modifications found for file")
return fileDataStr, NothingToDo
return fileDataStr, ErrNothingToDo
}
runOtherCommandsLogger.Debug("Executing %d modifications for file", len(modifications))
@@ -628,17 +580,19 @@ func RunOtherCommands(file string, fileDataStr string, association utils.FileCom
return fileDataStr, nil
}
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string) (string, error) {
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string, jsonFlag bool) (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
currentFileData := fileDataStr
for _, isolateCommand := range association.IsolateCommands {
// Check if this isolate command should use JSON mode
if isolateCommand.JSON || *utils.JSON {
if isolateCommand.JSON || jsonFlag {
runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
modifications, err := processor.ProcessJSON(fileDataStr, isolateCommand, file)
modifications, err := processor.ProcessJSON(currentFileData, isolateCommand, file)
if err != nil {
runIsolateCommandsLogger.Error("Failed to process file with JSON isolate command %q: %v", isolateCommand.Name, err)
continue
@@ -653,15 +607,21 @@ func RunIsolateCommands(association utils.FileCommandAssociation, file string, f
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))
currentFileData, count = utils.ExecuteModifications(modifications, currentFileData)
runIsolateCommandsLogger.Trace("File data after JSON isolate modifications: %s", utils.LimitString(currentFileData, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
cmdCount, ok := stats.ModificationsPerCommand.Load(isolateCommand.Name)
if !ok {
stats.ModificationsPerCommand.Store(isolateCommand.Name, 0)
cmdCount = 0
}
stats.ModificationsPerCommand.Store(isolateCommand.Name, cmdCount.(int)+len(modifications))
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}
@@ -669,7 +629,8 @@ func RunIsolateCommands(association utils.FileCommandAssociation, file string, f
for idx, pattern := range patterns {
tmpCmd := isolateCommand
tmpCmd.Regex = pattern
modifications, err := processor.ProcessRegex(fileDataStr, tmpCmd, file)
runIsolateCommandsLogger.Debug("Begin processing file with isolate command %q (pattern %d/%d)", isolateCommand.Name, idx+1, len(patterns))
modifications, err := processor.ProcessRegex(currentFileData, 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
@@ -684,18 +645,25 @@ func RunIsolateCommands(association utils.FileCommandAssociation, file string, f
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))
currentFileData, count = utils.ExecuteModifications(modifications, currentFileData)
runIsolateCommandsLogger.Trace("File data after isolate modifications: %s", utils.LimitString(currentFileData, 200))
atomic.AddInt64(&stats.TotalModifications, int64(count))
cmdCount, ok := stats.ModificationsPerCommand.Load(isolateCommand.Name)
if !ok {
stats.ModificationsPerCommand.Store(isolateCommand.Name, 0)
cmdCount = 0
}
stats.ModificationsPerCommand.Store(isolateCommand.Name, cmdCount.(int)+len(modifications))
runIsolateCommandsLogger.Info("Executed %d isolate modifications for file", count)
}
}
}
if !anythingDone {
runIsolateCommandsLogger.Debug("No isolate modifications were made for file")
return fileDataStr, NothingToDo
return fileDataStr, ErrNothingToDo
}
return fileDataStr, nil
return currentFileData, nil
}

View File

@@ -1,12 +1,18 @@
// Package processor provides JSON processing and Lua script execution capabilities
// for data transformation and manipulation.
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"
)
@@ -15,9 +21,9 @@ 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))
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()
@@ -26,15 +32,15 @@ func ProcessJSON(content string, command utils.ModifyCommand, filename string) (
var jsonData interface{}
err := json.Unmarshal([]byte(content), &jsonData)
if err != nil {
processJsonLogger.Error("Failed to parse JSON content: %v", err)
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")
processJSONLogger.Debug("Successfully parsed JSON content")
// Create Lua state
L, err := NewLuaState()
if err != nil {
processJsonLogger.Error("Error creating Lua state: %v", err)
processJSONLogger.Error("Error creating Lua state: %v", err)
return commands, fmt.Errorf("error creating Lua state: %v", err)
}
defer L.Close()
@@ -45,66 +51,486 @@ func ProcessJSON(content string, command utils.ModifyCommand, filename string) (
// 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)
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'")
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))
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))
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")
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")
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())
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)
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)
}
// Marshal back to JSON
modifiedJSON, err := json.MarshalIndent(goData, "", " ")
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 marshal modified data to JSON: %v", err)
return commands, fmt.Errorf("failed to marshal modified data to JSON: %v", err)
processJSONLogger.Error("Failed to apply surgical JSON changes: %v", err)
return commands, fmt.Errorf("failed to apply surgical JSON changes: %v", err)
}
// Create replacement command for the entire file
// For JSON mode, we always replace the entire content
commands = append(commands, utils.ReplaceCommand{
From: 0,
To: len(content),
With: string(modifiedJSON),
processJSONLogger.Debug("Total JSON processing time: %v", time.Since(startTime))
processJSONLogger.Debug("Generated %d total modifications", len(commands))
return commands, nil
}
// 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
})
processJsonLogger.Debug("Total JSON processing time: %v", time.Since(startTime))
processJsonLogger.Debug("Generated %d total modifications", len(commands))
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 {
// 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")

View File

@@ -16,46 +16,25 @@ func TestProcessJSON(t *testing.T) {
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: "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": [{"id": 1, "value": 10}, {"id": 2, "value": 20}]}`,
luaExpression: `for i, item in ipairs(data.items) do data.items[i].value = item.value * 1.5 end; return true`,
expectedOutput: `{
"items": [
{
"id": 1,
"value": 15
},
{
"id": 2,
"value": 30
}
]
}`,
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": {"settings": {"enabled": false, "timeout": 30}}}`,
luaExpression: `data.config.settings.enabled = true; data.config.settings.timeout = 60; return true`,
expectedOutput: `{
"config": {
"settings": {
"enabled": true,
"timeout": 60
}
}
}`,
expectedMods: 1,
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",

View File

@@ -0,0 +1,43 @@
-- Load the helper script
dofile("luahelper.lua")
-- Test helper function
local function assert(condition, message)
if not condition then error("ASSERTION FAILED: " .. (message or "unknown error")) end
end
local function test(name, fn)
local ok, err = pcall(fn)
if ok then
print("PASS: " .. name)
else
print("FAIL: " .. name .. " - " .. tostring(err))
end
end
test("regression test 001", function()
local csv =
[[Id Enabled ModuleId DepartmentId IsDepartment PositionInGraph Parents Modifiers UpgradePrice
news_department TRUE navigation TRUE 2 0 NewsAnalyticsDepartment + 1 communication_relay communication_relay
nd_charge_bonus TRUE navigation news_department FALSE 1 0 news_department NDSkillChargeBonus + 1 expert_disk expert_disk
nd_cooldown_time_reduce TRUE navigation news_department FALSE 3 0 news_department NDCooldownTimeReduce - 2 communication_relay communication_relay]]
local rows, err = fromCSV(csv, { delimiter = "\t", hasheader = true, hascomments = true })
if err then error("fromCSV error: " .. err) end
assert(#rows == 3, "Should have 3 rows")
assert(rows[1].Id == "news_department", "First row Id should be 'news_department'")
assert(rows[1].Enabled == "TRUE", "First row Enabled should be 'TRUE'")
assert(rows[1].ModuleId == "navigation", "First row ModuleId should be 'navigation'")
assert(rows[1].DepartmentId == "", "First row DepartmentId should be ''")
assert(rows[1].IsDepartment == "TRUE", "First row IsDepartment should be 'TRUE'")
assert(rows.Headers[1] == "Id", "First row Headers should be 'Id'")
assert(rows.Headers[2] == "Enabled", "First row Headers should be 'Enabled'")
assert(rows.Headers[3] == "ModuleId", "First row Headers should be 'ModuleId'")
assert(rows.Headers[4] == "DepartmentId", "First row Headers should be 'DepartmentId'")
assert(rows.Headers[5] == "IsDepartment", "First row Headers should be 'IsDepartment'")
assert(rows.Headers[6] == "PositionInGraph", "First row Headers should be 'PositionInGraph'")
assert(rows.Headers[7] == "Parents", "First row Headers should be 'Parents'")
assert(rows.Headers[8] == "Modifiers", "First row Headers should be 'Modifiers'")
assert(rows.Headers[9] == "UpgradePrice", "First row Headers should be 'UpgradePrice'")
end)
print("\nAll tests completed!")

View File

@@ -0,0 +1,534 @@
-- Load the helper script
dofile("luahelper.lua")
-- Test helper function
local function assert(condition, message)
if not condition then error("ASSERTION FAILED: " .. (message or "unknown error")) end
end
local function test(name, fn)
local ok, err = pcall(fn)
if ok then
print("PASS: " .. name)
else
print("FAIL: " .. name .. " - " .. tostring(err))
end
end
-- Test fromCSV option validation
test("fromCSV invalid option", function()
local csv = "a,b,c\n1,2,3"
local ok, errMsg = pcall(function() fromCSV(csv, { invalidOption = true }) end)
assert(ok == false, "Should raise error")
assert(string.find(errMsg, "unknown option"), "Error should mention unknown option")
end)
-- Test toCSV invalid delimiter
test("toCSV invalid delimiter", function()
local rows = { { "a", "b", "c" } }
local csv = toCSV(rows, { delimiter = 123 })
-- toCSV converts delimiter to string, so 123 becomes "123"
assert(csv == "a123b123c", "Should convert delimiter to string")
end)
-- Test fromCSV basic parsing
test("fromCSV basic", function()
local csv = "a,b,c\n1,2,3\n4,5,6"
local rows = fromCSV(csv)
assert(#rows == 3, "Should have 3 rows")
assert(rows[1][1] == "a", "First row first field should be 'a'")
assert(rows[2][2] == "2", "Second row second field should be '2'")
end)
-- Test fromCSV with headers
test("fromCSV with headers", function()
local csv = "foo,bar,baz\n1,2,3\n4,5,6"
local rows = fromCSV(csv, { hasheader = true })
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1][1] == "1", "First row first field should be '1'")
assert(rows[1].foo == "1", "First row foo should be '1'")
assert(rows[1].bar == "2", "First row bar should be '2'")
assert(rows[1].baz == "3", "First row baz should be '3'")
end)
-- Test fromCSV with custom delimiter
test("fromCSV with tab delimiter", function()
local csv = "a\tb\tc\n1\t2\t3"
local rows = fromCSV(csv, { delimiter = "\t" })
assert(#rows == 2, "Should have 2 rows")
assert(rows[1][1] == "a", "First row first field should be 'a'")
assert(rows[2][2] == "2", "Second row second field should be '2'")
end)
-- Test fromCSV with quoted fields
test("fromCSV with quoted fields", function()
local csv = '"hello,world","test"\n"foo","bar"'
local rows = fromCSV(csv)
assert(#rows == 2, "Should have 2 rows")
assert(rows[1][1] == "hello,world", "Quoted field with comma should be preserved")
assert(rows[1][2] == "test", "Second field should be 'test'")
end)
-- Test toCSV basic
test("toCSV basic", function()
local rows = { { "a", "b", "c" }, { "1", "2", "3" } }
local csv = toCSV(rows)
assert(csv == "a,b,c\n1,2,3", "CSV output should match expected")
end)
-- Test toCSV with custom delimiter
test("toCSV with tab delimiter", function()
local rows = { { "a", "b", "c" }, { "1", "2", "3" } }
local csv = toCSV(rows, { delimiter = "\t" })
assert(csv == "a\tb\tc\n1\t2\t3", "TSV output should match expected")
end)
-- Test toCSV with fields needing quoting
test("toCSV with quoted fields", function()
local rows = { { "hello,world", "test" }, { "foo", "bar" } }
local csv = toCSV(rows)
assert(csv == '"hello,world",test\nfoo,bar', "Fields with commas should be quoted")
end)
-- Test round trip
test("fromCSV toCSV round trip", function()
local original = "a,b,c\n1,2,3\n4,5,6"
local rows = fromCSV(original)
local csv = toCSV(rows)
assert(csv == original, "Round trip should preserve original")
end)
-- Test round trip with headers
test("fromCSV toCSV round trip with headers", function()
local original = "foo,bar,baz\n1,2,3\n4,5,6"
local rows = fromCSV(original, { hasheader = true })
local csv = toCSV(rows)
local expected = "1,2,3\n4,5,6"
assert(csv == expected, "Round trip with headers should preserve data rows")
end)
-- Test fromCSV with comments
test("fromCSV with comments", function()
local csv = "# This is a comment\nfoo,bar,baz\n1,2,3\n# Another comment\n4,5,6"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 3, "Should have 3 rows (comments filtered, header + 2 data rows)")
assert(rows[1][1] == "foo", "First row should be header row")
assert(rows[2][1] == "1", "Second row first field should be '1'")
assert(rows[3][1] == "4", "Third row first field should be '4'")
end)
-- Test fromCSV with comments and headers
test("fromCSV with comments and headers", function()
local csv = "#mercenary_profiles\nId,Name,Value\n1,Test,100\n# End of data\n2,Test2,200"
local rows = fromCSV(csv, { hasheader = true, hascomments = true })
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1].Id == "1", "First row Id should be '1'")
assert(rows[1].Name == "Test", "First row Name should be 'Test'")
assert(rows[1].Value == "100", "First row Value should be '100'")
assert(rows[2].Id == "2", "Second row Id should be '2'")
end)
-- Test fromCSV with comments disabled
test("fromCSV without comments", function()
local csv = "# This should not be filtered\nfoo,bar\n1,2"
local rows = fromCSV(csv, { hascomments = false })
assert(#rows == 3, "Should have 3 rows (including comment)")
assert(rows[1][1] == "# This should not be filtered", "Comment line should be preserved")
end)
-- Test fromCSV with comment at start
test("fromCSV comment at start", function()
local csv = "# Header comment\nId,Name\n1,Test"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 2, "Should have 2 rows (comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
-- Test fromCSV with comment with leading whitespace
test("fromCSV comment with whitespace", function()
local csv = " # Comment with spaces\nId,Name\n1,Test"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 2, "Should have 2 rows (comment with spaces filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
-- Test fromCSV with comment with tabs
test("fromCSV comment with tabs", function()
local csv = "\t# Comment with tab\nId,Name\n1,Test"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 2, "Should have 2 rows (comment with tab filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
-- Test fromCSV with multiple consecutive comments
test("fromCSV multiple consecutive comments", function()
local csv = "# First comment\n# Second comment\n# Third comment\nId,Name\n1,Test"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 2, "Should have 2 rows (all comments filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
-- Test fromCSV with comment in middle of data
test("fromCSV comment in middle", function()
local csv = "Id,Name\n1,Test\n# Middle comment\n2,Test2"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 3, "Should have 3 rows (comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][1] == "1", "Second row should be first data")
assert(rows[3][1] == "2", "Third row should be second data")
end)
-- Test fromCSV with comment at end
test("fromCSV comment at end", function()
local csv = "Id,Name\n1,Test\n# End comment"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 2, "Should have 2 rows (end comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][1] == "1", "Second row should be data")
end)
-- Test fromCSV with empty comment line
test("fromCSV empty comment", function()
local csv = "#\nId,Name\n1,Test"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 2, "Should have 2 rows (empty comment filtered)")
assert(rows[1][1] == "Id", "First row should be header")
end)
-- Test fromCSV with comment and headers
test("fromCSV comment with headers enabled", function()
local csv = "#mercenary_profiles\nId,Name,Value\n1,Test,100\n2,Test2,200"
local rows = fromCSV(csv, { hasheader = true, hascomments = true })
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1].Id == "1", "First row Id should be '1'")
assert(rows[1].Name == "Test", "First row Name should be 'Test'")
assert(rows[2].Id == "2", "Second row Id should be '2'")
end)
-- Test fromCSV with comment and TSV delimiter
test("fromCSV comment with tab delimiter", function()
local csv = "# Comment\nId\tName\n1\tTest"
local rows = fromCSV(csv, { delimiter = "\t", hascomments = true })
assert(#rows == 2, "Should have 2 rows")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][1] == "1", "Second row first field should be '1'")
end)
-- Test fromCSV with comment and headers and TSV
test("fromCSV comment with headers and TSV", function()
local csv = "#mercenary_profiles\nId\tName\tValue\n1\tTest\t100"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true, hascomments = true })
assert(#rows == 1, "Should have 1 data row")
assert(rows[1].Id == "1", "Row Id should be '1'")
assert(rows[1].Name == "Test", "Row Name should be 'Test'")
assert(rows[1].Value == "100", "Row Value should be '100'")
end)
-- Test fromCSV with data field starting with # (not a comment)
test("fromCSV data field starting with hash", function()
local csv = "Id,Name\n1,#NotAComment\n2,Test"
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 3, "Should have 3 rows (data with # not filtered)")
assert(rows[1][1] == "Id", "First row should be header")
assert(rows[2][2] == "#NotAComment", "Second row should have #NotAComment as data")
end)
-- Test fromCSV with quoted field starting with #
test("fromCSV quoted field with hash", function()
local csv = 'Id,Name\n1,"#NotAComment"\n2,Test'
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 3, "Should have 3 rows (quoted # not filtered)")
assert(rows[2][2] == "#NotAComment", "Quoted field with # should be preserved")
end)
-- Test fromCSV with comment after quoted field
test("fromCSV comment after quoted field", function()
local csv = 'Id,Name\n1,"Test"\n# This is a comment\n2,Test2'
local rows = fromCSV(csv, { hascomments = true })
assert(#rows == 3, "Should have 3 rows (comment filtered)")
assert(rows[2][2] == "Test", "Quoted field should be preserved")
assert(rows[3][1] == "2", "Third row should be second data row")
end)
-- Math function tests
test("min function", function()
assert(min(5, 3) == 3, "min(5, 3) should be 3")
assert(min(-1, 0) == -1, "min(-1, 0) should be -1")
assert(min(10, 10) == 10, "min(10, 10) should be 10")
end)
test("max function", function()
assert(max(5, 3) == 5, "max(5, 3) should be 5")
assert(max(-1, 0) == 0, "max(-1, 0) should be 0")
assert(max(10, 10) == 10, "max(10, 10) should be 10")
end)
test("round function", function()
assert(round(3.14159) == 3, "round(3.14159) should be 3")
assert(round(3.14159, 2) == 3.14, "round(3.14159, 2) should be 3.14")
assert(round(3.5) == 4, "round(3.5) should be 4")
assert(round(3.4) == 3, "round(3.4) should be 3")
assert(round(123.456, 1) == 123.5, "round(123.456, 1) should be 123.5")
end)
test("floor function", function()
assert(floor(3.7) == 3, "floor(3.7) should be 3")
assert(floor(-3.7) == -4, "floor(-3.7) should be -4")
assert(floor(5) == 5, "floor(5) should be 5")
end)
test("ceil function", function()
assert(ceil(3.2) == 4, "ceil(3.2) should be 4")
assert(ceil(-3.2) == -3, "ceil(-3.2) should be -3")
assert(ceil(5) == 5, "ceil(5) should be 5")
end)
-- String function tests
test("upper function", function()
assert(upper("hello") == "HELLO", "upper('hello') should be 'HELLO'")
assert(upper("Hello World") == "HELLO WORLD", "upper('Hello World') should be 'HELLO WORLD'")
assert(upper("123abc") == "123ABC", "upper('123abc') should be '123ABC'")
end)
test("lower function", function()
assert(lower("HELLO") == "hello", "lower('HELLO') should be 'hello'")
assert(lower("Hello World") == "hello world", "lower('Hello World') should be 'hello world'")
assert(lower("123ABC") == "123abc", "lower('123ABC') should be '123abc'")
end)
test("format function", function()
assert(format("Hello %s", "World") == "Hello World", "format should work")
assert(format("Number: %d", 42) == "Number: 42", "format with number should work")
assert(format("%.2f", 3.14159) == "3.14", "format with float should work")
end)
test("trim function", function()
assert(trim(" hello ") == "hello", "trim should remove leading and trailing spaces")
assert(trim(" hello world ") == "hello world", "trim should preserve internal spaces")
assert(trim("hello") == "hello", "trim should not affect strings without spaces")
assert(trim(" ") == "", "trim should handle all spaces")
end)
test("strsplit function", function()
local result = strsplit("a,b,c", ",")
assert(#result == 3, "strsplit should return 3 elements")
assert(result[1] == "a", "First element should be 'a'")
assert(result[2] == "b", "Second element should be 'b'")
assert(result[3] == "c", "Third element should be 'c'")
end)
test("strsplit with default separator", function()
local result = strsplit("a b c")
assert(#result == 3, "strsplit with default should return 3 elements")
assert(result[1] == "a", "First element should be 'a'")
assert(result[2] == "b", "Second element should be 'b'")
assert(result[3] == "c", "Third element should be 'c'")
end)
test("strsplit with custom separator", function()
local result = strsplit("a|b|c", "|")
assert(#result == 3, "strsplit with pipe should return 3 elements")
assert(result[1] == "a", "First element should be 'a'")
assert(result[2] == "b", "Second element should be 'b'")
assert(result[3] == "c", "Third element should be 'c'")
end)
-- Conversion function tests
test("num function", function()
assert(num("123") == 123, "num('123') should be 123")
assert(num("45.67") == 45.67, "num('45.67') should be 45.67")
assert(num("invalid") == 0, "num('invalid') should be 0")
assert(num("") == 0, "num('') should be 0")
end)
test("str function", function()
assert(str(123) == "123", "str(123) should be '123'")
assert(str(45.67) == "45.67", "str(45.67) should be '45.67'")
assert(str(0) == "0", "str(0) should be '0'")
end)
test("is_number function", function()
assert(is_number("123") == true, "is_number('123') should be true")
assert(is_number("45.67") == true, "is_number('45.67') should be true")
assert(is_number("invalid") == false, "is_number('invalid') should be false")
assert(is_number("") == false, "is_number('') should be false")
assert(is_number("123abc") == false, "is_number('123abc') should be false")
end)
-- Table function tests
test("isArray function", function()
assert(isArray({ 1, 2, 3 }) == true, "isArray should return true for sequential array")
assert(isArray({ "a", "b", "c" }) == true, "isArray should return true for string array")
assert(isArray({}) == true, "isArray should return true for empty array")
assert(isArray({ a = 1, b = 2 }) == false, "isArray should return false for map")
assert(isArray({ 1, 2, [4] = 4 }) == false, "isArray should return false for sparse array")
assert(
isArray({ [1] = 1, [2] = 2, [3] = 3 }) == true,
"isArray should return true for 1-indexed array"
)
assert(
isArray({ [0] = 1, [1] = 2 }) == false,
"isArray should return false for 0-indexed array"
)
assert(
isArray({ [1] = 1, [2] = 2, [4] = 4 }) == false,
"isArray should return false for non-sequential array"
)
assert(isArray("not a table") == false, "isArray should return false for non-table")
assert(isArray(123) == false, "isArray should return false for number")
end)
test("fromCSV assigns header keys correctly", function()
local teststr = [[
#mercenary_profiles
Id ModifyStartCost ModifyStep ModifyLevelLimit Health ResistSheet WoundSlots MeleeDamage MeleeAccuracy RangeAccuracy ReceiveAmputationChance ReceiveWoundChanceMult AttackWoundChanceMult Dodge Los StarvationLimit PainThresholdLimit PainThresholdRegen TalentPerkId ActorId SkinIndex HairType HairColorHex VoiceBank Immunity CreatureClass
john_hawkwood_boss 20 0.1 140 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 8 16 crit 1.60 critchance 0.05 0.5 0.5 0.03 0.5 1.2 0.3 8 2200 16 2 talent_the_man_who_sold_the_world human_male 0 hair1 #633D08 player Human
francis_reid_daly 20 0.1 130 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.05 0.5 0.4 0.04 0.9 1 0.3 8 2000 10 1 talent_weapon_durability human_male 0 player Human
]]
local rows = fromCSV(teststr, { delimiter = "\t", hasheader = true, hascomments = true })
assert(#rows == 2, "Should have 2 data rows")
-- Test first row
assert(rows[1].Id == "john_hawkwood_boss", "First row Id should be 'john_hawkwood_boss'")
assert(rows[1].ModifyStartCost == "20", "First row ModifyStartCost should be '20'")
assert(rows[1].ModifyStep == "0.1", "First row ModifyStep should be '0.1'")
assert(rows[1].Health == "140", "First row Health should be '140'")
assert(rows[1].ActorId == "human_male", "First row ActorId should be 'human_male'")
assert(rows[1].HairColorHex == "#633D08", "First row HairColorHex should be '#633D08'")
-- Test second row
assert(rows[2].Id == "francis_reid_daly", "Second row Id should be 'francis_reid_daly'")
assert(rows[2].ModifyStartCost == "20", "Second row ModifyStartCost should be '20'")
assert(rows[2].ModifyStep == "0.1", "Second row ModifyStep should be '0.1'")
assert(rows[2].Health == "130", "Second row Health should be '130'")
assert(rows[2].ActorId == "human_male", "Second row ActorId should be 'human_male'")
-- Test that numeric indices still work
assert(rows[1][1] == "john_hawkwood_boss", "First row first field by index should work")
assert(rows[1][2] == "20", "First row second field by index should work")
end)
test("fromCSV debug header assignment", function()
local csv = "Id Name Value\n1 Test 100\n2 Test2 200"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true })
assert(rows[1].Id == "1", "Id should be '1'")
assert(rows[1].Name == "Test", "Name should be 'Test'")
assert(rows[1].Value == "100", "Value should be '100'")
end)
test("fromCSV real world mercenary file format", function()
local csv = [[#mercenary_profiles
Id ModifyStartCost ModifyStep ModifyLevelLimit Health ResistSheet WoundSlots MeleeDamage MeleeAccuracy RangeAccuracy ReceiveAmputationChance ReceiveWoundChanceMult AttackWoundChanceMult Dodge Los StarvationLimit PainThresholdLimit PainThresholdRegen TalentPerkId ActorId SkinIndex HairType HairColorHex VoiceBank Immunity CreatureClass
john_hawkwood_boss 20 0.1 140 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 8 16 crit 1.60 critchance 0.05 0.5 0.5 0.03 0.5 1.2 0.3 8 2200 16 2 talent_the_man_who_sold_the_world human_male 0 hair1 #633D08 player Human
francis_reid_daly 20 0.1 130 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.05 0.5 0.4 0.04 0.9 1 0.3 8 2000 10 1 talent_weapon_durability human_male 0 player Human
]]
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true, hascomments = true })
assert(#rows == 2, "Should have 2 data rows")
assert(rows[1].Id == "john_hawkwood_boss", "First row Id should be 'john_hawkwood_boss'")
assert(rows[1].ModifyStartCost == "20", "First row ModifyStartCost should be '20'")
assert(rows[2].Id == "francis_reid_daly", "Second row Id should be 'francis_reid_daly'")
end)
test("full CSV parser complex", function()
local original = [[
#mercenary_profiles
Id ModifyStartCost ModifyStep ModifyLevelLimit Health ResistSheet WoundSlots MeleeDamage MeleeAccuracy RangeAccuracy ReceiveAmputationChance ReceiveWoundChanceMult AttackWoundChanceMult Dodge Los StarvationLimit PainThresholdLimit PainThresholdRegen TalentPerkId ActorId SkinIndex HairType HairColorHex VoiceBank Immunity CreatureClass
john_hawkwood_boss 20 0.1 140 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 8 16 crit 1.60 critchance 0.05 0.5 0.5 0.03 0.5 1.2 0.3 8 2200 16 2 talent_the_man_who_sold_the_world human_male 0 hair1 #633D08 player Human
francis_reid_daly 20 0.1 130 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.05 0.5 0.4 0.04 0.9 1 0.3 8 2000 10 1 talent_weapon_durability human_male 0 player Human
victoria_boudicca 20 0.1 90 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 5 10 crit 1.70 critchance 0.1 0.4 0.45 0.05 1 1.2 0.3 8 1800 8 1 talent_weapon_distance human_female 0 hair1 #633D08 player Human
persival_fawcett 20 0.1 150 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 6 12 crit 1.70 critchance 0.05 0.5 0.35 0.05 0.6 1 0.25 8 2100 16 1 talent_all_resists human_male 1 hair1 #633D08 player Human
Isabella_capet 20 0.1 100 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.15 0.55 0.3 0.03 0.8 1.4 0.35 7 1700 14 2 talent_ignore_infection human_female 1 hair3 #FF3100 player Human
maximilian_rohr 20 0.1 120 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 8 16 crit 1.75 critchance 0.05 0.45 0.45 0.06 0.9 1 0.2 8 2000 14 1 talent_ignore_pain human_male 0 hair2 #FFC400 player Human
priya_marlon 20 0.1 110 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 5 10 crit 1.70 critchance 0.15 0.45 0.35 0.05 1 1.1 0.3 7 2200 12 1 talent_all_consumables_stack human_female 0 hair2 #FFC400 player Human
jacques_kennet 20 0.1 120 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 5 10 crit 1.70 critchance 0.05 0.45 0.35 0.04 0.9 1.2 0.3 8 2300 10 1 talent_reload_time human_male 0 hair1 #908E87 player Human
mirza_aishatu 20 0.1 110 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 7 14 crit 1.70 critchance 0.05 0.55 0.45 0.03 1 1.1 0.25 9 2000 10 1 talent_starving_slower human_female 1 hair2 #633D08 player Human
kenzie_yukio 20 0.1 100 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 5 10 crit 1.70 critchance 0.1 0.6 0.4 0.04 1 1 0.4 7 1600 12 1 talent_weight_dodge_affect human_male 0 hair2 #633D08 player Human
marika_wulfnod 20 0.1 100 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 6 12 crit 1.60 critchance 0.05 0.5 0.5 0.04 1 1 0.3 9 1900 12 1 talent_belt_slots human_female 0 hair1 #FFC400 player Human
auberon_lukas 20 0.1 120 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 4 8 crit 1.60 critchance 0.15 0.45 0.45 0.05 0.8 1 0.2 9 1900 8 2 talent_weapon_slot human_male 0 hair2 #633D08 player Human
niko_medich 20 0.1 120 blunt 0 pierce 0 lacer 0 fire 0 cold 0 poison 0 shock 0 beam 0 HumanHead HumanShoulder HumanArm HumanThigh HumanFeet HumanChest HumanBody HumanStomach HumanKnee blunt 5 10 crit 1.70 critchance 0.05 0.4 0.45 0.04 1 1.3 0.25 8 2000 10 1 talent_pistol_acc human_male 0 hair1 #908E87 player Human
#end
#mercenary_classes
Id ModifyStartCost ModifyStep PerkIds
scouts_of_hades 30 0.1 cqc_specialist_basic military_training_basic gear_maintenance_basic blind_fury_basic fire_transfer_basic assault_reflex_basic
ecclipse_blades 30 0.1 berserkgang_basic athletics_basic reaction_training_basic cold_weapon_wielding_basic cannibalism_basic carnage_basic
tifton_elite 30 0.1 heavy_weaponary_basic grenadier_basic selfhealing_basic stationary_defense_basic spray_and_pray_basic shock_awe_basic
tunnel_rats 30 0.1 cautious_basic handmade_shotgun_ammo_basic marauder_basic dirty_shot_basic vicious_symbiosis_basic covermaster_basic
phoenix_brigade 30 0.1 shielding_basic battle_physicist_basic reinforced_battery_basic revealing_flame_basic cauterize_basic scholar_basic
]]
-- Parse with headers and comments
local rows = fromCSV(original, { delimiter = "\t", hasheader = true, hascomments = true })
assert(#rows > 0, "Should have parsed rows")
-- Convert back to CSV with headers
local csv = toCSV(rows, { delimiter = "\t", hasheader = true })
-- Parse again
local rows2 = fromCSV(csv, { delimiter = "\t", hasheader = true, hascomments = false })
-- Verify identical - same number of rows
assert(#rows2 == #rows, "Round trip should have same number of rows")
-- Verify first row data is identical
assert(rows2[1].Id == rows[1].Id, "Round trip first row Id should match")
assert(
rows2[1].ModifyStartCost == rows[1].ModifyStartCost,
"Round trip first row ModifyStartCost should match"
)
assert(rows2[1].Health == rows[1].Health, "Round trip first row Health should match")
-- Verify headers are preserved
assert(rows2.Headers ~= nil, "Round trip rows should have Headers field")
assert(#rows2.Headers == #rows.Headers, "Headers should have same number of elements")
assert(rows2.Headers[1] == rows.Headers[1], "First header should match")
end)
-- Test metatable: row[1] and row.foobar return same value
test("metatable row[1] equals row.header", function()
local csv = "Id Name Value\n1 Test 100"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true })
assert(rows[1][1] == rows[1].Id, "row[1] should equal row.Id")
assert(rows[1][2] == rows[1].Name, "row[2] should equal row.Name")
assert(rows[1][3] == rows[1].Value, "row[3] should equal row.Value")
assert(rows[1].Id == "1", "row.Id should be '1'")
assert(rows[1][1] == "1", "row[1] should be '1'")
end)
-- Test metatable: setting via header name updates numeric index
test("metatable set via header name", function()
local csv = "Id Name Value\n1 Test 100"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true })
rows[1].Id = "999"
assert(rows[1][1] == "999", "Setting row.Id should update row[1]")
assert(rows[1].Id == "999", "row.Id should be '999'")
end)
-- Test metatable: error on unknown header assignment
test("metatable error on unknown header", function()
local csv = "Id Name Value\n1 Test 100"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true })
local ok, errMsg = pcall(function() rows[1].UnknownHeader = "test" end)
assert(ok == false, "Should error on unknown header")
assert(string.find(errMsg, "unknown header"), "Error should mention unknown header")
end)
-- Test metatable: numeric indices still work
test("metatable numeric indices work", function()
local csv = "Id Name Value\n1 Test 100"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true })
rows[1][1] = "999"
assert(rows[1].Id == "999", "Setting row[1] should update row.Id")
assert(rows[1][1] == "999", "row[1] should be '999'")
end)
-- Test metatable: numeric keys work normally
test("metatable numeric keys work", function()
local csv = "Id Name Value\n1 Test 100"
local rows = fromCSV(csv, { delimiter = "\t", hasheader = true })
rows[1][100] = "hundred"
assert(rows[1][100] == "hundred", "Numeric keys should work")
end)
print("\nAll tests completed!")

352
processor/luahelper.lua Normal file
View File

@@ -0,0 +1,352 @@
-- Custom Lua helpers for math operations
function min(a, b) return math.min(a, b) end
function max(a, b) return math.max(a, b) end
function round(x, n)
if n == nil then n = 0 end
return math.floor(x * 10 ^ n + 0.5) / 10 ^ n
end
function floor(x) return math.floor(x) end
function ceil(x) return math.ceil(x) end
function upper(s) return string.upper(s) end
function lower(s) return string.lower(s) end
function format(s, ...) return string.format(s, ...) end
function trim(s) return string.gsub(s, "^%s*(.-)%s*$", "%1") end
-- String split helper
function strsplit(inputstr, sep)
if sep == nil then sep = "%s" end
local t = {}
for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
table.insert(t, str)
end
return t
end
---@param table table
---@param depth number?
function dump(table, depth)
if depth == nil then depth = 0 end
if depth > 200 then
print("Error: Depth > 200 in dump()")
return
end
for k, v in pairs(table) do
if type(v) == "table" then
print(string.rep(" ", depth) .. k .. ":")
dump(v, depth + 1)
else
print(string.rep(" ", depth) .. k .. ": ", v)
end
end
end
--- @class ParserOptions
--- @field delimiter string? The field delimiter (default: ",").
--- @field hasheader boolean? If true, first non-comment row is treated as headers (default: false).
--- @field hascomments boolean? If true, lines starting with '#' are skipped (default: false).
--- @type ParserOptions
parserDefaultOptions = { delimiter = ",", hasheader = false, hascomments = false }
--- Validates options against a set of valid option keys.
--- @param options ParserOptions? The options table to validate
function areOptionsValid(options)
if options == nil then return end
if type(options) ~= "table" then error("options must be a table") end
-- Build valid options list from validOptions table
local validOptionsStr = ""
for k, _ in pairs(parserDefaultOptions) do
validOptionsStr = validOptionsStr .. k .. ", "
end
for k, _ in pairs(options) do
if parserDefaultOptions[k] == nil then
error(
"unknown option: " .. tostring(k) .. " (valid options: " .. validOptionsStr .. ")"
)
end
end
end
--- Parses CSV text into rows and fields using a minimal RFC 4180 state machine.
---
--- Requirements/assumptions:
--- - Input is a single string containing the entire CSV content.
--- - Field separators are specified by delimiter option (default: comma).
--- - Newlines between rows may be "\n" or "\r\n". "\r\n" is treated as one line break.
--- - Fields may be quoted with double quotes (").
--- - Inside quoted fields, doubled quotes ("") represent a literal quote character.
--- - No backslash escaping is supported (not part of RFC 4180).
--- - Newlines inside quoted fields are preserved as part of the field.
--- - Leading/trailing spaces are preserved; no trimming is performed.
--- - Empty fields and empty rows are preserved.
--- - The final row is emitted even if the text does not end with a newline.
--- - Lines starting with '#' (after optional leading whitespace) are treated as comments and skipped if hascomments is true.
---
--- @param csv string The CSV text to parse.
--- @param options ParserOptions? Options for the parser
--- @return table #A table (array) of rows; each row is a table with numeric indices and optionally header-named keys.
function fromCSV(csv, options)
if options == nil then options = {} end
-- Validate options
areOptionsValid(options)
local delimiter = options.delimiter or parserDefaultOptions.delimiter
local hasheader = options.hasheader or parserDefaultOptions.hasheader
local hascomments = options.hascomments or parserDefaultOptions.hascomments
local allRows = {}
local fields = {}
local field = {}
local STATE_DEFAULT = 1
local STATE_IN_QUOTES = 2
local STATE_QUOTE_IN_QUOTES = 3
local state = STATE_DEFAULT
local i = 1
local len = #csv
while i <= len do
local c = csv:sub(i, i)
if state == STATE_DEFAULT then
if c == '"' then
state = STATE_IN_QUOTES
i = i + 1
elseif c == delimiter then
table.insert(fields, table.concat(field))
field = {}
i = i + 1
elseif c == "\r" or c == "\n" then
table.insert(fields, table.concat(field))
field = {}
local shouldAdd = true
if hascomments and #fields > 0 then
local firstField = fields[1]
local trimmed = trim(firstField)
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
end
if shouldAdd then table.insert(allRows, fields) end
fields = {}
if c == "\r" and i < len and csv:sub(i + 1, i + 1) == "\n" then
i = i + 2
else
i = i + 1
end
else
table.insert(field, c)
i = i + 1
end
elseif state == STATE_IN_QUOTES then
if c == '"' then
state = STATE_QUOTE_IN_QUOTES
i = i + 1
else
table.insert(field, c)
i = i + 1
end
else -- STATE_QUOTE_IN_QUOTES
if c == '"' then
table.insert(field, '"')
state = STATE_IN_QUOTES
i = i + 1
elseif c == delimiter then
table.insert(fields, table.concat(field))
field = {}
state = STATE_DEFAULT
i = i + 1
elseif c == "\r" or c == "\n" then
table.insert(fields, table.concat(field))
field = {}
local shouldAdd = true
if hascomments and #fields > 0 then
local firstField = fields[1]
local trimmed = string.gsub(firstField, "^%s*(.-)%s*$", "%1")
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
end
if shouldAdd then table.insert(allRows, fields) end
fields = {}
state = STATE_DEFAULT
if c == "\r" and i < len and csv:sub(i + 1, i + 1) == "\n" then
i = i + 2
else
i = i + 1
end
else
state = STATE_DEFAULT
-- Don't increment i, reprocess character in DEFAULT state
end
end
end
if #field > 0 or #fields > 0 then
table.insert(fields, table.concat(field))
local shouldAdd = true
if hascomments and #fields > 0 then
local firstField = fields[1]
local trimmed = string.gsub(firstField, "^%s*(.-)%s*$", "%1")
if string.sub(trimmed, 1, 1) == "#" then shouldAdd = false end
end
if shouldAdd then table.insert(allRows, fields) end
end
if hasheader and #allRows > 0 then
local headers = allRows[1]
local headerMap = {}
for j = 1, #headers do
if headers[j] ~= nil and headers[j] ~= "" then
local headerName = trim(headers[j])
headerMap[headerName] = j
end
end
local header_mt = {
headers = headerMap,
__index = function(t, key)
local mt = getmetatable(t)
if type(key) == "string" and mt.headers and mt.headers[key] then
return rawget(t, mt.headers[key])
end
return rawget(t, key)
end,
__newindex = function(t, key, value)
local mt = getmetatable(t)
if type(key) == "string" and mt.headers then
if mt.headers[key] then
rawset(t, mt.headers[key], value)
else
error("unknown header: " .. tostring(key))
end
else
rawset(t, key, value)
end
end,
}
local rows = {}
for ii = 2, #allRows do
local row = {}
local dataRow = allRows[ii]
for j = 1, #dataRow do
row[j] = dataRow[j]
end
setmetatable(row, header_mt)
table.insert(rows, row)
end
rows.Headers = headers
return rows
end
return allRows
end
--- Converts a table of rows back to CSV text format (RFC 4180 compliant).
---
--- Requirements:
--- - Input is a table (array) of rows, where each row is a table (array) of field values.
--- - Field values are converted to strings using tostring().
--- - Fields are quoted if they contain the delimiter, newlines, or double quotes.
--- - Double quotes inside quoted fields are doubled ("").
--- - Fields are joined with the specified delimiter; rows are joined with newlines.
--- - If includeHeaders is true and rows have a Headers field, headers are included as the first row.
---
--- @param rows table Array of rows, where each row is an array of field values.
--- @param options ParserOptions? Options for the parser
--- @return string #CSV-formatted text
function toCSV(rows, options)
if options == nil then options = {} end
-- Validate options
areOptionsValid(options)
local delimiter = options.delimiter or parserDefaultOptions.delimiter
local includeHeaders = options.hasheader or parserDefaultOptions.hasheader
local rowStrings = {}
-- Include headers row if requested and available
if includeHeaders and #rows > 0 and rows.Headers ~= nil then
local headerStrings = {}
for _, header in ipairs(rows.Headers) do
local headerStr = tostring(header)
local needsQuoting = false
if
headerStr:find(delimiter)
or headerStr:find("\n")
or headerStr:find("\r")
or headerStr:find('"')
then
needsQuoting = true
end
if needsQuoting then
headerStr = headerStr:gsub('"', '""')
headerStr = '"' .. headerStr .. '"'
end
table.insert(headerStrings, headerStr)
end
table.insert(rowStrings, table.concat(headerStrings, delimiter))
end
for _, row in ipairs(rows) do
local fieldStrings = {}
for _, field in ipairs(row) do
local fieldStr = tostring(field)
local needsQuoting = false
if
fieldStr:find(delimiter)
or fieldStr:find("\n")
or fieldStr:find("\r")
or fieldStr:find('"')
then
needsQuoting = true
end
if needsQuoting then
fieldStr = fieldStr:gsub('"', '""')
fieldStr = '"' .. fieldStr .. '"'
end
table.insert(fieldStrings, fieldStr)
end
table.insert(rowStrings, table.concat(fieldStrings, delimiter))
end
return table.concat(rowStrings, "\n")
end
-- String to number conversion helper
function num(str) return tonumber(str) or 0 end
-- Number to string conversion
function str(num) return tostring(num) end
-- Check if string is numeric
function is_number(str) return tonumber(str) ~= nil end
function isArray(t)
if type(t) ~= "table" then return false end
local max = 0
local count = 0
for k, _ in pairs(t) do
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return false end
max = math.max(max, k)
count = count + 1
end
return max == count
end
modified = false

View File

@@ -1,6 +1,7 @@
package processor
import (
_ "embed"
"fmt"
"io"
"net/http"
@@ -13,6 +14,9 @@ import (
lua "github.com/yuin/gopher-lua"
)
//go:embed luahelper.lua
var helperScript string
// processorLogger is a scoped logger for the processor package.
var processorLogger = logger.Default.WithPrefix("processor")
@@ -160,84 +164,6 @@ func InitLuaHelpers(L *lua.LState) error {
initLuaHelpersLogger := processorLogger.WithPrefix("InitLuaHelpers")
initLuaHelpersLogger.Debug("Loading Lua helper functions")
helperScript := `
-- Custom Lua helpers for math operations
function min(a, b) return math.min(a, b) end
function max(a, b) return math.max(a, b) end
function round(x, n)
if n == nil then n = 0 end
return math.floor(x * 10^n + 0.5) / 10^n
end
function floor(x) return math.floor(x) end
function ceil(x) return math.ceil(x) end
function upper(s) return string.upper(s) end
function lower(s) return string.lower(s) end
function format(s, ...) return string.format(s, ...) end
function trim(s) return string.gsub(s, "^%s*(.-)%s*$", "%1") end
-- String split helper
function strsplit(inputstr, sep)
if sep == nil then
sep = "%s"
end
local t = {}
for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
table.insert(t, str)
end
return t
end
---@param table table
---@param depth number?
function DumpTable(table, depth)
if depth == nil then
depth = 0
end
if (depth > 200) then
print("Error: Depth > 200 in dumpTable()")
return
end
for k, v in pairs(table) do
if (type(v) == "table") then
print(string.rep(" ", depth) .. k .. ":")
DumpTable(v, depth + 1)
else
print(string.rep(" ", depth) .. k .. ": ", v)
end
end
end
-- String to number conversion helper
function num(str)
return tonumber(str) or 0
end
-- Number to string conversion
function str(num)
return tostring(num)
end
-- Check if string is numeric
function is_number(str)
return tonumber(str) ~= nil
end
function isArray(t)
if type(t) ~= "table" then return false end
local max = 0
local count = 0
for k, _ in pairs(t) do
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
return false
end
max = math.max(max, k)
count = count + 1
end
return max == count
end
modified = false
`
if err := L.DoString(helperScript); err != nil {
initLuaHelpersLogger.Error("Failed to load Lua helper functions: %v", err)
return fmt.Errorf("error loading helper functions: %v", err)
@@ -247,6 +173,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
}
@@ -302,8 +229,8 @@ func BuildLuaScript(luaExpr string) string {
// 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")
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)
@@ -315,7 +242,7 @@ func BuildJSONLuaScript(luaExpr string) string {
local res = run()
modified = res == nil or res
`, luaExpr)
buildJsonLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
buildJSONLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
return fullScript
}
@@ -384,9 +311,9 @@ func fetch(L *lua.LState) int {
fetchLogger.Debug("Fetching URL: %q", url)
// Get options from second argument if provided
var method string = "GET"
var headers map[string]string = make(map[string]string)
var body string = ""
var method = "GET"
var headers = make(map[string]string)
var body = ""
if L.GetTop() > 1 {
options := L.ToTable(2)
@@ -481,3 +408,84 @@ 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")
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))
evalRegexLogger.Debug("Matches is nil: %t", matches == nil)
if len(matches) > 0 {
matchesTable := L.NewTable()
for i, match := range matches {
matchesTable.RawSetInt(i+1, lua.LString(match))
evalRegexLogger.Debug("Set table[%d] = %q", i+1, match)
}
L.Push(matchesTable)
} else {
L.Push(lua.LNil)
}
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)
fromCSV(csv, delimiter, hasHeaders) - Parses CSV text into rows of fields (delimiter defaults to ",", hasHeaders defaults to false)
toCSV(rows, delimiter) - Converts table of rows to CSV text format (delimiter defaults to ",")
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:
dump(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"}`
}

147
processor/processor_test.go Normal file
View File

@@ -0,0 +1,147 @@
package processor_test
import (
"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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
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.RawGetInt(i + 1)
assert.Equal(t, lua.LString(v), val, "Expected index %d to be %q", i+1, v)
}
}
// Happy Path: Function returns nil when regex pattern does not match input string.
func TestEvalRegex_NoMatchReturnsNil(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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
out := L.Get(-1)
// Should be nil when no matches found
assert.Equal(t, lua.LNil, out, "Expected nil when no matches found")
}
// 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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
out := L.Get(-1)
tbl, ok := out.(*lua.LTable)
if !ok {
t.Fatalf("Expected Lua table, got %T", out)
}
fullMatch := tbl.RawGetInt(1)
assert.Equal(t, lua.LString("foo123"), fullMatch)
// There should be only the full match (index 1)
count := 0
tbl.ForEach(func(k, v lua.LValue) {
count++
})
assert.Equal(t, 1, count)
}
// Edge Case: Function handles invalid regex pattern by letting regexp.MustCompile panic (which is expected behavior)
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"))
// This should panic due to invalid regex pattern
assert.Panics(t, func() {
processor.EvalRegex(L)
}, "Expected panic for invalid regex pattern")
}
// Edge Case: Function returns nil when input string is empty and pattern doesn't match.
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, 1, result, "Expected return value to be 1 (one value pushed to Lua stack)")
out := L.Get(-1)
// Should be nil when no matches found
assert.Equal(t, lua.LNil, out, "Expected nil when input is empty and pattern doesn't match")
}
// 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) {
// Test complex regex pattern with multiple capture groups
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)
}
// Pattern should match: ["Pistol_Round", "Pistol_Round", "", "Pistol"]
// This creates 4 elements in the matches array, not 1
expectedCount := 4
actualCount := 0
tbl.ForEach(func(k, v lua.LValue) {
actualCount++
})
assert.Equal(t, expectedCount, actualCount, "Expected %d matches for pattern %q with input %q", expectedCount, pattern, input)
}

View File

@@ -22,9 +22,9 @@ type CaptureGroup struct {
Range [2]int
}
// ProcessContent applies regex replacement with Lua processing
// The filename here exists ONLY so we can pass it to the lua environment
// It's not used for anything else
// ProcessRegex applies regex replacement with Lua processing.
// The filename here exists ONLY so we can pass it to the lua environment.
// It's not used for anything else.
func ProcessRegex(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) {
processRegexLogger := regexLogger.WithPrefix("ProcessRegex").WithField("commandName", command.Name).WithField("file", filename)
processRegexLogger.Debug("Starting regex processing for file")
@@ -53,7 +53,7 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
processRegexLogger.Error("Error compiling pattern %q: %v", pattern, err)
return commands, fmt.Errorf("error compiling pattern: %v", err)
}
processRegexLogger.Debug("Compiled pattern successfully in %v", time.Since(patternCompileStart))
processRegexLogger.Debug("Compiled pattern successfully in %v. Pattern: %s", time.Since(patternCompileStart), pattern)
// Same here, it's just string concatenation, it won't kill us
// More important is that we don't fuck up the command
@@ -77,7 +77,7 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
processRegexLogger.Debug("Pattern complexity estimate: %d", patternComplexity)
if len(indices) == 0 {
processRegexLogger.Warning("No matches found for regex: %q", pattern)
processRegexLogger.Warning("No matches found for regex: %s", pattern)
processRegexLogger.Debug("Total regex processing time: %v", time.Since(startTime))
return commands, nil
}
@@ -216,9 +216,6 @@ func ProcessRegex(content string, command utils.ModifyCommand, filename string)
}
if replacement == "" {
// Apply the modifications to the original match
replacement = matchContent
// Count groups that were actually modified
modifiedGroupsCount := 0
for _, capture := range updatedCaptureGroups {
@@ -335,6 +332,9 @@ func resolveRegexPlaceholders(pattern string) string {
pattern = strings.ReplaceAll(pattern, "!any", `.*?`)
resolveLogger.Debug("Replaced !any with non-greedy wildcard")
pattern = strings.ReplaceAll(pattern, "\n", "\r?\n")
resolveLogger.Debug("Added optional carriage return support for Windows line endings")
repPattern := regexp.MustCompile(`!rep\(([^,]+),\s*(\d+)\)`)
// !rep(pattern, count) repeats the pattern n times
// Inserting !any between each repetition

View File

@@ -30,7 +30,7 @@ func normalizeWhitespace(s string) string {
return re.ReplaceAllString(strings.TrimSpace(s), " ")
}
func ApiAdaptor(content string, regex string, lua string) (string, int, int, error) {
func APIAdaptor(content string, regex string, lua string) (string, int, int, error) {
command := utils.ModifyCommand{
Regex: regex,
Lua: lua,
@@ -79,7 +79,7 @@ func TestSimpleValueMultiplication(t *testing.T) {
</item>
</config>`
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(\d+)</value>`, "v1 = v1*1.5")
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(\d+)</value>`, "v1 = v1*1.5")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -100,7 +100,7 @@ func TestShorthandNotation(t *testing.T) {
</item>
</config>`
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(\d+)</value>`, "v1*1.5")
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(\d+)</value>`, "v1*1.5")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -121,7 +121,7 @@ func TestShorthandNotationFloats(t *testing.T) {
</item>
</config>`
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(\d+\.\d+)</value>`, "v1*1.5")
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(\d+\.\d+)</value>`, "v1*1.5")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -146,7 +146,7 @@ func TestArrayNotation(t *testing.T) {
</prices>
</config>`
result, mods, matches, err := ApiAdaptor(content, `(?s)<price>(\d+)</price>`, "v1*2")
result, mods, matches, err := APIAdaptor(content, `(?s)<price>(\d+)</price>`, "v1*2")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches)
@@ -167,7 +167,7 @@ func TestMultipleNumericMatches(t *testing.T) {
<entry>400</entry>
</data>`
result, mods, matches, err := ApiAdaptor(content, `<entry>(\d+)</entry>`, "v1*2")
result, mods, matches, err := APIAdaptor(content, `<entry>(\d+)</entry>`, "v1*2")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches)
@@ -186,7 +186,7 @@ func TestMultipleStringMatches(t *testing.T) {
<name>Mary_modified</name>
</data>`
result, mods, matches, err := ApiAdaptor(content, `<name>([A-Za-z]+)</name>`, `s1 = s1 .. "_modified"`)
result, mods, matches, err := APIAdaptor(content, `<name>([A-Za-z]+)</name>`, `s1 = s1 .. "_modified"`)
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
@@ -205,7 +205,7 @@ func TestStringUpperCase(t *testing.T) {
<user>MARY</user>
</users>`
result, mods, matches, err := ApiAdaptor(content, `<user>([A-Za-z]+)</user>`, `s1 = string.upper(s1)`)
result, mods, matches, err := APIAdaptor(content, `<user>([A-Za-z]+)</user>`, `s1 = string.upper(s1)`)
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
@@ -224,7 +224,7 @@ func TestStringConcatenation(t *testing.T) {
<product>Banana_fruit</product>
</products>`
result, mods, matches, err := ApiAdaptor(content, `<product>([A-Za-z]+)</product>`, `s1 = s1 .. "_fruit"`)
result, mods, matches, err := APIAdaptor(content, `<product>([A-Za-z]+)</product>`, `s1 = s1 .. "_fruit"`)
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
@@ -254,7 +254,7 @@ func TestDecimalValues(t *testing.T) {
regex := regexp.MustCompile(`(?s)<value>([0-9.]+)</value>.*?<multiplier>([0-9.]+)</multiplier>`)
luaExpr := BuildLuaScript("v1 = v1 * v2")
result, _, _, err := ApiAdaptor(content, regex.String(), luaExpr)
result, _, _, err := APIAdaptor(content, regex.String(), luaExpr)
assert.NoError(t, err, "Error processing content: %v", err)
normalizedModified := normalizeWhitespace(result)
@@ -282,7 +282,7 @@ func TestLuaMathFunctions(t *testing.T) {
regex := regexp.MustCompile(`(?s)<value>(\d+)</value>`)
luaExpr := BuildLuaScript("v1 = math.sqrt(v1)")
modifiedContent, _, _, err := ApiAdaptor(content, regex.String(), luaExpr)
modifiedContent, _, _, err := APIAdaptor(content, regex.String(), luaExpr)
assert.NoError(t, err, "Error processing content: %v", err)
normalizedModified := normalizeWhitespace(modifiedContent)
@@ -310,7 +310,7 @@ func TestDirectAssignment(t *testing.T) {
regex := regexp.MustCompile(`(?s)<value>(\d+)</value>`)
luaExpr := BuildLuaScript("=0")
modifiedContent, _, _, err := ApiAdaptor(content, regex.String(), luaExpr)
modifiedContent, _, _, err := APIAdaptor(content, regex.String(), luaExpr)
assert.NoError(t, err, "Error processing content: %v", err)
normalizedModified := normalizeWhitespace(modifiedContent)
@@ -369,7 +369,7 @@ func TestStringAndNumericOperations(t *testing.T) {
luaExpr := BuildLuaScript(tt.luaExpression)
// Process with our function
result, modCount, _, err := ApiAdaptor(tt.input, pattern, luaExpr)
result, modCount, _, err := APIAdaptor(tt.input, pattern, luaExpr)
assert.NoError(t, err, "Process function failed: %v", err)
// Check results
@@ -430,7 +430,7 @@ func TestEdgeCases(t *testing.T) {
luaExpr := BuildLuaScript(tt.luaExpression)
// Process with our function
result, modCount, _, err := ApiAdaptor(tt.input, pattern, luaExpr)
result, modCount, _, err := APIAdaptor(tt.input, pattern, luaExpr)
assert.NoError(t, err, "Process function failed: %v", err)
// Check results
@@ -453,7 +453,7 @@ func TestNamedCaptureGroups(t *testing.T) {
</item>
</config>`
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(?<amount>\d+)</value>`, "amount = amount * 2")
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(?<amount>\d+)</value>`, "amount = amount * 2")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -474,7 +474,7 @@ func TestNamedCaptureGroupsNum(t *testing.T) {
</item>
</config>`
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(?<amount>!num)</value>`, "amount = amount * 2")
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(?<amount>!num)</value>`, "amount = amount * 2")
assert.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -495,7 +495,7 @@ func TestMultipleNamedCaptureGroups(t *testing.T) {
<quantity>15</quantity>
</product>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`(?s)<name>(?<prodName>[^<]+)</name>.*?<price>(?<prodPrice>\d+\.\d+)</price>.*?<quantity>(?<prodQty>\d+)</quantity>`,
`prodName = string.upper(prodName)
prodPrice = round(prodPrice + 8, 2)
@@ -518,7 +518,7 @@ func TestMixedIndexedAndNamedCaptures(t *testing.T) {
<data>VALUE</data>
</entry>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`(?s)<id>(\d+)</id>.*?<data>(?<dataField>[^<]+)</data>`,
`v1 = v1 * 2
dataField = string.upper(dataField)`)
@@ -550,7 +550,7 @@ func TestComplexNestedNamedCaptures(t *testing.T) {
</contact>
</person>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`(?s)<details>.*?<name>(?<fullName>[^<]+)</name>.*?<age>(?<age>\d+)</age>`,
`fullName = string.upper(fullName) .. " (" .. age .. ")"`)
@@ -571,7 +571,7 @@ func TestNamedCaptureWithVariableReadback(t *testing.T) {
<mana>300</mana>
</stats>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`(?s)<health>(?<hp>\d+)</health>.*?<mana>(?<mp>\d+)</mana>`,
`hp = hp * 1.5
mp = mp * 1.5`)
@@ -587,7 +587,7 @@ func TestNamedCaptureWithSpecialCharsInName(t *testing.T) {
expected := `<data value="84" min="10" max="100" />`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<data value="(?<val_1>\d+)"`,
`val_1 = val_1 * 2`)
@@ -602,7 +602,7 @@ func TestEmptyNamedCapture(t *testing.T) {
expected := `<tag attr="default" />`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`attr="(?<value>.*?)"`,
`value = value == "" and "default" or value`)
@@ -617,7 +617,7 @@ func TestMultipleNamedCapturesInSameLine(t *testing.T) {
expected := `<rect x="20" y="40" width="200" height="100" />`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`x="(?<x>\d+)" y="(?<y>\d+)" width="(?<w>\d+)" height="(?<h>\d+)"`,
`x = x * 2
y = y * 2
@@ -641,7 +641,7 @@ func TestConditionalNamedCapture(t *testing.T) {
<item status="inactive" count="10" />
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<item status="(?<status>[^"]+)" count="(?<count>\d+)"`,
`count = status == "active" and count * 2 or count`)
@@ -662,7 +662,7 @@ func TestLuaFunctionsOnNamedCaptures(t *testing.T) {
<user name="JANE SMITH" role="admin" />
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<user name="(?<name>[^"]+)" role="(?<role>[^"]+)"`,
`-- Capitalize first letters for regular users
if role == "user" then
@@ -692,7 +692,7 @@ func TestNamedCaptureWithMath(t *testing.T) {
<item price="19.99" quantity="3" total="59.97" />
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<item price="(?<price>\d+\.\d+)" quantity="(?<qty>\d+)"!any$`,
`-- Calculate and add total
replacement = string.format('<item price="%s" quantity="%s" total="%.2f" />',
@@ -712,7 +712,7 @@ func TestNamedCaptureWithGlobals(t *testing.T) {
expected := `<temp unit="F">77</temp>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<temp unit="(?<unit>[CF]?)">(?<value>\d+)</temp>`,
`if unit == "C" then
value = value * 9/5 + 32
@@ -739,7 +739,7 @@ func TestMixedDynamicAndNamedCaptures(t *testing.T) {
<color rgb="0,255,0" name="GREEN" hex="#00FF00" />
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<color rgb="(?<r>\d+),(?<g>\d+),(?<b>\d+)" name="(?<colorName>[^"]+)" />`,
`-- Uppercase the name
colorName = string.upper(colorName)
@@ -765,7 +765,7 @@ func TestNamedCapturesWithMultipleReferences(t *testing.T) {
expected := `<text format="uppercase" length="11">HELLO WORLD</text>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<text>(?<content>[^<]+)</text>`,
`local uppercaseContent = string.upper(content)
local contentLength = string.len(content)
@@ -783,7 +783,7 @@ func TestNamedCaptureWithJsonData(t *testing.T) {
expected := `<data>{"name":"JOHN","age":30}</data>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<data>(?<json>\{.*?\})</data>`,
`-- Parse JSON (simplified, assumes valid JSON)
local name = json:match('"name":"([^"]+)"')
@@ -813,7 +813,7 @@ func TestNamedCaptureInXML(t *testing.T) {
</product>
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`(?s)<price currency="(?<currency>[^"]+)">(?<price>\d+\.\d+)</price>.*?<stock>(?<stock>\d+)</stock>`,
`-- Add 20% to price if USD
if currency == "USD" then
@@ -870,7 +870,7 @@ func TestComprehensiveNamedCaptures(t *testing.T) {
</products>
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`(?s)<product sku="(?<sku>[^"]+)" status="(?<status>[^"]+)"[^>]*>\s*<name>(?<product_name>[^<]+)</name>\s*<price currency="(?<currency>[^"]+)">(?<price>\d+\.\d+)</price>\s*<quantity>(?<qty>\d+)</quantity>`,
`-- Only process in-stock items
if status == "in-stock" then
@@ -924,7 +924,7 @@ func TestVariousNamedCaptureFormats(t *testing.T) {
</data>
`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`<entry id="(?<id_num>\d+)" value="(?<val>\d+)"(?: status="(?<status>[^"]*)")? />`,
`-- Prefix the ID with "ID-"
id_num = "ID-" .. id_num
@@ -963,7 +963,7 @@ func TestSimpleNamedCapture(t *testing.T) {
expected := `<product name="WIDGET" price="19.99"/>`
result, mods, matches, err := ApiAdaptor(content,
result, mods, matches, err := APIAdaptor(content,
`name="(?<product_name>[^"]+)"`,
`product_name = string.upper(product_name)`)

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

513
toml_test.go Normal file
View File

@@ -0,0 +1,513 @@
package main
import (
"os"
"path/filepath"
"testing"
"cook/utils"
"github.com/stretchr/testify/assert"
)
func TestTOMLLoadBasic(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-basic-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a simple TOML test file
tomlContent := `[[commands]]
name = "SimpleTest"
regex = "test = !num"
lua = "v1 * 2"
files = ["test.txt"]
[[commands]]
name = "AnotherTest"
regex = "value = (!num)"
lua = "v1 + 10"
files = ["*.txt"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify first command
assert.Equal(t, "SimpleTest", commands[0].Name, "First command name should match")
assert.Equal(t, "test = !num", commands[0].Regex, "First command regex should match")
assert.Equal(t, "v1 * 2", commands[0].Lua, "First command Lua should match")
assert.Equal(t, []string{"test.txt"}, commands[0].Files, "First command files should match")
// Verify second command
assert.Equal(t, "AnotherTest", commands[1].Name, "Second command name should match")
assert.Equal(t, "value = (!num)", commands[1].Regex, "Second command regex should match")
assert.Equal(t, "v1 + 10", commands[1].Lua, "Second command Lua should match")
assert.Equal(t, []string{"*.txt"}, commands[1].Files, "Second command files should match")
}
func TestTOMLGlobalModifiers(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-global-modifiers-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with global modifiers
tomlContent := `[[commands]]
modifiers = { multiplier = 3, prefix = "TEST_", enabled = true }
[[commands]]
name = "UseGlobalModifiers"
regex = "value = !num"
lua = "v1 * multiplier; s1 = prefix .. s1"
files = ["test.txt"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify global modifiers command (first command should have only modifiers)
assert.Empty(t, commands[0].Name, "Global modifiers command should have no name")
assert.Empty(t, commands[0].Regex, "Global modifiers command should have no regex")
assert.Empty(t, commands[0].Lua, "Global modifiers command should have no lua")
assert.Empty(t, commands[0].Files, "Global modifiers command should have no files")
assert.Len(t, commands[0].Modifiers, 3, "Global modifiers command should have 3 modifiers")
assert.Equal(t, int64(3), commands[0].Modifiers["multiplier"], "Multiplier should be 3")
assert.Equal(t, "TEST_", commands[0].Modifiers["prefix"], "Prefix should be TEST_")
assert.Equal(t, true, commands[0].Modifiers["enabled"], "Enabled should be true")
// Verify regular command
assert.Equal(t, "UseGlobalModifiers", commands[1].Name, "Regular command name should match")
assert.Equal(t, "value = !num", commands[1].Regex, "Regular command regex should match")
}
func TestTOMLMultilineRegex(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-multiline-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with multiline regex using literal strings
tomlContent := `[[commands]]
modifiers = { factor = 2.5 }
[[commands]]
name = "MultilineTest"
regex = '''
\[config\.settings\]
depth = !num
width = !num
height = !num'''
lua = "v1 * factor"
files = ["test.conf"]
isolate = true
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Create test file that matches the multiline pattern
testContent := `[config.settings]
depth = 10
width = 20
height = 30
`
testFile := filepath.Join(tmpDir, "test.conf")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify the multiline regex command
multilineCmd := commands[1]
assert.Equal(t, "MultilineTest", multilineCmd.Name, "Command name should match")
assert.Contains(t, multilineCmd.Regex, "\\[config\\.settings\\]", "Regex should contain escaped brackets")
assert.Contains(t, multilineCmd.Regex, "depth = !num", "Regex should contain depth pattern")
assert.Contains(t, multilineCmd.Regex, "width = !num", "Regex should contain width pattern")
assert.Contains(t, multilineCmd.Regex, "height = !num", "Regex should contain height pattern")
assert.Contains(t, multilineCmd.Regex, "\n", "Regex should contain newlines")
assert.True(t, multilineCmd.Isolate, "Isolate should be true")
// Verify the regex preserves proper structure
expectedLines := []string{
"\\[config\\.settings\\]",
"depth = !num",
"width = !num",
"height = !num",
}
for _, line := range expectedLines {
assert.Contains(t, multilineCmd.Regex, line, "Regex should contain: "+line)
}
}
func TestTOMLComplexRegexPatterns(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-complex-regex-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with complex regex patterns
tomlContent := `[[commands]]
name = "ComplexPatterns"
regexes = [
"\\[section\\.([^\\]]+)\\]",
"(?P<key>\\w+)\\s*=\\s*(?P<value>\\d+\\.\\d+)",
"network\\.(\\w+)\\.(enable|disable)"
]
lua = "if is_number(value) then value = num(value) * 1.1 end; return true"
files = ["*.conf", "*.ini"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 1, "Should load 1 command from TOML")
// Verify the complex regex command
cmd := commands[0]
assert.Equal(t, "ComplexPatterns", cmd.Name, "Command name should match")
assert.Len(t, cmd.Regexes, 3, "Should have 3 regex patterns")
// Verify each regex pattern
assert.Equal(t, `\[section\.([^\]]+)\]`, cmd.Regexes[0], "First regex should match section pattern")
assert.Equal(t, `(?P<key>\w+)\s*=\s*(?P<value>\d+\.\d+)`, cmd.Regexes[1], "Second regex should match key-value pattern")
assert.Equal(t, `network\.(\w+)\.(enable|disable)`, cmd.Regexes[2], "Third regex should match network pattern")
assert.Equal(t, []string{"*.conf", "*.ini"}, cmd.Files, "Files should match")
}
func TestTOMLJSONMode(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-json-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create TOML content with JSON mode commands
tomlContent := `[[commands]]
name = "JSONMultiply"
json = true
lua = "for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true"
files = ["data.json"]
[[commands]]
name = "JSONObjectUpdate"
json = true
lua = "data.version = '2.0.0'; data.enabled = true; return true"
files = ["config.json"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test loading TOML commands
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 2, "Should load 2 commands from TOML")
// Verify first JSON command
cmd1 := commands[0]
assert.Equal(t, "JSONMultiply", cmd1.Name, "First command name should match")
assert.True(t, cmd1.JSON, "First command should have JSON mode enabled")
assert.Equal(t, "for i, item in ipairs(data.items) do data.items[i].value = item.value * 2 end; return true", cmd1.Lua, "First command Lua should match")
assert.Equal(t, []string{"data.json"}, cmd1.Files, "First command files should match")
// Verify second JSON command
cmd2 := commands[1]
assert.Equal(t, "JSONObjectUpdate", cmd2.Name, "Second command name should match")
assert.True(t, cmd2.JSON, "Second command should have JSON mode enabled")
assert.Equal(t, "data.version = '2.0.0'; data.enabled = true; return true", cmd2.Lua, "Second command Lua should match")
assert.Equal(t, []string{"config.json"}, cmd2.Files, "Second command files should match")
}
func TestTOMLEndToEndIntegration(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-integration-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create comprehensive TOML content
tomlContent := `[[commands]]
modifiers = { multiplier = 4, base_value = 100 }
[[commands]]
name = "IntegrationTest"
regex = '''
\[kinetics\.stressValues\.v2\.capacity\]
steam_engine = !num
water_wheel = !num
copper_valve_handle = !num'''
lua = "v1 * multiplier"
files = ["test.txt"]
isolate = true
[[commands]]
name = "SimplePattern"
regex = "enabled = (true|false)"
lua = "= false"
files = ["test.txt"]
`
tomlFile := filepath.Join(tmpDir, "test.toml")
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
if err != nil {
t.Fatalf("Failed to write TOML test file: %v", err)
}
// Create test file that matches the patterns
testContent := `[kinetics.stressValues.v2.capacity]
steam_engine = 256
water_wheel = 64
copper_valve_handle = 16
some_other_setting = enabled = true
`
testFile := filepath.Join(tmpDir, "test.txt")
err = os.WriteFile(testFile, []byte(testContent), 0644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test the complete workflow using the main function
commands, err := utils.LoadCommands([]string{"test.toml"})
assert.NoError(t, err, "Should load TOML commands without error")
assert.Len(t, commands, 3, "Should load 3 commands total (including global modifiers)")
// Associate files with commands
files := []string{"test.txt"}
associations, err := utils.AssociateFilesWithCommands(files, commands)
assert.NoError(t, err, "Should associate files with commands")
// Verify associations
association := associations["test.txt"]
assert.Len(t, association.IsolateCommands, 1, "Should have 1 isolate command")
assert.Len(t, association.Commands, 1, "Should have 1 regular command")
assert.Equal(t, "IntegrationTest", association.IsolateCommands[0].Name, "Isolate command should match")
assert.Equal(t, "SimplePattern", association.Commands[0].Name, "Regular command should match")
t.Logf("TOML integration test completed successfully")
t.Logf("Loaded %d commands from TOML", len(commands))
t.Logf("Associated commands: %d isolate, %d regular",
len(association.IsolateCommands), len(association.Commands))
}
func TestTOMLErrorHandling(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "toml-error-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Test 1: Invalid TOML syntax
invalidTOML := `[[commands]]
name = "Invalid"
regex = "test = !num"
lua = "v1 * 2"
files = ["test.txt"
# Missing closing bracket
`
invalidFile := filepath.Join(tmpDir, "invalid.toml")
err = os.WriteFile(invalidFile, []byte(invalidTOML), 0644)
assert.NoError(t, err, "Should write invalid TOML file")
commands, err := utils.LoadCommandsFromTomlFiles("invalid.toml")
assert.Error(t, err, "Should return error for invalid TOML syntax")
assert.Nil(t, commands, "Should return nil commands for invalid TOML")
assert.Contains(t, err.Error(), "failed to unmarshal TOML file", "Error should mention TOML unmarshaling")
// Test 2: Non-existent file
commands, err = utils.LoadCommandsFromTomlFiles("nonexistent.toml")
assert.NoError(t, err, "Should handle non-existent file without error")
assert.Empty(t, commands, "Should return empty commands for non-existent file")
// Test 3: Empty TOML file creates an error (this is expected behavior)
emptyFile := filepath.Join(tmpDir, "empty.toml")
err = os.WriteFile(emptyFile, []byte(""), 0644)
assert.NoError(t, err, "Should write empty TOML file")
commands, err = utils.LoadCommandsFromTomlFiles("empty.toml")
assert.Error(t, err, "Should return error for empty TOML file")
assert.Nil(t, commands, "Should return nil commands for empty TOML")
}
func TestYAMLToTOMLConversion(t *testing.T) {
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "yaml-to-toml-conversion-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Change to temp directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(tmpDir)
// Create a test YAML file
yamlContent := `- name: "ConversionTest"
regex: "value = !num"
lua: "v1 * 3"
files: ["test.txt"]
loglevel: DEBUG
- name: "AnotherTest"
regex: "enabled = (true|false)"
lua: "= false"
files: ["*.conf"]
- name: "GlobalModifiers"
modifiers:
multiplier: 2.5
prefix: "CONV_"
`
yamlFile := filepath.Join(tmpDir, "test.yml")
err = os.WriteFile(yamlFile, []byte(yamlContent), 0644)
assert.NoError(t, err, "Should write YAML test file")
// Test conversion
err = utils.ConvertYAMLToTOML("test.yml")
assert.NoError(t, err, "Should convert YAML to TOML without error")
// Check that TOML file was created
tomlFile := filepath.Join(tmpDir, "test.toml")
_, err = os.Stat(tomlFile)
assert.NoError(t, err, "TOML file should exist after conversion")
// Read and verify TOML content
tomlData, err := os.ReadFile(tomlFile)
assert.NoError(t, err, "Should read TOML file")
tomlContent := string(tomlData)
assert.Contains(t, tomlContent, `name = "ConversionTest"`, "TOML should contain first command name")
assert.Contains(t, tomlContent, `name = "AnotherTest"`, "TOML should contain second command name")
assert.Contains(t, tomlContent, `name = "GlobalModifiers"`, "TOML should contain global modifiers command")
assert.Contains(t, tomlContent, `multiplier = 2.5`, "TOML should contain multiplier")
assert.Contains(t, tomlContent, `prefix = "CONV_"`, "TOML should contain prefix")
// Test that converted TOML loads correctly
commands, err := utils.LoadCommandsFromTomlFiles("test.toml")
assert.NoError(t, err, "Should load converted TOML without error")
assert.Len(t, commands, 3, "Should load 3 commands from converted TOML")
// Find global modifiers command (it might not be first)
var globalCmd utils.ModifyCommand
foundGlobal := false
for _, cmd := range commands {
if cmd.Name == "GlobalModifiers" {
globalCmd = cmd
foundGlobal = true
break
}
}
assert.True(t, foundGlobal, "Should find global modifiers command")
assert.Equal(t, 2.5, globalCmd.Modifiers["multiplier"], "Should preserve multiplier value")
assert.Equal(t, "CONV_", globalCmd.Modifiers["prefix"], "Should preserve prefix value")
// Test skip functionality - run conversion again
err = utils.ConvertYAMLToTOML("test.yml")
assert.NoError(t, err, "Should handle existing TOML file without error")
// Verify original TOML file wasn't modified
originalTomlData, err := os.ReadFile(tomlFile)
assert.NoError(t, err, "Should read TOML file again")
assert.Equal(t, tomlData, originalTomlData, "TOML file content should be unchanged")
t.Logf("YAML to TOML conversion test completed successfully")
}

View File

@@ -1,6 +1,7 @@
package utils
import (
"errors"
"path/filepath"
"time"
@@ -41,24 +42,25 @@ func GetDB() (DB, error) {
dbFile := filepath.Join("data.sqlite")
getDBLogger.Debug("Opening database file: %q", dbFile)
getDBLogger.Trace("Database configuration: PrepareStmt=true, GORM logger=Silent")
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
// SkipDefaultTransaction: true,
PrepareStmt: true,
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
getDBLogger.Error("Failed to open database: %v", err)
getDBLogger.Error("Failed to open database file %q: %v", dbFile, err)
return nil, err
}
getDBLogger.Debug("Database opened successfully, running auto migration")
getDBLogger.Debug("Database opened successfully, running auto migration for FileSnapshot model")
if err := db.AutoMigrate(&FileSnapshot{}); err != nil {
getDBLogger.Error("Auto migration failed: %v", err)
getDBLogger.Error("Auto migration failed for FileSnapshot model: %v", err)
return nil, err
}
getDBLogger.Debug("Auto migration completed")
getDBLogger.Info("Database initialized and migrated successfully")
globalDB = &DBWrapper{db: db}
getDBLogger.Debug("Database wrapper initialized")
getDBLogger.Debug("Database wrapper initialized and cached globally")
return globalDB, nil
}
@@ -88,7 +90,7 @@ func (db *DBWrapper) FileExists(filePath string) (bool, error) {
}
func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
saveFileLogger := dbLogger.WithPrefix("SaveFile").WithField("filePath", filePath)
saveFileLogger := dbLogger.WithPrefix("SaveFile").WithField("filePath", filePath).WithField("dataSize", len(fileData))
saveFileLogger.Debug("Attempting to save file to database")
saveFileLogger.Trace("File data length: %d", len(fileData))
@@ -98,7 +100,7 @@ func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
return err
}
if exists {
saveFileLogger.Debug("File already exists, skipping save")
saveFileLogger.Debug("File already exists in database, skipping save to avoid overwriting original snapshot")
return nil
}
saveFileLogger.Debug("Creating new file snapshot in database")
@@ -110,7 +112,7 @@ func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
if err != nil {
saveFileLogger.Error("Failed to create file snapshot: %v", err)
} else {
saveFileLogger.Debug("File saved successfully to database")
saveFileLogger.Info("File successfully saved to database")
}
return err
}
@@ -121,8 +123,11 @@ 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 {
// Downgrade not-found to warning to avoid noisy errors during first run
getFileLogger.Warning("Failed to get file from database: %v", err)
if errors.Is(err, gorm.ErrRecordNotFound) {
getFileLogger.Debug("File not found in database: %v", err)
} else {
getFileLogger.Warning("Failed to get file from database: %v", err)
}
return nil, err
}
getFileLogger.Debug("File found in database")

View File

@@ -2,7 +2,6 @@ package utils
import (
"os"
"path/filepath"
"strconv"
"strings"
@@ -13,34 +12,13 @@ import (
var fileLogger = logger.Default.WithPrefix("utils/file")
func CleanPath(path string) string {
cleanPathLogger := fileLogger.WithPrefix("CleanPath")
cleanPathLogger.Debug("Cleaning path: %q", path)
cleanPathLogger.Trace("Original path: %q", path)
path = filepath.Clean(path)
path = strings.ReplaceAll(path, "\\", "/")
cleanPathLogger.Trace("Cleaned path result: %q", path)
return path
// Use the centralized ResolvePath function
return ResolvePath(path)
}
func ToAbs(path string) string {
toAbsLogger := fileLogger.WithPrefix("ToAbs")
toAbsLogger.Debug("Converting path to absolute: %q", path)
toAbsLogger.Trace("Input path: %q", path)
if filepath.IsAbs(path) {
toAbsLogger.Debug("Path is already absolute, cleaning it.")
cleanedPath := CleanPath(path)
toAbsLogger.Trace("Already absolute path after cleaning: %q", cleanedPath)
return cleanedPath
}
cwd, err := os.Getwd()
if err != nil {
toAbsLogger.Error("Error getting current working directory: %v", err)
return CleanPath(path)
}
toAbsLogger.Trace("Current working directory: %q", cwd)
cleanedPath := CleanPath(filepath.Join(cwd, path))
toAbsLogger.Trace("Converted absolute path result: %q", cleanedPath)
return cleanedPath
// Use the centralized ResolvePath function
return ResolvePath(path)
}
// LimitString truncates a string to maxLen and adds "..." if truncated

View File

@@ -1,21 +0,0 @@
package utils
import (
"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 (
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, JSON initial value: %t", *ParallelFiles, *Filter, *JSON)
}

View File

@@ -8,6 +8,7 @@ import (
logger "git.site.quack-lab.dev/dave/cylogger"
"github.com/bmatcuk/doublestar/v4"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
@@ -15,18 +16,18 @@ import (
var modifyCommandLogger = logger.Default.WithPrefix("utils/modifycommand")
type ModifyCommand struct {
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"`
Name string `yaml:"name,omitempty" toml:"name,omitempty"`
Regex string `yaml:"regex,omitempty" toml:"regex,omitempty"`
Regexes []string `yaml:"regexes,omitempty" toml:"regexes,omitempty"`
Lua string `yaml:"lua,omitempty" toml:"lua,omitempty"`
Files []string `yaml:"files,omitempty" toml:"files,omitempty"`
Reset bool `yaml:"reset,omitempty" toml:"reset,omitempty"`
LogLevel string `yaml:"loglevel,omitempty" toml:"loglevel,omitempty"`
Isolate bool `yaml:"isolate,omitempty" toml:"isolate,omitempty"`
NoDedup bool `yaml:"nodedup,omitempty" toml:"nodedup,omitempty"`
Disabled bool `yaml:"disable,omitempty" toml:"disable,omitempty"`
JSON bool `yaml:"json,omitempty" toml:"json,omitempty"`
Modifiers map[string]interface{} `yaml:"modifiers,omitempty" toml:"modifiers,omitempty"`
}
type CookFile []ModifyCommand
@@ -84,25 +85,27 @@ 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)
cwd, err := os.Getwd()
if err != nil {
splitPatternLogger.Error("Error getting current working directory: %v", err)
return "", ""
}
splitPatternLogger.Trace("Current working directory: %q", cwd)
// Resolve the pattern first to handle ~ expansion and make it absolute
resolvedPattern := ResolvePath(pattern)
splitPatternLogger.Trace("Resolved pattern: %q", resolvedPattern)
static, pattern := doublestar.SplitPattern(resolvedPattern)
// Ensure static part is properly resolved
if static == "" {
splitPatternLogger.Debug("Static part is empty, defaulting to current working directory")
cwd, err := os.Getwd()
if err != nil {
splitPatternLogger.Error("Error getting current working directory: %v", err)
return "", ""
}
static = cwd
splitPatternLogger.Debug("Static part is empty, defaulting to current working directory: %q", static)
} else {
// Static part should already be resolved by ResolvePath
static = strings.ReplaceAll(static, "\\", "/")
}
if !filepath.IsAbs(static) {
splitPatternLogger.Debug("Static part is not absolute, joining with current working directory")
static = filepath.Join(cwd, static)
static = filepath.Clean(static)
splitPatternLogger.Trace("Static path after joining and cleaning: %q", static)
}
static = strings.ReplaceAll(static, "\\", "/")
splitPatternLogger.Trace("Final static path: %q, Remaining pattern: %q", static, pattern)
return static, pattern
}
@@ -122,33 +125,23 @@ func AssociateFilesWithCommands(files []string, commands []ModifyCommand) (map[s
fileCommands := make(map[string]FileCommandAssociation)
for _, file := range files {
file = strings.ReplaceAll(file, "\\", "/")
associateFilesLogger.Debug("Processing file: %q", file)
// Use centralized path resolution internally but keep original file as key
resolvedFile := ResolvePath(file)
associateFilesLogger.Debug("Processing file: %q (resolved: %q)", file, resolvedFile)
fileCommands[file] = FileCommandAssociation{
File: file,
File: resolvedFile,
IsolateCommands: []ModifyCommand{},
Commands: []ModifyCommand{},
}
for _, command := range commands {
associateFilesLogger.Debug("Checking command %q for file %q", command.Name, file)
for _, glob := range command.Files {
glob = strings.ReplaceAll(glob, "\\", "/")
// SplitPattern now handles tilde expansion and path resolution
static, pattern := SplitPattern(glob)
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, "\\", "/")
// Use resolved file for matching
absFile := resolvedFile
associateFilesLogger.Trace("Absolute file path resolved for matching: %q", absFile)
// Only match if the file is under the static root
@@ -199,9 +192,14 @@ func AggregateGlobs(commands []ModifyCommand) map[string]struct{} {
for _, command := range commands {
aggregateGlobsLogger.Debug("Processing command %q for glob patterns", command.Name)
for _, glob := range command.Files {
resolvedGlob := strings.Replace(glob, "~", os.Getenv("HOME"), 1)
resolvedGlob = strings.ReplaceAll(resolvedGlob, "\\", "/")
aggregateGlobsLogger.Trace("Adding glob: %q (resolved to %q)", glob, resolvedGlob)
// Split the glob into static and pattern parts, then resolve ONLY the static part
static, pattern := SplitPattern(glob)
// Reconstruct the glob with resolved static part
resolvedGlob := static
if pattern != "" {
resolvedGlob += "/" + pattern
}
aggregateGlobsLogger.Trace("Adding glob: %q (resolved to %q) [static=%s, pattern=%s]", glob, resolvedGlob, static, pattern)
globs[resolvedGlob] = struct{}{}
}
}
@@ -235,15 +233,16 @@ func ExpandGLobs(patterns map[string]struct{}) ([]string, error) {
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 {
m = filepath.Join(static, m)
info, err := os.Stat(m)
// Resolve the full path
fullPath := ResolvePath(filepath.Join(static, m))
info, err := os.Stat(fullPath)
if err != nil {
expandGlobsLogger.Warning("Error getting file info for %q: %v", m, err)
expandGlobsLogger.Warning("Error getting file info for %q: %v", fullPath, err)
continue
}
if !info.IsDir() && !filesMap[m] {
expandGlobsLogger.Trace("Adding unique file to list: %q", m)
filesMap[m], files = true, append(files, m)
if !info.IsDir() && !filesMap[fullPath] {
expandGlobsLogger.Trace("Adding unique file to list: %q", fullPath)
filesMap[fullPath], files = true, append(files, fullPath)
}
}
}
@@ -265,11 +264,27 @@ func LoadCommands(args []string) ([]ModifyCommand, error) {
for _, arg := range args {
loadCommandsLogger.Debug("Processing argument for commands: %q", arg)
newCommands, err := LoadCommandsFromCookFiles(arg)
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)
var newCommands []ModifyCommand
var err error
// Check file extension to determine format
if strings.HasSuffix(arg, ".toml") {
loadCommandsLogger.Debug("Loading TOML commands from %q", arg)
newCommands, err = LoadCommandsFromTomlFiles(arg)
if err != nil {
loadCommandsLogger.Error("Failed to load TOML commands from argument %q: %v", arg, err)
return nil, fmt.Errorf("failed to load commands from TOML files: %w", err)
}
} else {
// Default to YAML for .yml, .yaml, or any other extension
loadCommandsLogger.Debug("Loading YAML commands from %q", arg)
newCommands, err = LoadCommandsFromCookFiles(arg)
if err != nil {
loadCommandsLogger.Error("Failed to load YAML commands from argument %q: %v", arg, err)
return nil, fmt.Errorf("failed to load commands from cook files: %w", err)
}
}
loadCommandsLogger.Debug("Successfully loaded %d commands from %q", len(newCommands), arg)
for _, cmd := range newCommands {
if cmd.Disabled {
@@ -300,9 +315,8 @@ func LoadCommandsFromCookFiles(pattern string) ([]ModifyCommand, error) {
loadCookFilesLogger.Trace("Cook files found: %v", cookFiles)
for _, cookFile := range cookFiles {
cookFile = filepath.Join(static, cookFile)
cookFile = filepath.Clean(cookFile)
cookFile = strings.ReplaceAll(cookFile, "\\", "/")
// Use centralized path resolution
cookFile = ResolvePath(filepath.Join(static, cookFile))
loadCookFilesLogger.Debug("Loading commands from individual cook file: %q", cookFile)
cookFileData, err := os.ReadFile(cookFile)
@@ -373,3 +387,189 @@ func FilterCommands(commands []ModifyCommand, filter string) []ModifyCommand {
filterCommandsLogger.Trace("Filtered commands: %v", filteredCommands)
return filteredCommands
}
func LoadCommandsFromTomlFiles(pattern string) ([]ModifyCommand, error) {
loadTomlFilesLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromTomlFiles").WithField("pattern", pattern)
loadTomlFilesLogger.Debug("Loading commands from TOML files based on pattern")
loadTomlFilesLogger.Trace("Input pattern: %q", pattern)
static, pattern := SplitPattern(pattern)
commands := []ModifyCommand{}
tomlFiles, err := doublestar.Glob(os.DirFS(static), pattern)
if err != nil {
loadTomlFilesLogger.Error("Failed to glob TOML files for pattern %q: %v", pattern, err)
return nil, fmt.Errorf("failed to glob TOML files: %w", err)
}
loadTomlFilesLogger.Debug("Found %d TOML files for pattern %q", len(tomlFiles), pattern)
loadTomlFilesLogger.Trace("TOML files found: %v", tomlFiles)
for _, tomlFile := range tomlFiles {
// Use centralized path resolution
tomlFile = ResolvePath(filepath.Join(static, tomlFile))
loadTomlFilesLogger.Debug("Loading commands from individual TOML file: %q", tomlFile)
tomlFileData, err := os.ReadFile(tomlFile)
if err != nil {
loadTomlFilesLogger.Error("Failed to read TOML file %q: %v", tomlFile, err)
return nil, fmt.Errorf("failed to read TOML file: %w", err)
}
loadTomlFilesLogger.Trace("Read %d bytes from TOML file %q", len(tomlFileData), tomlFile)
newCommands, err := LoadCommandsFromTomlFile(tomlFileData)
if err != nil {
loadTomlFilesLogger.Error("Failed to load commands from TOML file data for %q: %v", tomlFile, err)
return nil, fmt.Errorf("failed to load commands from TOML file: %w", err)
}
commands = append(commands, newCommands...)
loadTomlFilesLogger.Debug("Added %d commands from TOML file %q. Total commands now: %d", len(newCommands), tomlFile, len(commands))
}
loadTomlFilesLogger.Debug("Finished loading commands from TOML files. Total %d commands", len(commands))
return commands, nil
}
func LoadCommandsFromTomlFile(tomlFileData []byte) ([]ModifyCommand, error) {
loadTomlCommandLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromTomlFile")
loadTomlCommandLogger.Debug("Unmarshaling commands from TOML file data")
loadTomlCommandLogger.Trace("TOML file data length: %d", len(tomlFileData))
// TOML structure for commands array
var tomlData struct {
Commands []ModifyCommand `toml:"commands"`
// Also support direct array without wrapper
DirectCommands []ModifyCommand `toml:"-"`
}
// First try to parse as wrapped structure
err := toml.Unmarshal(tomlFileData, &tomlData)
if err != nil {
loadTomlCommandLogger.Error("Failed to unmarshal TOML file data: %v", err)
return nil, fmt.Errorf("failed to unmarshal TOML file: %w", err)
}
var commands []ModifyCommand
// If we found commands in the wrapped structure, use those
if len(tomlData.Commands) > 0 {
commands = tomlData.Commands
loadTomlCommandLogger.Debug("Found %d commands in wrapped TOML structure", len(commands))
} else {
// Try to parse as direct array (similar to YAML format)
commands = []ModifyCommand{}
err = toml.Unmarshal(tomlFileData, &commands)
if err != nil {
loadTomlCommandLogger.Error("Failed to unmarshal TOML file data as direct array: %v", err)
return nil, fmt.Errorf("failed to unmarshal TOML file as direct array: %w", err)
}
loadTomlCommandLogger.Debug("Found %d commands in direct TOML array", len(commands))
}
loadTomlCommandLogger.Debug("Successfully unmarshaled %d commands", len(commands))
loadTomlCommandLogger.Trace("Unmarshaled commands: %v", commands)
return commands, nil
}
// ConvertYAMLToTOML converts YAML files to TOML format
func ConvertYAMLToTOML(yamlPattern string) error {
convertLogger := modifyCommandLogger.WithPrefix("ConvertYAMLToTOML").WithField("pattern", yamlPattern)
convertLogger.Debug("Starting YAML to TOML conversion")
// Load YAML commands
yamlCommands, err := LoadCommandsFromCookFiles(yamlPattern)
if err != nil {
convertLogger.Error("Failed to load YAML commands: %v", err)
return fmt.Errorf("failed to load YAML commands: %w", err)
}
if len(yamlCommands) == 0 {
convertLogger.Info("No YAML commands found for pattern: %s", yamlPattern)
return nil
}
convertLogger.Debug("Loaded %d commands from YAML", len(yamlCommands))
// Find all YAML files matching the pattern
static, pattern := SplitPattern(yamlPattern)
yamlFiles, err := doublestar.Glob(os.DirFS(static), pattern)
if err != nil {
convertLogger.Error("Failed to glob YAML files: %v", err)
return fmt.Errorf("failed to glob YAML files: %w", err)
}
convertLogger.Debug("Found %d YAML files to convert", len(yamlFiles))
conversionCount := 0
skippedCount := 0
for _, yamlFile := range yamlFiles {
// Use centralized path resolution
yamlFilePath := ResolvePath(filepath.Join(static, yamlFile))
// Generate corresponding TOML file path
tomlFilePath := strings.TrimSuffix(yamlFilePath, filepath.Ext(yamlFilePath)) + ".toml"
convertLogger.Debug("Processing YAML file: %s -> %s", yamlFilePath, tomlFilePath)
// Check if TOML file already exists
if _, err := os.Stat(tomlFilePath); err == nil {
convertLogger.Info("Skipping conversion - TOML file already exists: %s", tomlFilePath)
skippedCount++
continue
}
// Read YAML file
yamlData, err := os.ReadFile(yamlFilePath)
if err != nil {
convertLogger.Error("Failed to read YAML file %s: %v", yamlFilePath, err)
continue
}
// Load YAML commands from this specific file
fileCommands, err := LoadCommandsFromCookFile(yamlData)
if err != nil {
convertLogger.Error("Failed to parse YAML file %s: %v", yamlFilePath, err)
continue
}
// Convert to TOML structure
tomlData, err := convertCommandsToTOML(fileCommands)
if err != nil {
convertLogger.Error("Failed to convert commands to TOML for %s: %v", yamlFilePath, err)
continue
}
// Write TOML file
err = os.WriteFile(tomlFilePath, tomlData, 0644)
if err != nil {
convertLogger.Error("Failed to write TOML file %s: %v", tomlFilePath, err)
continue
}
convertLogger.Info("Successfully converted %s to %s", yamlFilePath, tomlFilePath)
conversionCount++
}
convertLogger.Info("Conversion completed: %d files converted, %d files skipped", conversionCount, skippedCount)
return nil
}
// convertCommandsToTOML converts a slice of ModifyCommand to TOML format
func convertCommandsToTOML(commands []ModifyCommand) ([]byte, error) {
convertLogger := modifyCommandLogger.WithPrefix("convertCommandsToTOML")
convertLogger.Debug("Converting %d commands to TOML format", len(commands))
// Create TOML structure
tomlData := struct {
Commands []ModifyCommand `toml:"commands"`
}{
Commands: commands,
}
// Marshal to TOML
tomlBytes, err := toml.Marshal(tomlData)
if err != nil {
convertLogger.Error("Failed to marshal commands to TOML: %v", err)
return nil, fmt.Errorf("failed to marshal commands to TOML: %w", err)
}
convertLogger.Debug("Successfully converted %d commands to TOML (%d bytes)", len(commands), len(tomlBytes))
return tomlBytes, nil
}

View File

@@ -251,11 +251,19 @@ func TestAggregateGlobs(t *testing.T) {
globs := AggregateGlobs(commands)
// Now we properly resolve only the static part of globs
// *.xml has no static part (current dir), so it becomes resolved_dir/*.xml
// *.txt has no static part (current dir), so it becomes resolved_dir/*.txt
// *.json has no static part (current dir), so it becomes resolved_dir/*.json
// subdir/*.xml has static "subdir", so it becomes resolved_dir/subdir/*.xml
cwd, _ := os.Getwd()
resolvedCwd := ResolvePath(cwd)
expected := map[string]struct{}{
"*.xml": {},
"*.txt": {},
"*.json": {},
"subdir/*.xml": {},
resolvedCwd + "/*.xml": {},
resolvedCwd + "/*.txt": {},
resolvedCwd + "/*.json": {},
resolvedCwd + "/subdir/*.xml": {},
}
if len(globs) != len(expected) {

104
utils/path.go Normal file
View File

@@ -0,0 +1,104 @@
package utils
import (
"os"
"path/filepath"
"runtime"
"strings"
logger "git.site.quack-lab.dev/dave/cylogger"
)
// pathLogger is a scoped logger for the utils/path package.
var pathLogger = logger.Default.WithPrefix("utils/path")
// ResolvePath resolves a file path by:
// 1. Expanding ~ to the user's home directory
// 2. Making the path absolute if it's relative
// 3. Normalizing path separators to forward slashes
// 4. Cleaning the path
func ResolvePath(path string) string {
resolvePathLogger := pathLogger.WithPrefix("ResolvePath").WithField("inputPath", path)
resolvePathLogger.Debug("Resolving path")
if path == "" {
resolvePathLogger.Warning("Empty path provided")
return ""
}
// Step 1: Expand ~ to home directory
originalPath := path
if strings.HasPrefix(path, "~") {
home := os.Getenv("HOME")
if home == "" {
// Fallback for Windows
if runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
}
if home != "" {
if path == "~" {
path = home
} else if strings.HasPrefix(path, "~/") {
path = filepath.Join(home, path[2:])
} else {
// Handle cases like ~username
// For now, just replace ~ with home directory
path = strings.Replace(path, "~", home, 1)
}
resolvePathLogger.Debug("Expanded tilde to home directory: home=%s, result=%s", home, path)
} else {
resolvePathLogger.Warning("Could not determine home directory for tilde expansion")
}
}
// Step 2: Make path absolute if it's not already
if !filepath.IsAbs(path) {
cwd, err := os.Getwd()
if err != nil {
resolvePathLogger.Error("Failed to get current working directory: %v", err)
return path // Return as-is if we can't get CWD
}
path = filepath.Join(cwd, path)
resolvePathLogger.Debug("Made relative path absolute: cwd=%s, result=%s", cwd, path)
}
// Step 3: Clean the path
path = filepath.Clean(path)
resolvePathLogger.Debug("Cleaned path: result=%s", path)
// Step 4: Normalize path separators to forward slashes for consistency
path = strings.ReplaceAll(path, "\\", "/")
resolvePathLogger.Debug("Final resolved path: original=%s, final=%s", originalPath, path)
return path
}
// ResolvePathForLogging is the same as ResolvePath but includes more detailed logging
// for debugging purposes
func ResolvePathForLogging(path string) string {
return ResolvePath(path)
}
// IsAbsolutePath checks if a path is absolute (including tilde expansion)
func IsAbsolutePath(path string) bool {
// Check for tilde expansion first
if strings.HasPrefix(path, "~") {
return true // Tilde paths become absolute after expansion
}
return filepath.IsAbs(path)
}
// GetRelativePath returns the relative path from base to target
func GetRelativePath(base, target string) (string, error) {
resolvedBase := ResolvePath(base)
resolvedTarget := ResolvePath(target)
relPath, err := filepath.Rel(resolvedBase, resolvedTarget)
if err != nil {
return "", err
}
// Normalize to forward slashes
return strings.ReplaceAll(relPath, "\\", "/"), nil
}

432
utils/path_test.go Normal file
View File

@@ -0,0 +1,432 @@
package utils
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolvePath(t *testing.T) {
// Save original working directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Create a temporary directory for testing
tmpDir, err := os.MkdirTemp("", "path_test")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
tests := []struct {
name string
input string
expected string
setup func() // Optional setup function
}{
{
name: "Empty path",
input: "",
expected: "",
},
{
name: "Already absolute path",
input: func() string {
if runtime.GOOS == "windows" {
return "C:/absolute/path/file.txt"
}
return "/absolute/path/file.txt"
}(),
expected: func() string {
if runtime.GOOS == "windows" {
return "C:/absolute/path/file.txt"
}
return "/absolute/path/file.txt"
}(),
},
{
name: "Relative path",
input: "relative/file.txt",
expected: func() string {
abs, _ := filepath.Abs("relative/file.txt")
return strings.ReplaceAll(abs, "\\", "/")
}(),
},
{
name: "Tilde expansion - home only",
input: "~",
expected: func() string {
home := os.Getenv("HOME")
if home == "" && runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
return strings.ReplaceAll(filepath.Clean(home), "\\", "/")
}(),
},
{
name: "Tilde expansion - with subpath",
input: "~/Documents/file.txt",
expected: func() string {
home := os.Getenv("HOME")
if home == "" && runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
expected := filepath.Join(home, "Documents", "file.txt")
return strings.ReplaceAll(filepath.Clean(expected), "\\", "/")
}(),
},
{
name: "Path normalization - double slashes",
input: "path//to//file.txt",
expected: func() string {
abs, _ := filepath.Abs("path/to/file.txt")
return strings.ReplaceAll(abs, "\\", "/")
}(),
},
{
name: "Path normalization - . and ..",
input: "path/./to/../file.txt",
expected: func() string {
abs, _ := filepath.Abs("path/file.txt")
return strings.ReplaceAll(abs, "\\", "/")
}(),
},
{
name: "Windows backslash normalization",
input: "path\\to\\file.txt",
expected: func() string {
abs, _ := filepath.Abs("path/to/file.txt")
return strings.ReplaceAll(abs, "\\", "/")
}(),
},
{
name: "Mixed separators with tilde",
input: "~/Documents\\file.txt",
expected: func() string {
home := os.Getenv("HOME")
if home == "" && runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
expected := filepath.Join(home, "Documents", "file.txt")
return strings.ReplaceAll(filepath.Clean(expected), "\\", "/")
}(),
},
{
name: "Relative path from current directory",
input: "./file.txt",
expected: func() string {
abs, _ := filepath.Abs("file.txt")
return strings.ReplaceAll(abs, "\\", "/")
}(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setup != nil {
tt.setup()
}
result := ResolvePath(tt.input)
assert.Equal(t, tt.expected, result, "ResolvePath(%q) = %q, want %q", tt.input, result, tt.expected)
})
}
}
func TestResolvePathWithWorkingDirectoryChange(t *testing.T) {
// Save original working directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Create temporary directories
tmpDir, err := os.MkdirTemp("", "path_test")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
subDir := filepath.Join(tmpDir, "subdir")
err = os.MkdirAll(subDir, 0755)
assert.NoError(t, err)
// Change to subdirectory
err = os.Chdir(subDir)
assert.NoError(t, err)
// Test relative path resolution from new working directory
result := ResolvePath("../test.txt")
expected := filepath.Join(tmpDir, "test.txt")
expected = strings.ReplaceAll(filepath.Clean(expected), "\\", "/")
assert.Equal(t, expected, result)
}
func TestResolvePathComplexTilde(t *testing.T) {
// Test complex tilde patterns
home := os.Getenv("HOME")
if home == "" && runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
if home == "" {
t.Skip("Cannot determine home directory for tilde expansion tests")
}
tests := []struct {
input string
expected string
}{
{
input: "~",
expected: strings.ReplaceAll(filepath.Clean(home), "\\", "/"),
},
{
input: "~/",
expected: strings.ReplaceAll(filepath.Clean(home), "\\", "/"),
},
{
input: "~~",
expected: func() string {
// ~~ should be treated as ~ followed by ~ (tilde expansion)
home := os.Getenv("HOME")
if home == "" && runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
if home != "" {
// First ~ gets expanded, second ~ remains
return strings.ReplaceAll(filepath.Clean(home+"~"), "\\", "/")
}
abs, _ := filepath.Abs("~~")
return strings.ReplaceAll(abs, "\\", "/")
}(),
},
{
input: func() string {
if runtime.GOOS == "windows" {
return "C:/not/tilde/path"
}
return "/not/tilde/path"
}(),
expected: func() string {
if runtime.GOOS == "windows" {
return "C:/not/tilde/path"
}
return "/not/tilde/path"
}(),
},
}
for _, tt := range tests {
t.Run("Complex tilde: "+tt.input, func(t *testing.T) {
result := ResolvePath(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsAbsolutePath(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{
name: "Empty path",
input: "",
expected: false,
},
{
name: "Absolute Unix path",
input: "/absolute/path",
expected: func() bool {
if runtime.GOOS == "windows" {
// On Windows, paths starting with / are not considered absolute
return false
}
return true
}(),
},
{
name: "Relative path",
input: "relative/path",
expected: false,
},
{
name: "Tilde expansion (becomes absolute)",
input: "~/path",
expected: true,
},
{
name: "Windows absolute path",
input: "C:\\Windows\\System32",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsAbsolutePath(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetRelativePath(t *testing.T) {
// Create temporary directories for testing
tmpDir, err := os.MkdirTemp("", "relative_path_test")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
baseDir := filepath.Join(tmpDir, "base")
targetDir := filepath.Join(tmpDir, "target")
subDir := filepath.Join(targetDir, "subdir")
err = os.MkdirAll(baseDir, 0755)
assert.NoError(t, err)
err = os.MkdirAll(subDir, 0755)
assert.NoError(t, err)
tests := []struct {
name string
base string
target string
expected string
wantErr bool
}{
{
name: "Target is subdirectory of base",
base: baseDir,
target: filepath.Join(baseDir, "subdir"),
expected: "subdir",
wantErr: false,
},
{
name: "Target is parent of base",
base: filepath.Join(baseDir, "subdir"),
target: baseDir,
expected: "..",
wantErr: false,
},
{
name: "Target is sibling directory",
base: baseDir,
target: targetDir,
expected: "../target",
wantErr: false,
},
{
name: "Same directory",
base: baseDir,
target: baseDir,
expected: ".",
wantErr: false,
},
{
name: "With tilde expansion",
base: baseDir,
target: filepath.Join(baseDir, "file.txt"),
expected: "file.txt",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := GetRelativePath(tt.base, tt.target)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
func TestResolvePathRegression(t *testing.T) {
// This test specifically addresses the original bug:
// "~ is NOT BEING FUCKING RESOLVED"
home := os.Getenv("HOME")
if home == "" && runtime.GOOS == "windows" {
home = os.Getenv("USERPROFILE")
}
if home == "" {
t.Skip("Cannot determine home directory for regression test")
}
// Test the exact pattern from the bug report
testPath := "~/Seafile/activitywatch/sync.yml"
result := ResolvePath(testPath)
expected := filepath.Join(home, "Seafile", "activitywatch", "sync.yml")
expected = strings.ReplaceAll(filepath.Clean(expected), "\\", "/")
assert.Equal(t, expected, result, "Tilde expansion bug not fixed!")
assert.NotContains(t, result, "~", "Tilde still present in resolved path!")
// Convert both to forward slashes for comparison
homeForwardSlash := strings.ReplaceAll(home, "\\", "/")
assert.Contains(t, result, homeForwardSlash, "Home directory not found in resolved path!")
}
func TestResolvePathEdgeCases(t *testing.T) {
// Save original working directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
tests := []struct {
name string
input string
setup func()
shouldPanic bool
}{
{
name: "Just dot",
input: ".",
},
{
name: "Just double dot",
input: "..",
},
{
name: "Triple dot",
input: "...",
},
{
name: "Multiple leading dots",
input: "./.././../file.txt",
},
{
name: "Path with spaces",
input: "path with spaces/file.txt",
},
{
name: "Very long relative path",
input: strings.Repeat("../", 10) + "file.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setup != nil {
tt.setup()
}
if tt.shouldPanic {
assert.Panics(t, func() {
ResolvePath(tt.input)
})
} else {
// Should not panic
assert.NotPanics(t, func() {
ResolvePath(tt.input)
})
// Result should be a valid absolute path
result := ResolvePath(tt.input)
if tt.input != "" {
assert.True(t, filepath.IsAbs(result) || result == "", "Result should be absolute or empty")
}
}
})
}
}