69 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
bff7cc2a27 Hallucinate a json mode implementation 2025-08-21 20:39:35 +02:00
ff30b00e71 refactor(db.go, file.go): improve database error handling and file snapshot seeding 2025-08-09 16:12:26 +02:00
e1eb5eeaa6 Improve config readability by removing unnecessary fields and adding omitempty 2025-08-08 09:58:03 +02:00
2a2e11d8e0 Add more examples to the configuration file generator 2025-08-08 09:52:47 +02:00
6eb4f31127 Implement regexes, an entry option allow for same modification to apply to multiple regexes and variables that can be referenced in lua 2025-08-08 09:50:31 +02:00
4b58e00c26 Hallucinate some logs
Hallucinate more logs

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

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

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

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

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

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

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

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

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

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

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

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

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

feat(main.go): add per-file association summary for better visibility when debugging
2025-08-08 08:10:51 +02:00
8ffd8af13c Use atomic values instead of mutexes 2025-08-01 16:38:31 +02:00
67861d4455 Now only save CHANGED files to database (before changes of course) 2025-08-01 16:34:48 +02:00
299e6d8bfe Fix glob matching when backslashes are used as separators 2025-07-30 14:41:38 +02:00
388822e90a Create example yml even when no args are provided
ie. invalid usage
2025-07-30 14:38:29 +02:00
91993b4548 Improve error handling in ResetAllFiles function by logging warnings for failed file writes instead of returning errors 2025-07-27 12:47:21 +02:00
bb69558aaa Fix issue where invalid isolate commands would prevent other isolate commands from running 2025-07-21 21:28:12 +02:00
052c670627 Add a simple trim to lua 2025-07-21 21:00:11 +02:00
28 changed files with 5927 additions and 752 deletions

1
.gitignore vendored
View File

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

35
.vscode/launch.json vendored
View File

@@ -76,6 +76,41 @@
"args": [ "args": [
"tester.yml", "tester.yml",
] ]
},
{
"name": "Launch Package (Avorion)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "C:/Users/Administrator/Seafile/Games-Avorion/Avorion",
"args": [
"*.yml",
]
},
{
"name": "Launch Package (Minecraft)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "C:/Users/Administrator/Seafile/Games-Minecraft",
"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 - String manipulations
- Date conversions - Date conversions
- Structural changes - Structural changes
- CSV/TSV parsing with comments and headers
- Whole ass Lua environment - Whole ass Lua environment
- **Error Handling**: Comprehensive error detection for: - **Error Handling**: Comprehensive error detection for:
- Invalid XML/JSON - 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"/> <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 ## Installation
```bash ```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']

43
go.mod
View File

@@ -1,46 +1,41 @@
module cook module cook
go 1.24.2 go 1.23.2
require ( require (
git.site.quack-lab.dev/dave/cylogger v1.2.2 git.site.quack-lab.dev/dave/cylogger v1.3.0
github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/yuin/gopher-lua v1.1.1 github.com/yuin/gopher-lua v1.1.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.30.0
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/hexops/valast v1.5.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/tidwall/match v1.1.1 // indirect
golang.org/x/crypto v0.35.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.30.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 golang.org/x/text v0.22.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect golang.org/x/tools v0.26.0 // indirect
gorm.io/gorm v1.30.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
mvdan.cc/gofumpt v0.4.0 // indirect
) )
require ( require (
git.site.quack-lab.dev/dave/cyutils v1.0.0 github.com/BurntSushi/toml v1.5.0
github.com/go-git/go-git/v5 v5.14.0 github.com/google/go-cmp v0.6.0
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/spf13/cobra v1.10.1
golang.org/x/net v0.35.0 // indirect github.com/tidwall/gjson v1.18.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
) )

125
go.sum
View File

@@ -1,54 +1,30 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= git.site.quack-lab.dev/dave/cylogger v1.3.0 h1:eTWPUD+ThVi8kGIsRcE0XDeoH3yFb5miFEODyKUdWJw=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= git.site.quack-lab.dev/dave/cylogger v1.3.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk=
git.site.quack-lab.dev/dave/cylogger v1.2.2 h1:4xUXASEBlG9NiGxh7f57xHh9imW4unHzakIEpQoKC5E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
git.site.quack-lab.dev/dave/cylogger v1.2.2/go.mod h1:VS9MI4Y/cwjCBZgel7dSfCQlwtAgHmfvixOoBgBhtKg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
git.site.quack-lab.dev/dave/cyutils v1.0.0 h1:yp/jkM2M7UZ+UIQuy+vPI7yDvTUdpbEdFL8h0lzUTvA=
git.site.quack-lab.dev/dave/cyutils v1.0.0/go.mod h1:luGNFimplFhkpRLebhkVTNjG2wYfPAs+pu+UIMhBYbE=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= 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/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/hexops/autogold v0.8.1 h1:wvyd/bAJ+Dy+DcE09BoLk6r4Fa5R5W+O+GUzmR985WM=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 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/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -57,64 +33,43 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM=
mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ=

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)
}

663
main.go
View File

@@ -1,26 +1,32 @@
package main package main
import ( import (
"flag" _ "embed"
"fmt" "errors"
"os" "os"
"sort" "sort"
"sync" "sync"
"sync/atomic"
"time" "time"
"cook/processor" "cook/processor"
"cook/utils" "cook/utils"
"gopkg.in/yaml.v3"
logger "git.site.quack-lab.dev/dave/cylogger" 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")
type GlobalStats struct { type GlobalStats struct {
TotalMatches int TotalMatches int64
TotalModifications int TotalModifications int64
ProcessedFiles int ProcessedFiles int64
FailedFiles int FailedFiles int64
ModificationsPerCommand sync.Map ModificationsPerCommand sync.Map
} }
@@ -30,109 +36,263 @@ var (
} }
) )
func main() { // rootCmd represents the base command when called without any subcommands
flag.Usage = func() { var rootCmd *cobra.Command
fmt.Fprintf(os.Stderr, "Usage: %s [options] <pattern> <lua_expression> <...files_or_globs>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nOptions:\n") func init() {
fmt.Fprintf(os.Stderr, " -reset\n") rootCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, " Reset files to their original state\n") Use: "modifier [options] <pattern> <lua_expression> <...files_or_globs>",
fmt.Fprintf(os.Stderr, " -loglevel string\n") Short: "A powerful file modification tool with Lua scripting",
fmt.Fprintf(os.Stderr, " Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE (default \"INFO\")\n") Long: `Modifier is a powerful file processing tool that supports regex patterns,
fmt.Fprintf(os.Stderr, "\nExamples:\n") JSON manipulation, and YAML to TOML conversion with Lua scripting capabilities.
fmt.Fprintf(os.Stderr, " Regex mode (default):\n")
fmt.Fprintf(os.Stderr, " %s \"<value>(\\d+)</value>\" \"*1.5\" data.xml\n", os.Args[0]) Features:
fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n") - Regex-based pattern matching and replacement
fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n") - JSON file processing with query support
fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n") - YAML to TOML conversion
fmt.Fprintf(os.Stderr, " is_number(str) checks if a string is numeric\n") - Lua scripting for complex transformations
fmt.Fprintf(os.Stderr, " If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended\n") - Parallel file processing
fmt.Fprintf(os.Stderr, " You can use any valid Lua code, including if statements, loops, etc.\n") - Command filtering and organization`,
fmt.Fprintf(os.Stderr, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n") 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() // Global flags
logger.Info("Initializing with log level: %s", logger.GetLevel().String()) rootCmd.PersistentFlags().StringP("loglevel", "l", "INFO", "Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE")
db, err := utils.GetDB() // Local flags
if err != nil { rootCmd.Flags().IntP("parallel", "P", 100, "Number of files to process in parallel")
logger.Error("Failed to get database: %v", err) 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 return
} }
workdone, err := HandleSpecialArgs(args, err, db) mainLogger.Debug("Getting database connection")
db, err := utils.GetDB()
if err != nil { if err != nil {
logger.Error("Failed to handle special args: %v", err) mainLogger.Error("Failed to get database: %v", err)
return
}
mainLogger.Debug("Database connection established")
workdone, err := HandleSpecialArgs(args, db)
if err != nil {
mainLogger.Error("Failed to handle special args: %v", err)
return return
} }
if workdone { if workdone {
mainLogger.Info("Special arguments handled, exiting.")
return return
} }
// The plan is: // The plan is:
// Load all commands // Load all commands
mainLogger.Debug("Loading commands from arguments")
mainLogger.Trace("Arguments: %v", args)
commands, err := utils.LoadCommands(args) commands, err := utils.LoadCommands(args)
if err != nil || len(commands) == 0 { if err != nil || len(commands) == 0 {
logger.Error("Failed to load commands: %v", err) mainLogger.Error("Failed to load commands: %v", err)
CreateExampleConfig() cmd.Usage()
flag.Usage()
return return
} }
// Collect global modifiers from special entries and filter them out
vars := map[string]interface{}{}
filtered := make([]utils.ModifyCommand, 0, len(commands))
for _, c := range commands {
if len(c.Modifiers) > 0 && c.Name == "" && c.Regex == "" && len(c.Regexes) == 0 && c.Lua == "" && len(c.Files) == 0 {
for k, v := range c.Modifiers {
vars[k] = v
}
continue
}
filtered = append(filtered, c)
}
if len(vars) > 0 {
mainLogger.Info("Loaded %d global modifiers", len(vars))
processor.SetVariables(vars)
}
commands = filtered
mainLogger.Info("Loaded %d commands", len(commands))
if *utils.Filter != "" { if filterFlag != "" {
logger.Info("Filtering commands by name: %s", *utils.Filter) mainLogger.Info("Filtering commands by name: %s", filterFlag)
commands = utils.FilterCommands(commands, *utils.Filter) commands = utils.FilterCommands(commands, filterFlag)
logger.Info("Filtered %d commands", len(commands)) mainLogger.Info("Filtered %d commands", len(commands))
} }
// Then aggregate all the globs and deduplicate them // Then aggregate all the globs and deduplicate them
mainLogger.Debug("Aggregating globs and deduplicating")
globs := utils.AggregateGlobs(commands) globs := utils.AggregateGlobs(commands)
logger.Debug("Aggregated %d globs before deduplication", utils.CountGlobsBeforeDedup(commands)) mainLogger.Debug("Aggregated %d globs before deduplication", utils.CountGlobsBeforeDedup(commands))
for _, command := range commands { for _, command := range commands {
logger.Trace("Command: %s", command.Name) mainLogger.Trace("Command: %s", command.Name)
logger.Trace("Regex: %s", command.Regex) if len(command.Regexes) > 0 {
logger.Trace("Files: %v", command.Files) mainLogger.Trace("Regexes: %v", command.Regexes)
logger.Trace("Lua: %s", command.Lua) } else {
logger.Trace("Reset: %t", command.Reset) mainLogger.Trace("Regex: %s", command.Regex)
logger.Trace("Isolate: %t", command.Isolate) }
logger.Trace("LogLevel: %s", command.LogLevel) mainLogger.Trace("Files: %v", command.Files)
mainLogger.Trace("Lua: %s", command.Lua)
mainLogger.Trace("Reset: %t", command.Reset)
mainLogger.Trace("Isolate: %t", command.Isolate)
mainLogger.Trace("LogLevel: %s", command.LogLevel)
} }
// Resolve all the files for all the globs // Resolve all the files for all the globs
logger.Info("Found %d unique file patterns", len(globs)) mainLogger.Info("Found %d unique file patterns", len(globs))
mainLogger.Debug("Expanding glob patterns to files")
files, err := utils.ExpandGLobs(globs) files, err := utils.ExpandGLobs(globs)
if err != nil { if err != nil {
logger.Error("Failed to expand file patterns: %v", err) mainLogger.Error("Failed to expand file patterns: %v", err)
return return
} }
logger.Info("Found %d files to process", len(files)) mainLogger.Info("Found %d files to process", len(files))
mainLogger.Trace("Files to process: %v", files)
// Somehow connect files to commands via globs.. // Somehow connect files to commands via globs..
// For each file check every glob of every command // For each file check every glob of every command
// Maybe memoize this part // Maybe memoize this part
// That way we know what commands affect what files // That way we know what commands affect what files
mainLogger.Debug("Associating files with commands")
associations, err := utils.AssociateFilesWithCommands(files, commands) associations, err := utils.AssociateFilesWithCommands(files, commands)
if err != nil { if err != nil {
logger.Error("Failed to associate files with commands: %v", err) mainLogger.Error("Failed to associate files with commands: %v", err)
return return
} }
mainLogger.Debug("Files associated with commands")
mainLogger.Trace("File-command associations: %v", associations)
// Per-file association summary for better visibility when debugging
for file, assoc := range associations {
cmdNames := make([]string, 0, len(assoc.Commands))
for _, c := range assoc.Commands {
cmdNames = append(cmdNames, c.Name)
}
isoNames := make([]string, 0, len(assoc.IsolateCommands))
for _, c := range assoc.IsolateCommands {
isoNames = append(isoNames, c.Name)
}
mainLogger.Debug("File %q has %d regular and %d isolate commands", file, len(assoc.Commands), len(assoc.IsolateCommands))
mainLogger.Trace("\tRegular: %v", cmdNames)
mainLogger.Trace("\tIsolate: %v", isoNames)
}
mainLogger.Debug("Resetting files where necessary")
err = utils.ResetWhereNecessary(associations, db) err = utils.ResetWhereNecessary(associations, db)
if err != nil { if err != nil {
logger.Error("Failed to reset files where necessary: %v", err) mainLogger.Error("Failed to reset files where necessary: %v", err)
return return
} }
mainLogger.Debug("Files reset where necessary")
// Then for each file run all commands associated with the file // Then for each file run all commands associated with the file
workers := make(chan struct{}, *utils.ParallelFiles) workers := make(chan struct{}, parallelFlag)
wg := sync.WaitGroup{} wg := sync.WaitGroup{}
mainLogger.Debug("Starting file processing with %d parallel workers", parallelFlag)
// Add performance tracking // Add performance tracking
startTime := time.Now() startTime := time.Now()
var fileMutex sync.Mutex
// Create a map to store loggers for each command // Create a map to store loggers for each command
commandLoggers := make(map[string]*logger.Logger) commandLoggers := make(map[string]*logger.Logger)
@@ -152,10 +312,10 @@ func main() {
cmdLogLevel := logger.ParseLevel(command.LogLevel) cmdLogLevel := logger.ParseLevel(command.LogLevel)
// Create a logger with the command name as a field // Create a logger with the command name as a field
commandLoggers[command.Name] = logger.WithField("command", cmdName) commandLoggers[command.Name] = logger.Default.WithField("command", cmdName)
commandLoggers[command.Name].SetLevel(cmdLogLevel) commandLoggers[command.Name].SetLevel(cmdLogLevel)
logger.Debug("Created logger for command %q with log level %s", cmdName, cmdLogLevel.String()) mainLogger.Debug("Created logger for command %q with log level %s", cmdName, cmdLogLevel.String())
} }
for file, association := range associations { for file, association := range associations {
@@ -167,98 +327,87 @@ func main() {
// Track per-file processing time // Track per-file processing time
fileStartTime := time.Now() fileStartTime := time.Now()
logger.Debug("Reading file %q", file) mainLogger.Debug("Reading file %q", file)
fileData, err := os.ReadFile(file) fileData, err := os.ReadFile(file)
if err != nil { if err != nil {
logger.Error("Failed to read file %q: %v", file, err) mainLogger.Error("Failed to read file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
fileDataStr := string(fileData) fileDataStr := string(fileData)
mainLogger.Trace("File %q content: %s", file, utils.LimitString(fileDataStr, 500))
logger.Debug("Saving file %q to database", file) isChanged := false
err = db.SaveFile(file, fileData) mainLogger.Debug("Running isolate commands for file %q", file)
if err != nil { fileDataStr, err = RunIsolateCommands(association, file, fileDataStr, jsonFlag)
logger.Error("Failed to save file %q to database: %v", file, err) if err != nil && err != ErrNothingToDo {
mainLogger.Error("Failed to run isolate commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
if err != ErrNothingToDo {
logger.Debug("Running isolate commands for file %q", file) isChanged = true
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr, &fileMutex)
if err != nil {
logger.Error("Failed to run isolate commands for file %q: %v", file, err)
return
} }
logger.Debug("Running other commands for file %q", file) mainLogger.Debug("Running other commands for file %q", file)
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, &fileMutex, commandLoggers) fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers, jsonFlag)
if err != nil { if err != nil && err != ErrNothingToDo {
logger.Error("Failed to run other commands for file %q: %v", file, err) mainLogger.Error("Failed to run other commands for file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
if err != ErrNothingToDo {
isChanged = true
}
logger.Debug("Writing file %q", file) if isChanged {
mainLogger.Debug("Saving file %q to database", file)
err = db.SaveFile(file, fileData)
if err != nil {
mainLogger.Error("Failed to save file %q to database: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return
}
mainLogger.Debug("File %q saved to database", file)
}
mainLogger.Debug("Writing file %q", file)
err = os.WriteFile(file, []byte(fileDataStr), 0644) err = os.WriteFile(file, []byte(fileDataStr), 0644)
if err != nil { if err != nil {
logger.Error("Failed to write file %q: %v", file, err) mainLogger.Error("Failed to write file %q: %v", file, err)
atomic.AddInt64(&stats.FailedFiles, 1)
return return
} }
mainLogger.Debug("File %q written", file)
logger.Debug("File %q processed in %v", file, time.Since(fileStartTime)) // Only increment ProcessedFiles once per file, after all processing is complete
atomic.AddInt64(&stats.ProcessedFiles, 1)
mainLogger.Debug("File %q processed in %v", file, time.Since(fileStartTime))
}, file, commands) }, file, commands)
} }
wg.Wait() wg.Wait()
processingTime := time.Since(startTime) processingTime := time.Since(startTime)
logger.Info("Processing completed in %v", processingTime) mainLogger.Info("Processing completed in %v", processingTime)
if stats.ProcessedFiles > 0 { processedFiles := atomic.LoadInt64(&stats.ProcessedFiles)
logger.Info("Average time per file: %v", processingTime/time.Duration(stats.ProcessedFiles)) if processedFiles > 0 {
mainLogger.Info("Average time per file: %v", processingTime/time.Duration(processedFiles))
} }
// TODO: Also give each command its own logger, maybe prefix it with something... Maybe give commands a name? // TODO: Also give each command its own logger, maybe prefix it with something... Maybe give commands a name?
// Do that with logger.WithField("loglevel", level.String()) // Do that with logger.WithField("loglevel", level.String())
// Since each command also has its own log level // Since each command also has its own log level
// TODO: Maybe even figure out how to run individual commands...? // TODO: Maybe even figure out how to run individual commands...?
// TODO: What to do with git? Figure it out ....
// if *gitFlag {
// logger.Info("Git integration enabled, setting up git repository")
// err := setupGit()
// if err != nil {
// logger.Error("Failed to setup git: %v", err)
// fmt.Fprintf(os.Stderr, "Error setting up git: %v\n", err)
// return
// }
// }
// logger.Debug("Expanding file patterns")
// files, err := expandFilePatterns(filePatterns)
// if err != nil {
// logger.Error("Failed to expand file patterns: %v", err)
// fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err)
// return
// }
// if *gitFlag {
// logger.Info("Cleaning up git files before processing")
// err := cleanupGitFiles(files)
// if err != nil {
// logger.Error("Failed to cleanup git files: %v", err)
// fmt.Fprintf(os.Stderr, "Error cleaning up git files: %v\n", err)
// return
// }
// }
// if *resetFlag {
// logger.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 // Print summary
if stats.TotalModifications == 0 { totalModifications := atomic.LoadInt64(&stats.TotalModifications)
logger.Warning("No modifications were made in any files") if totalModifications == 0 {
mainLogger.Warning("No modifications were made in any files")
} else { } else {
logger.Info("Operation complete! Modified %d values in %d/%d files", failedFiles := atomic.LoadInt64(&stats.FailedFiles)
stats.TotalModifications, stats.ProcessedFiles, stats.ProcessedFiles+stats.FailedFiles) mainLogger.Info("Operation complete! Modified %d values in %d/%d files",
totalModifications, processedFiles, processedFiles+failedFiles)
sortedCommands := []string{} sortedCommands := []string{}
stats.ModificationsPerCommand.Range(func(key, value interface{}) bool { stats.ModificationsPerCommand.Range(func(key, value interface{}) bool {
sortedCommands = append(sortedCommands, key.(string)) sortedCommands = append(sortedCommands, key.(string))
@@ -269,146 +418,252 @@ func main() {
for _, command := range sortedCommands { for _, command := range sortedCommands {
count, _ := stats.ModificationsPerCommand.Load(command) count, _ := stats.ModificationsPerCommand.Load(command)
if count.(int) > 0 { if count.(int) > 0 {
logger.Info("\tCommand %q made %d modifications", command, count) mainLogger.Info("\tCommand %q made %d modifications", command, count)
} else { } else {
logger.Warning("\tCommand %q made no modifications", command) mainLogger.Warning("\tCommand %q made no modifications", command)
} }
} }
} }
} }
func HandleSpecialArgs(args []string, err error, db utils.DB) (bool, error) { func HandleSpecialArgs(args []string, 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] { switch args[0] {
case "reset": case "reset":
err = utils.ResetAllFiles(db) handleSpecialArgsLogger.Info("Resetting all files to their original state from database")
err := utils.ResetAllFiles(db)
if err != nil { if err != nil {
logger.Error("Failed to reset all files: %v", err) handleSpecialArgsLogger.Error("Failed to reset all files: %v", err)
return true, err return true, err
} }
logger.Info("All files reset") handleSpecialArgsLogger.Info("Successfully reset all files to original state")
return true, nil return true, nil
case "dump": case "dump":
err = db.RemoveAllFiles() handleSpecialArgsLogger.Info("Dumping all files from database (clearing snapshots)")
err := db.RemoveAllFiles()
if err != nil { if err != nil {
logger.Error("Failed to remove all files from database: %v", err) handleSpecialArgsLogger.Error("Failed to remove all files from database: %v", err)
return true, err return true, err
} }
logger.Info("All files removed from database") handleSpecialArgsLogger.Info("Successfully cleared all file snapshots from database")
return true, nil return true, nil
default:
handleSpecialArgsLogger.Debug("Unknown special argument: %q", args[0])
} }
handleSpecialArgsLogger.Debug("No special arguments handled, returning false")
return false, nil return false, nil
} }
func CreateExampleConfig() { func CreateExampleConfig() {
commands := []utils.ModifyCommand{ createExampleConfigLogger := logger.Default.WithPrefix("CreateExampleConfig")
{ createExampleConfigLogger.Debug("Creating example configuration file")
Name: "DoubleNumericValues",
Regex: "<value>(\\d+)</value>",
Lua: "v1 * 2",
Files: []string{"data/*.xml"},
LogLevel: "INFO",
},
{
Name: "UpdatePrices",
Regex: "price=\"(\\d+)\"",
Lua: "if num(v1) < 100 then return v1 * 1.5 else return v1 end",
Files: []string{"items/*.xml", "shop/*.xml"},
LogLevel: "DEBUG",
},
{
Name: "IsolatedTagUpdate",
Regex: "<tag>(.*?)</tag>",
Lua: "string.upper(s1)",
Files: []string{"config.xml"},
Isolate: true,
NoDedup: true,
LogLevel: "TRACE",
},
}
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 { if err != nil {
logger.Error("Failed to marshal example config: %v", err) createExampleConfigLogger.Error("Failed to write example_cook.toml: %v", err)
return return
} }
err = os.WriteFile("example_cook.yml", data, 0644) createExampleConfigLogger.Info("Wrote example_cook.toml")
if err != nil {
logger.Error("Failed to write example_cook.yml: %v", err)
return
}
logger.Info("Wrote example_cook.yml")
} }
func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, fileMutex *sync.Mutex, commandLoggers map[string]*logger.Logger) (string, error) { var ErrNothingToDo = errors.New("nothing to do")
// Aggregate all the modifications and execute them
modifications := []utils.ReplaceCommand{} func RunOtherCommands(file string, fileDataStr string, association utils.FileCommandAssociation, commandLoggers map[string]*logger.Logger, 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))
// Separate JSON and regex commands for different processing approaches
jsonCommands := []utils.ModifyCommand{}
regexCommands := []utils.ModifyCommand{}
for _, command := range association.Commands { for _, command := range association.Commands {
// Use command-specific logger if available, otherwise fall back to default logger if command.JSON || jsonFlag {
jsonCommands = append(jsonCommands, command)
} else {
regexCommands = append(regexCommands, command)
}
}
// Process JSON commands sequentially (each operates on the entire file)
for _, command := range jsonCommands {
cmdLogger := logger.Default cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok { if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog cmdLogger = cmdLog
} }
cmdLogger.Info("Processing file %q with command %q", file, command.Regex) cmdLogger.Debug("Processing file with JSON mode for command %q", command.Name)
newModifications, err := processor.ProcessRegex(fileDataStr, command, file) newModifications, err := processor.ProcessJSON(fileDataStr, command, file)
if err != nil { if err != nil {
return fileDataStr, fmt.Errorf("failed to process file %q with command %q: %w", file, command.Regex, err) runOtherCommandsLogger.Error("Failed to process file with JSON command %q: %v", command.Name, err)
continue
} }
modifications = append(modifications, newModifications...)
// It is not guranteed that all the commands will be executed... // Apply JSON modifications immediately
// TODO: Make this better if len(newModifications) > 0 {
// We'd have to pass the map to executemodifications or something... var count int
fileDataStr, count = utils.ExecuteModifications(newModifications, fileDataStr)
atomic.AddInt64(&stats.TotalModifications, int64(count))
cmdLogger.Debug("Applied %d JSON modifications for command %q", count, command.Name)
}
count, ok := stats.ModificationsPerCommand.Load(command.Name) count, ok := stats.ModificationsPerCommand.Load(command.Name)
if !ok { if !ok {
count = 0 count = 0
} }
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications)) stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
cmdLogger.Debug("Command %q generated %d modifications", command.Name, len(newModifications))
} }
// Aggregate regex modifications and execute them
modifications := []utils.ReplaceCommand{}
numCommandsConsidered := 0
for _, command := range regexCommands {
cmdLogger := logger.Default
if cmdLog, ok := commandLoggers[command.Name]; ok {
cmdLogger = cmdLog
}
patterns := command.Regexes
if len(patterns) == 0 {
patterns = []string{command.Regex}
}
for idx, pattern := range patterns {
tmpCmd := command
tmpCmd.Regex = pattern
cmdLogger.Debug("Begin processing file with command %q (pattern %d/%d)", command.Name, idx+1, len(patterns))
numCommandsConsidered++
newModifications, err := processor.ProcessRegex(fileDataStr, tmpCmd, file)
if err != nil {
runOtherCommandsLogger.Error("Failed to process file with command %q: %v", command.Name, err)
continue
}
modifications = append(modifications, newModifications...)
count, ok := stats.ModificationsPerCommand.Load(command.Name)
if !ok {
count = 0
}
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
cmdLogger.Debug("Command %q generated %d modifications (pattern %d/%d)", command.Name, len(newModifications), idx+1, len(patterns))
cmdLogger.Trace("Modifications generated by command %q: %v", command.Name, newModifications)
if len(newModifications) == 0 {
cmdLogger.Debug("No modifications yielded by command %q (pattern %d/%d)", command.Name, idx+1, len(patterns))
}
}
}
runOtherCommandsLogger.Debug("Aggregated %d modifications from %d command-pattern runs", len(modifications), numCommandsConsidered)
runOtherCommandsLogger.Trace("All aggregated modifications: %v", modifications)
if len(modifications) == 0 { if len(modifications) == 0 {
logger.Info("No modifications found for file %q", file) runOtherCommandsLogger.Warning("No modifications found for file")
return fileDataStr, nil return fileDataStr, ErrNothingToDo
} }
runOtherCommandsLogger.Debug("Executing %d modifications for file", len(modifications))
// Sort commands in reverse order for safe replacements // Sort commands in reverse order for safe replacements
var count int var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr) fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
runOtherCommandsLogger.Trace("File data after modifications: %s", utils.LimitString(fileDataStr, 200))
fileMutex.Lock() atomic.AddInt64(&stats.TotalModifications, int64(count))
stats.ProcessedFiles++
stats.TotalModifications += count
fileMutex.Unlock()
logger.Info("Executed %d modifications for file %q", count, file) runOtherCommandsLogger.Info("Executed %d modifications for file", count)
return fileDataStr, nil return fileDataStr, nil
} }
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string, fileMutex *sync.Mutex) (string, error) { func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string, 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 { for _, isolateCommand := range association.IsolateCommands {
logger.Info("Processing file %q with isolate command %q", file, isolateCommand.Regex) // Check if this isolate command should use JSON mode
modifications, err := processor.ProcessRegex(fileDataStr, isolateCommand, file) if isolateCommand.JSON || jsonFlag {
if err != nil { runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
return fileDataStr, fmt.Errorf("failed to process file %q with isolate command %q: %w", file, isolateCommand.Regex, err) 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
}
if len(modifications) == 0 {
runIsolateCommandsLogger.Debug("JSON isolate command %q produced no modifications", isolateCommand.Name)
continue
}
anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d JSON isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("JSON isolate modifications: %v", modifications)
var count int
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
patterns := isolateCommand.Regexes
if len(patterns) == 0 {
patterns = []string{isolateCommand.Regex}
}
for idx, pattern := range patterns {
tmpCmd := isolateCommand
tmpCmd.Regex = pattern
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
}
if len(modifications) == 0 {
runIsolateCommandsLogger.Debug("Isolate command %q produced no modifications (pattern %d/%d)", isolateCommand.Name, idx+1, len(patterns))
continue
}
anythingDone = true
runIsolateCommandsLogger.Debug("Executing %d isolate modifications for file", len(modifications))
runIsolateCommandsLogger.Trace("Isolate modifications: %v", modifications)
var count int
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 len(modifications) == 0 {
logger.Warning("No modifications found for file %q", file)
return fileDataStr, nil
}
var count int
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
fileMutex.Lock()
stats.ProcessedFiles++
stats.TotalModifications += count
fileMutex.Unlock()
logger.Info("Executed %d isolate modifications for file %q", count, file)
} }
return fileDataStr, nil if !anythingDone {
runIsolateCommandsLogger.Debug("No isolate modifications were made for file")
return fileDataStr, ErrNothingToDo
}
return currentFileData, nil
} }

642
processor/json.go Normal file
View File

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

96
processor/json_test.go Normal file
View File

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

View File

@@ -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,156 +1,152 @@
package processor package processor
import ( import (
_ "embed"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
"cook/utils"
logger "git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
lua "github.com/yuin/gopher-lua" lua "github.com/yuin/gopher-lua"
) )
//go:embed luahelper.lua
var helperScript string
// processorLogger is a scoped logger for the processor package.
var processorLogger = logger.Default.WithPrefix("processor")
// Maybe we make this an interface again for the shits and giggles // Maybe we make this an interface again for the shits and giggles
// We will see, it could easily be... // We will see, it could easily be...
var globalVariables = map[string]interface{}{}
func SetVariables(vars map[string]interface{}) {
for k, v := range vars {
globalVariables[k] = v
}
}
func NewLuaState() (*lua.LState, error) { func NewLuaState() (*lua.LState, error) {
newLStateLogger := processorLogger.WithPrefix("NewLuaState")
newLStateLogger.Debug("Creating new Lua state")
L := lua.NewState() L := lua.NewState()
// defer L.Close() // defer L.Close()
// Load math library // Load math library
logger.Debug("Loading Lua math library") newLStateLogger.Debug("Loading Lua math library")
L.Push(L.GetGlobal("require")) L.Push(L.GetGlobal("require"))
L.Push(lua.LString("math")) L.Push(lua.LString("math"))
if err := L.PCall(1, 1, nil); err != nil { if err := L.PCall(1, 1, nil); err != nil {
logger.Error("Failed to load Lua math library: %v", err) newLStateLogger.Error("Failed to load Lua math library: %v", err)
return nil, fmt.Errorf("error loading Lua math library: %v", err) return nil, fmt.Errorf("error loading Lua math library: %v", err)
} }
newLStateLogger.Debug("Lua math library loaded")
// Initialize helper functions // Initialize helper functions
logger.Debug("Initializing Lua helper functions") newLStateLogger.Debug("Initializing Lua helper functions")
if err := InitLuaHelpers(L); err != nil { if err := InitLuaHelpers(L); err != nil {
logger.Error("Failed to initialize Lua helper functions: %v", err) newLStateLogger.Error("Failed to initialize Lua helper functions: %v", err)
return nil, err return nil, err
} }
newLStateLogger.Debug("Lua helper functions initialized")
// Inject global variables
if len(globalVariables) > 0 {
newLStateLogger.Debug("Injecting %d global variables into Lua state", len(globalVariables))
for k, v := range globalVariables {
switch val := v.(type) {
case int:
L.SetGlobal(k, lua.LNumber(float64(val)))
case int64:
L.SetGlobal(k, lua.LNumber(float64(val)))
case float32:
L.SetGlobal(k, lua.LNumber(float64(val)))
case float64:
L.SetGlobal(k, lua.LNumber(val))
case string:
L.SetGlobal(k, lua.LString(val))
case bool:
if val {
L.SetGlobal(k, lua.LTrue)
} else {
L.SetGlobal(k, lua.LFalse)
}
default:
// Fallback to string representation
L.SetGlobal(k, lua.LString(fmt.Sprintf("%v", val)))
}
}
}
newLStateLogger.Debug("New Lua state created successfully")
return L, nil return L, nil
} }
// func Process(filename string, pattern string, luaExpr string) (int, int, error) {
// logger.Debug("Processing file %q with pattern %q", filename, pattern)
//
// // Read file content
// cwd, err := os.Getwd()
// if err != nil {
// logger.Error("Failed to get current working directory: %v", err)
// return 0, 0, fmt.Errorf("error getting current working directory: %v", err)
// }
//
// fullPath := filepath.Join(cwd, filename)
// logger.Trace("Reading file from: %s", fullPath)
//
// stat, err := os.Stat(fullPath)
// if err != nil {
// logger.Error("Failed to stat file %s: %v", fullPath, err)
// return 0, 0, fmt.Errorf("error getting file info: %v", err)
// }
// logger.Debug("File size: %d bytes, modified: %s", stat.Size(), stat.ModTime().Format(time.RFC3339))
//
// content, err := os.ReadFile(fullPath)
// if err != nil {
// logger.Error("Failed to read file %s: %v", fullPath, err)
// return 0, 0, fmt.Errorf("error reading file: %v", err)
// }
//
// fileContent := string(content)
// logger.Trace("File read successfully: %d bytes, hash: %x", len(content), md5sum(content))
//
// // Detect and log file type
// fileType := detectFileType(filename, fileContent)
// if fileType != "" {
// logger.Debug("Detected file type: %s", fileType)
// }
//
// // Process the content
// logger.Debug("Starting content processing")
// modifiedContent, modCount, matchCount, err := ProcessContent(fileContent, pattern, luaExpr)
// if err != nil {
// logger.Error("Processing error: %v", err)
// return 0, 0, err
// }
//
// logger.Debug("Processing results: %d matches, %d modifications", matchCount, modCount)
//
// // If we made modifications, save the file
// if modCount > 0 {
// // Calculate changes summary
// changePercent := float64(len(modifiedContent)) / float64(len(fileContent)) * 100
// logger.Info("File size change: %d → %d bytes (%.1f%%)",
// len(fileContent), len(modifiedContent), changePercent)
//
// logger.Debug("Writing modified content to %s", fullPath)
// err = os.WriteFile(fullPath, []byte(modifiedContent), 0644)
// if err != nil {
// logger.Error("Failed to write to file %s: %v", fullPath, err)
// return 0, 0, fmt.Errorf("error writing file: %v", err)
// }
// logger.Debug("File written successfully, new hash: %x", md5sum([]byte(modifiedContent)))
// } else if matchCount > 0 {
// logger.Debug("No content modifications needed for %d matches", matchCount)
// } else {
// logger.Debug("No matches found in file")
// }
//
// return modCount, matchCount, nil
// }
// FromLua converts a Lua table to a struct or map recursively // FromLua converts a Lua table to a struct or map recursively
func FromLua(L *lua.LState, luaValue lua.LValue) (interface{}, error) { func FromLua(L *lua.LState, luaValue lua.LValue) (interface{}, error) {
fromLuaLogger := processorLogger.WithPrefix("FromLua").WithField("luaType", luaValue.Type().String())
fromLuaLogger.Debug("Converting Lua value to Go interface")
switch v := luaValue.(type) { switch v := luaValue.(type) {
// Well shit...
// Tables in lua are both maps and arrays
// As arrays they are ordered and as maps, obviously, not
// So when we parse them to a go map we fuck up the order for arrays
// We have to find a better way....
case *lua.LTable: case *lua.LTable:
fromLuaLogger.Debug("Processing Lua table")
isArray, err := IsLuaTableArray(L, v) isArray, err := IsLuaTableArray(L, v)
if err != nil { if err != nil {
fromLuaLogger.Error("Failed to determine if Lua table is array: %v", err)
return nil, err return nil, err
} }
fromLuaLogger.Debug("Lua table is array: %t", isArray)
if isArray { if isArray {
fromLuaLogger.Debug("Converting Lua table to Go array")
result := make([]interface{}, 0) result := make([]interface{}, 0)
v.ForEach(func(key lua.LValue, value lua.LValue) { v.ForEach(func(key lua.LValue, value lua.LValue) {
converted, _ := FromLua(L, value) converted, _ := FromLua(L, value)
result = append(result, converted) result = append(result, converted)
}) })
fromLuaLogger.Trace("Converted Go array: %v", result)
return result, nil return result, nil
} else { } else {
fromLuaLogger.Debug("Converting Lua table to Go map")
result := make(map[string]interface{}) result := make(map[string]interface{})
v.ForEach(func(key lua.LValue, value lua.LValue) { v.ForEach(func(key lua.LValue, value lua.LValue) {
converted, _ := FromLua(L, value) converted, _ := FromLua(L, value)
result[key.String()] = converted result[key.String()] = converted
}) })
fromLuaLogger.Trace("Converted Go map: %v", result)
return result, nil return result, nil
} }
case lua.LString: case lua.LString:
fromLuaLogger.Debug("Converting Lua string to Go string")
fromLuaLogger.Trace("Lua string: %q", string(v))
return string(v), nil return string(v), nil
case lua.LBool: case lua.LBool:
fromLuaLogger.Debug("Converting Lua boolean to Go boolean")
fromLuaLogger.Trace("Lua boolean: %t", bool(v))
return bool(v), nil return bool(v), nil
case lua.LNumber: case lua.LNumber:
fromLuaLogger.Debug("Converting Lua number to Go float64")
fromLuaLogger.Trace("Lua number: %f", float64(v))
return float64(v), nil return float64(v), nil
default: default:
fromLuaLogger.Debug("Unsupported Lua type, returning nil")
return nil, nil return nil, nil
} }
} }
func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) { func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) {
logger.Trace("Checking if Lua table is an array") isLuaTableArrayLogger := processorLogger.WithPrefix("IsLuaTableArray")
isLuaTableArrayLogger.Debug("Checking if Lua table is an array")
isLuaTableArrayLogger.Trace("Lua table input: %v", v)
L.SetGlobal("table_to_check", v) L.SetGlobal("table_to_check", v)
// Use our predefined helper function from InitLuaHelpers // Use our predefined helper function from InitLuaHelpers
err := L.DoString(`is_array = isArray(table_to_check)`) err := L.DoString(`is_array = isArray(table_to_check)`)
if err != nil { if err != nil {
logger.Error("Error determining if table is an array: %v", err) isLuaTableArrayLogger.Error("Error determining if table is an array: %v", err)
return false, fmt.Errorf("error determining if table is array: %w", err) return false, fmt.Errorf("error determining if table is array: %w", err)
} }
@@ -158,112 +154,33 @@ func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) {
isArray := L.GetGlobal("is_array") isArray := L.GetGlobal("is_array")
// LVIsFalse returns true if a given LValue is a nil or false otherwise false. // LVIsFalse returns true if a given LValue is a nil or false otherwise false.
result := !lua.LVIsFalse(isArray) result := !lua.LVIsFalse(isArray)
logger.Trace("Lua table is array: %v", result) isLuaTableArrayLogger.Debug("Lua table is array: %t", result)
isLuaTableArrayLogger.Trace("isArray result Lua value: %v", isArray)
return result, nil return result, nil
} }
// InitLuaHelpers initializes common Lua helper functions // InitLuaHelpers initializes common Lua helper functions
func InitLuaHelpers(L *lua.LState) error { func InitLuaHelpers(L *lua.LState) error {
logger.Debug("Loading Lua helper functions") initLuaHelpersLogger := processorLogger.WithPrefix("InitLuaHelpers")
initLuaHelpersLogger.Debug("Loading Lua helper functions")
helperScript := `
-- 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
-- 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 { if err := L.DoString(helperScript); err != nil {
logger.Error("Failed to load Lua helper functions: %v", err) initLuaHelpersLogger.Error("Failed to load Lua helper functions: %v", err)
return fmt.Errorf("error loading helper functions: %v", err) return fmt.Errorf("error loading helper functions: %v", err)
} }
initLuaHelpersLogger.Debug("Lua helper functions loaded")
logger.Debug("Setting up Lua print function to Go") initLuaHelpersLogger.Debug("Setting up Lua print function to Go")
L.SetGlobal("print", L.NewFunction(printToGo)) L.SetGlobal("print", L.NewFunction(printToGo))
L.SetGlobal("fetch", L.NewFunction(fetch)) L.SetGlobal("fetch", L.NewFunction(fetch))
L.SetGlobal("re", L.NewFunction(EvalRegex))
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
return nil return nil
} }
// LimitString truncates a string to maxLen and adds "..." if truncated
func LimitString(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", "\\n")
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func PrependLuaAssignment(luaExpr string) string { func PrependLuaAssignment(luaExpr string) string {
prependLuaAssignmentLogger := processorLogger.WithPrefix("PrependLuaAssignment").WithField("originalLuaExpr", luaExpr)
prependLuaAssignmentLogger.Debug("Prepending Lua assignment if necessary")
// Auto-prepend v1 for expressions starting with operators // Auto-prepend v1 for expressions starting with operators
if strings.HasPrefix(luaExpr, "*") || if strings.HasPrefix(luaExpr, "*") ||
strings.HasPrefix(luaExpr, "/") || strings.HasPrefix(luaExpr, "/") ||
@@ -272,30 +189,32 @@ func PrependLuaAssignment(luaExpr string) string {
strings.HasPrefix(luaExpr, "^") || strings.HasPrefix(luaExpr, "^") ||
strings.HasPrefix(luaExpr, "%") { strings.HasPrefix(luaExpr, "%") {
luaExpr = "v1 = v1" + luaExpr luaExpr = "v1 = v1" + luaExpr
prependLuaAssignmentLogger.Debug("Prepended 'v1 = v1' due to operator prefix")
} else if strings.HasPrefix(luaExpr, "=") { } else if strings.HasPrefix(luaExpr, "=") {
// Handle direct assignment with = operator // Handle direct assignment with = operator
luaExpr = "v1 " + luaExpr luaExpr = "v1 " + luaExpr
prependLuaAssignmentLogger.Debug("Prepended 'v1' due to direct assignment operator")
} }
// Add assignment if needed // Add assignment if needed
if !strings.Contains(luaExpr, "=") { if !strings.Contains(luaExpr, "=") {
luaExpr = "v1 = " + luaExpr luaExpr = "v1 = " + luaExpr
prependLuaAssignmentLogger.Debug("Prepended 'v1 =' as no assignment was found")
} }
prependLuaAssignmentLogger.Trace("Final Lua expression after prepending: %q", luaExpr)
return luaExpr return luaExpr
} }
// BuildLuaScript prepares a Lua expression from shorthand notation // BuildLuaScript prepares a Lua expression from shorthand notation
func BuildLuaScript(luaExpr string) string { func BuildLuaScript(luaExpr string) string {
logger.Debug("Building Lua script from expression: %s", luaExpr) buildLuaScriptLogger := processorLogger.WithPrefix("BuildLuaScript").WithField("inputLuaExpr", luaExpr)
buildLuaScriptLogger.Debug("Building full Lua script from expression")
// Perform $var substitutions from globalVariables
luaExpr = replaceVariables(luaExpr)
luaExpr = PrependLuaAssignment(luaExpr) luaExpr = PrependLuaAssignment(luaExpr)
// This allows the user to specify whether or not they modified a value
// If they do nothing we assume they did modify (no return at all)
// If they return before our return then they themselves specify what they did
// If nothing is returned lua assumes nil
// So we can say our value was modified if the return value is either nil or true
// If the return value is false then the user wants to keep the original
fullScript := fmt.Sprintf(` fullScript := fmt.Sprintf(`
function run() function run()
%s %s
@@ -303,11 +222,60 @@ func BuildLuaScript(luaExpr string) string {
local res = run() local res = run()
modified = res == nil or res modified = res == nil or res
`, luaExpr) `, luaExpr)
buildLuaScriptLogger.Trace("Generated full Lua script: %q", utils.LimitString(fullScript, 200))
return fullScript return fullScript
} }
// BuildJSONLuaScript prepares a Lua expression for JSON mode
func BuildJSONLuaScript(luaExpr string) string {
buildJSONLuaScriptLogger := processorLogger.WithPrefix("BuildJSONLuaScript").WithField("inputLuaExpr", luaExpr)
buildJSONLuaScriptLogger.Debug("Building full Lua script for JSON mode from expression")
// Perform $var substitutions from globalVariables
luaExpr = replaceVariables(luaExpr)
fullScript := fmt.Sprintf(`
function run()
%s
end
local res = run()
modified = res == nil or res
`, luaExpr)
buildJSONLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
return fullScript
}
func replaceVariables(expr string) string {
// $varName -> literal value
varNameRe := regexp.MustCompile(`\$(\w+)`)
return varNameRe.ReplaceAllStringFunc(expr, func(m string) string {
name := varNameRe.FindStringSubmatch(m)[1]
if v, ok := globalVariables[name]; ok {
switch val := v.(type) {
case int, int64, float32, float64:
return fmt.Sprintf("%v", val)
case bool:
if val {
return "true"
} else {
return "false"
}
case string:
// Quote strings for Lua literal
return fmt.Sprintf("%q", val)
default:
return fmt.Sprintf("%q", fmt.Sprintf("%v", val))
}
}
return m
})
}
func printToGo(L *lua.LState) int { func printToGo(L *lua.LState) int {
printToGoLogger := processorLogger.WithPrefix("printToGo")
printToGoLogger.Debug("Lua print function called, redirecting to Go logger")
top := L.GetTop() top := L.GetTop()
args := make([]interface{}, top) args := make([]interface{}, top)
@@ -321,53 +289,67 @@ func printToGo(L *lua.LState) int {
parts = append(parts, fmt.Sprintf("%v", arg)) parts = append(parts, fmt.Sprintf("%v", arg))
} }
message := strings.Join(parts, " ") message := strings.Join(parts, " ")
printToGoLogger.Trace("Lua print message: %q", message)
// Use the LUA log level with a script tag // Use the LUA log level with a script tag
logger.Lua("%s", message) logger.Lua("%s", message)
printToGoLogger.Debug("Message logged from Lua")
return 0 return 0
} }
func fetch(L *lua.LState) int { func fetch(L *lua.LState) int {
fetchLogger := processorLogger.WithPrefix("fetch")
fetchLogger.Debug("Lua fetch function called")
// Get URL from first argument // Get URL from first argument
url := L.ToString(1) url := L.ToString(1)
if url == "" { if url == "" {
fetchLogger.Error("Fetch failed: URL is required")
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString("URL is required")) L.Push(lua.LString("URL is required"))
return 2 return 2
} }
fetchLogger.Debug("Fetching URL: %q", url)
// Get options from second argument if provided // Get options from second argument if provided
var method string = "GET" var method = "GET"
var headers map[string]string = make(map[string]string) var headers = make(map[string]string)
var body string = "" var body = ""
if L.GetTop() > 1 { if L.GetTop() > 1 {
options := L.ToTable(2) options := L.ToTable(2)
if options != nil { if options != nil {
fetchLogger.Debug("Processing fetch options")
// Get method // Get method
if methodVal := options.RawGetString("method"); methodVal != lua.LNil { if methodVal := options.RawGetString("method"); methodVal != lua.LNil {
method = methodVal.String() method = methodVal.String()
fetchLogger.Trace("Method from options: %q", method)
} }
// Get headers // Get headers
if headersVal := options.RawGetString("headers"); headersVal != lua.LNil { if headersVal := options.RawGetString("headers"); headersVal != lua.LNil {
if headersTable, ok := headersVal.(*lua.LTable); ok { if headersTable, ok := headersVal.(*lua.LTable); ok {
fetchLogger.Trace("Processing headers table")
headersTable.ForEach(func(key lua.LValue, value lua.LValue) { headersTable.ForEach(func(key lua.LValue, value lua.LValue) {
headers[key.String()] = value.String() headers[key.String()] = value.String()
fetchLogger.Trace("Header: %q = %q", key.String(), value.String())
}) })
} }
fetchLogger.Trace("All headers: %v", headers)
} }
// Get body // Get body
if bodyVal := options.RawGetString("body"); bodyVal != lua.LNil { if bodyVal := options.RawGetString("body"); bodyVal != lua.LNil {
body = bodyVal.String() body = bodyVal.String()
fetchLogger.Trace("Body from options: %q", utils.LimitString(body, 100))
} }
} }
} }
fetchLogger.Debug("Fetch request details: Method=%q, URL=%q, BodyLength=%d, Headers=%v", method, url, len(body), headers)
// Create HTTP request // Create HTTP request
req, err := http.NewRequest(method, url, strings.NewReader(body)) req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil { if err != nil {
fetchLogger.Error("Error creating HTTP request: %v", err)
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("Error creating request: %v", err))) L.Push(lua.LString(fmt.Sprintf("Error creating request: %v", err)))
return 2 return 2
@@ -377,24 +359,33 @@ func fetch(L *lua.LState) int {
for key, value := range headers { for key, value := range headers {
req.Header.Set(key, value) req.Header.Set(key, value)
} }
fetchLogger.Debug("HTTP request created and headers set")
fetchLogger.Trace("HTTP Request: %+v", req)
// Make request // Make request
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
fetchLogger.Error("Error making HTTP request: %v", err)
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("Error making request: %v", err))) L.Push(lua.LString(fmt.Sprintf("Error making request: %v", err)))
return 2 return 2
} }
defer resp.Body.Close() defer func() {
fetchLogger.Debug("Closing HTTP response body")
resp.Body.Close()
}()
fetchLogger.Debug("HTTP request executed. Status Code: %d", resp.StatusCode)
// Read response body // Read response body
bodyBytes, err := io.ReadAll(resp.Body) bodyBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
fetchLogger.Error("Error reading response body: %v", err)
L.Push(lua.LNil) L.Push(lua.LNil)
L.Push(lua.LString(fmt.Sprintf("Error reading response: %v", err))) L.Push(lua.LString(fmt.Sprintf("Error reading response: %v", err)))
return 2 return 2
} }
fetchLogger.Trace("Response body length: %d", len(bodyBytes))
// Create response table // Create response table
responseTable := L.NewTable() responseTable := L.NewTable()
@@ -402,14 +393,99 @@ func fetch(L *lua.LState) int {
responseTable.RawSetString("statusText", lua.LString(resp.Status)) responseTable.RawSetString("statusText", lua.LString(resp.Status))
responseTable.RawSetString("ok", lua.LBool(resp.StatusCode >= 200 && resp.StatusCode < 300)) responseTable.RawSetString("ok", lua.LBool(resp.StatusCode >= 200 && resp.StatusCode < 300))
responseTable.RawSetString("body", lua.LString(string(bodyBytes))) responseTable.RawSetString("body", lua.LString(string(bodyBytes)))
fetchLogger.Debug("Created Lua response table")
// Set headers in response // Set headers in response
headersTable := L.NewTable() headersTable := L.NewTable()
for key, values := range resp.Header { for key, values := range resp.Header {
headersTable.RawSetString(key, lua.LString(values[0])) headersTable.RawSetString(key, lua.LString(values[0]))
fetchLogger.Trace("Response header: %q = %q", key, values[0])
} }
responseTable.RawSetString("headers", headersTable) responseTable.RawSetString("headers", headersTable)
fetchLogger.Trace("Full response table: %v", responseTable)
L.Push(responseTable) L.Push(responseTable)
fetchLogger.Debug("Pushed response table to Lua stack")
return 1 return 1
} }
func EvalRegex(L *lua.LState) int {
evalRegexLogger := processorLogger.WithPrefix("evalRegex")
evalRegexLogger.Debug("Lua evalRegex function called")
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

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

View File

@@ -30,7 +30,7 @@ func normalizeWhitespace(s string) string {
return re.ReplaceAllString(strings.TrimSpace(s), " ") 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{ command := utils.ModifyCommand{
Regex: regex, Regex: regex,
Lua: lua, Lua: lua,
@@ -79,7 +79,7 @@ func TestSimpleValueMultiplication(t *testing.T) {
</item> </item>
</config>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches) assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -100,7 +100,7 @@ func TestShorthandNotation(t *testing.T) {
</item> </item>
</config>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches) assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -121,7 +121,7 @@ func TestShorthandNotationFloats(t *testing.T) {
</item> </item>
</config>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches) assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -146,7 +146,7 @@ func TestArrayNotation(t *testing.T) {
</prices> </prices>
</config>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches) assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches)
@@ -167,7 +167,7 @@ func TestMultipleNumericMatches(t *testing.T) {
<entry>400</entry> <entry>400</entry>
</data>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches) assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches)
@@ -186,7 +186,7 @@ func TestMultipleStringMatches(t *testing.T) {
<name>Mary_modified</name> <name>Mary_modified</name>
</data>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches) assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
@@ -205,7 +205,7 @@ func TestStringUpperCase(t *testing.T) {
<user>MARY</user> <user>MARY</user>
</users>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches) assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
@@ -224,7 +224,7 @@ func TestStringConcatenation(t *testing.T) {
<product>Banana_fruit</product> <product>Banana_fruit</product>
</products>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches) 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>`) regex := regexp.MustCompile(`(?s)<value>([0-9.]+)</value>.*?<multiplier>([0-9.]+)</multiplier>`)
luaExpr := BuildLuaScript("v1 = v1 * v2") 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) assert.NoError(t, err, "Error processing content: %v", err)
normalizedModified := normalizeWhitespace(result) normalizedModified := normalizeWhitespace(result)
@@ -282,7 +282,7 @@ func TestLuaMathFunctions(t *testing.T) {
regex := regexp.MustCompile(`(?s)<value>(\d+)</value>`) regex := regexp.MustCompile(`(?s)<value>(\d+)</value>`)
luaExpr := BuildLuaScript("v1 = math.sqrt(v1)") 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) assert.NoError(t, err, "Error processing content: %v", err)
normalizedModified := normalizeWhitespace(modifiedContent) normalizedModified := normalizeWhitespace(modifiedContent)
@@ -310,7 +310,7 @@ func TestDirectAssignment(t *testing.T) {
regex := regexp.MustCompile(`(?s)<value>(\d+)</value>`) regex := regexp.MustCompile(`(?s)<value>(\d+)</value>`)
luaExpr := BuildLuaScript("=0") 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) assert.NoError(t, err, "Error processing content: %v", err)
normalizedModified := normalizeWhitespace(modifiedContent) normalizedModified := normalizeWhitespace(modifiedContent)
@@ -369,7 +369,7 @@ func TestStringAndNumericOperations(t *testing.T) {
luaExpr := BuildLuaScript(tt.luaExpression) luaExpr := BuildLuaScript(tt.luaExpression)
// Process with our function // 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) assert.NoError(t, err, "Process function failed: %v", err)
// Check results // Check results
@@ -430,7 +430,7 @@ func TestEdgeCases(t *testing.T) {
luaExpr := BuildLuaScript(tt.luaExpression) luaExpr := BuildLuaScript(tt.luaExpression)
// Process with our function // 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) assert.NoError(t, err, "Process function failed: %v", err)
// Check results // Check results
@@ -453,7 +453,7 @@ func TestNamedCaptureGroups(t *testing.T) {
</item> </item>
</config>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches) assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -474,7 +474,7 @@ func TestNamedCaptureGroupsNum(t *testing.T) {
</item> </item>
</config>` </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.NoError(t, err, "Error processing content: %v", err)
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches) assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
@@ -495,7 +495,7 @@ func TestMultipleNamedCaptureGroups(t *testing.T) {
<quantity>15</quantity> <quantity>15</quantity>
</product>` </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>`, `(?s)<name>(?<prodName>[^<]+)</name>.*?<price>(?<prodPrice>\d+\.\d+)</price>.*?<quantity>(?<prodQty>\d+)</quantity>`,
`prodName = string.upper(prodName) `prodName = string.upper(prodName)
prodPrice = round(prodPrice + 8, 2) prodPrice = round(prodPrice + 8, 2)
@@ -518,7 +518,7 @@ func TestMixedIndexedAndNamedCaptures(t *testing.T) {
<data>VALUE</data> <data>VALUE</data>
</entry>` </entry>`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`(?s)<id>(\d+)</id>.*?<data>(?<dataField>[^<]+)</data>`, `(?s)<id>(\d+)</id>.*?<data>(?<dataField>[^<]+)</data>`,
`v1 = v1 * 2 `v1 = v1 * 2
dataField = string.upper(dataField)`) dataField = string.upper(dataField)`)
@@ -550,7 +550,7 @@ func TestComplexNestedNamedCaptures(t *testing.T) {
</contact> </contact>
</person>` </person>`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`(?s)<details>.*?<name>(?<fullName>[^<]+)</name>.*?<age>(?<age>\d+)</age>`, `(?s)<details>.*?<name>(?<fullName>[^<]+)</name>.*?<age>(?<age>\d+)</age>`,
`fullName = string.upper(fullName) .. " (" .. age .. ")"`) `fullName = string.upper(fullName) .. " (" .. age .. ")"`)
@@ -571,7 +571,7 @@ func TestNamedCaptureWithVariableReadback(t *testing.T) {
<mana>300</mana> <mana>300</mana>
</stats>` </stats>`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`(?s)<health>(?<hp>\d+)</health>.*?<mana>(?<mp>\d+)</mana>`, `(?s)<health>(?<hp>\d+)</health>.*?<mana>(?<mp>\d+)</mana>`,
`hp = hp * 1.5 `hp = hp * 1.5
mp = mp * 1.5`) mp = mp * 1.5`)
@@ -587,7 +587,7 @@ func TestNamedCaptureWithSpecialCharsInName(t *testing.T) {
expected := `<data value="84" min="10" max="100" />` 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+)"`, `<data value="(?<val_1>\d+)"`,
`val_1 = val_1 * 2`) `val_1 = val_1 * 2`)
@@ -602,7 +602,7 @@ func TestEmptyNamedCapture(t *testing.T) {
expected := `<tag attr="default" />` expected := `<tag attr="default" />`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`attr="(?<value>.*?)"`, `attr="(?<value>.*?)"`,
`value = value == "" and "default" or 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" />` 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>\d+)" y="(?<y>\d+)" width="(?<w>\d+)" height="(?<h>\d+)"`,
`x = x * 2 `x = x * 2
y = y * 2 y = y * 2
@@ -641,7 +641,7 @@ func TestConditionalNamedCapture(t *testing.T) {
<item status="inactive" count="10" /> <item status="inactive" count="10" />
` `
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`<item status="(?<status>[^"]+)" count="(?<count>\d+)"`, `<item status="(?<status>[^"]+)" count="(?<count>\d+)"`,
`count = status == "active" and count * 2 or count`) `count = status == "active" and count * 2 or count`)
@@ -662,7 +662,7 @@ func TestLuaFunctionsOnNamedCaptures(t *testing.T) {
<user name="JANE SMITH" role="admin" /> <user name="JANE SMITH" role="admin" />
` `
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`<user name="(?<name>[^"]+)" role="(?<role>[^"]+)"`, `<user name="(?<name>[^"]+)" role="(?<role>[^"]+)"`,
`-- Capitalize first letters for regular users `-- Capitalize first letters for regular users
if role == "user" then if role == "user" then
@@ -692,7 +692,7 @@ func TestNamedCaptureWithMath(t *testing.T) {
<item price="19.99" quantity="3" total="59.97" /> <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$`, `<item price="(?<price>\d+\.\d+)" quantity="(?<qty>\d+)"!any$`,
`-- Calculate and add total `-- Calculate and add total
replacement = string.format('<item price="%s" quantity="%s" total="%.2f" />', 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>` 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>`, `<temp unit="(?<unit>[CF]?)">(?<value>\d+)</temp>`,
`if unit == "C" then `if unit == "C" then
value = value * 9/5 + 32 value = value * 9/5 + 32
@@ -739,7 +739,7 @@ func TestMixedDynamicAndNamedCaptures(t *testing.T) {
<color rgb="0,255,0" name="GREEN" hex="#00FF00" /> <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>[^"]+)" />`, `<color rgb="(?<r>\d+),(?<g>\d+),(?<b>\d+)" name="(?<colorName>[^"]+)" />`,
`-- Uppercase the name `-- Uppercase the name
colorName = string.upper(colorName) colorName = string.upper(colorName)
@@ -765,7 +765,7 @@ func TestNamedCapturesWithMultipleReferences(t *testing.T) {
expected := `<text format="uppercase" length="11">HELLO WORLD</text>` expected := `<text format="uppercase" length="11">HELLO WORLD</text>`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`<text>(?<content>[^<]+)</text>`, `<text>(?<content>[^<]+)</text>`,
`local uppercaseContent = string.upper(content) `local uppercaseContent = string.upper(content)
local contentLength = string.len(content) local contentLength = string.len(content)
@@ -783,7 +783,7 @@ func TestNamedCaptureWithJsonData(t *testing.T) {
expected := `<data>{"name":"JOHN","age":30}</data>` expected := `<data>{"name":"JOHN","age":30}</data>`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`<data>(?<json>\{.*?\})</data>`, `<data>(?<json>\{.*?\})</data>`,
`-- Parse JSON (simplified, assumes valid JSON) `-- Parse JSON (simplified, assumes valid JSON)
local name = json:match('"name":"([^"]+)"') local name = json:match('"name":"([^"]+)"')
@@ -813,7 +813,7 @@ func TestNamedCaptureInXML(t *testing.T) {
</product> </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>`, `(?s)<price currency="(?<currency>[^"]+)">(?<price>\d+\.\d+)</price>.*?<stock>(?<stock>\d+)</stock>`,
`-- Add 20% to price if USD `-- Add 20% to price if USD
if currency == "USD" then if currency == "USD" then
@@ -870,7 +870,7 @@ func TestComprehensiveNamedCaptures(t *testing.T) {
</products> </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>`, `(?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 `-- Only process in-stock items
if status == "in-stock" then if status == "in-stock" then
@@ -924,7 +924,7 @@ func TestVariousNamedCaptureFormats(t *testing.T) {
</data> </data>
` `
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`<entry id="(?<id_num>\d+)" value="(?<val>\d+)"(?: status="(?<status>[^"]*)")? />`, `<entry id="(?<id_num>\d+)" value="(?<val>\d+)"(?: status="(?<status>[^"]*)")? />`,
`-- Prefix the ID with "ID-" `-- Prefix the ID with "ID-"
id_num = "ID-" .. id_num id_num = "ID-" .. id_num
@@ -963,7 +963,7 @@ func TestSimpleNamedCapture(t *testing.T) {
expected := `<product name="WIDGET" price="19.99"/>` expected := `<product name="WIDGET" price="19.99"/>`
result, mods, matches, err := ApiAdaptor(content, result, mods, matches, err := APIAdaptor(content,
`name="(?<product_name>[^"]+)"`, `name="(?<product_name>[^"]+)"`,
`product_name = string.upper(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,15 +1,19 @@
package utils package utils
import ( import (
"fmt" "errors"
"path/filepath" "path/filepath"
"time" "time"
"git.site.quack-lab.dev/dave/cylogger" logger "git.site.quack-lab.dev/dave/cylogger"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
) )
// dbLogger is a scoped logger for the utils/db package.
var dbLogger = logger.Default.WithPrefix("utils/db")
type DB interface { type DB interface {
DB() *gorm.DB DB() *gorm.DB
Raw(sql string, args ...any) *gorm.DB Raw(sql string, args ...any) *gorm.DB
@@ -29,92 +33,130 @@ type DBWrapper struct {
db *gorm.DB db *gorm.DB
} }
var db *DBWrapper var globalDB *DBWrapper
func GetDB() (DB, error) { func GetDB() (DB, error) {
getDBLogger := dbLogger.WithPrefix("GetDB")
getDBLogger.Debug("Attempting to get database connection")
var err error var err error
dbFile := filepath.Join("data.sqlite") dbFile := filepath.Join("data.sqlite")
getDBLogger.Debug("Opening database file: %q", dbFile)
getDBLogger.Trace("Database configuration: PrepareStmt=true, GORM logger=Silent")
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
// SkipDefaultTransaction: true, // SkipDefaultTransaction: true,
PrepareStmt: true, PrepareStmt: true,
// Logger: gormlogger.Default.LogMode(gormlogger.Silent), Logger: gormlogger.Default.LogMode(gormlogger.Silent),
}) })
if err != nil { if err != nil {
getDBLogger.Error("Failed to open database file %q: %v", dbFile, err)
return nil, err return nil, err
} }
db.AutoMigrate(&FileSnapshot{}) getDBLogger.Debug("Database opened successfully, running auto migration for FileSnapshot model")
if err := db.AutoMigrate(&FileSnapshot{}); err != nil {
getDBLogger.Error("Auto migration failed for FileSnapshot model: %v", err)
return nil, err
}
getDBLogger.Info("Database initialized and migrated successfully")
return &DBWrapper{db: db}, nil globalDB = &DBWrapper{db: db}
getDBLogger.Debug("Database wrapper initialized and cached globally")
return globalDB, nil
} }
// Just a wrapper // Just a wrapper
func (db *DBWrapper) Raw(sql string, args ...any) *gorm.DB { func (db *DBWrapper) Raw(sql string, args ...any) *gorm.DB {
rawLogger := dbLogger.WithPrefix("Raw").WithField("sql", sql)
rawLogger.Debug("Executing raw SQL query with args: %v", args)
return db.db.Raw(sql, args...) return db.db.Raw(sql, args...)
} }
func (db *DBWrapper) DB() *gorm.DB { func (db *DBWrapper) DB() *gorm.DB {
dbLogger.WithPrefix("DB").Debug("Returning GORM DB instance")
return db.db return db.db
} }
func (db *DBWrapper) FileExists(filePath string) (bool, error) { func (db *DBWrapper) FileExists(filePath string) (bool, error) {
fileExistsLogger := dbLogger.WithPrefix("FileExists").WithField("filePath", filePath)
fileExistsLogger.Debug("Checking if file exists in database")
var count int64 var count int64
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).Count(&count).Error err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).Count(&count).Error
if err != nil {
fileExistsLogger.Error("Error checking if file exists: %v", err)
return false, err
}
fileExistsLogger.Debug("File exists: %t", count > 0)
return count > 0, err return count > 0, err
} }
func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error { func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
log := cylogger.Default.WithPrefix(fmt.Sprintf("SaveFile: %q", filePath)) saveFileLogger := dbLogger.WithPrefix("SaveFile").WithField("filePath", filePath).WithField("dataSize", len(fileData))
saveFileLogger.Debug("Attempting to save file to database")
saveFileLogger.Trace("File data length: %d", len(fileData))
exists, err := db.FileExists(filePath) exists, err := db.FileExists(filePath)
if err != nil { if err != nil {
log.Error("Error checking if file exists: %v", err) saveFileLogger.Error("Error checking if file exists: %v", err)
return err return err
} }
log.Debug("File exists: %t", exists)
// Nothing to do, file already exists
if exists { if exists {
log.Debug("File already exists, skipping save") saveFileLogger.Debug("File already exists in database, skipping save to avoid overwriting original snapshot")
return nil return nil
} }
log.Debug("Saving file to database") saveFileLogger.Debug("Creating new file snapshot in database")
return db.db.Create(&FileSnapshot{ err = db.db.Create(&FileSnapshot{
Date: time.Now(), Date: time.Now(),
FilePath: filePath, FilePath: filePath,
FileData: fileData, FileData: fileData,
}).Error }).Error
if err != nil {
saveFileLogger.Error("Failed to create file snapshot: %v", err)
} else {
saveFileLogger.Info("File successfully saved to database")
}
return err
} }
func (db *DBWrapper) GetFile(filePath string) ([]byte, error) { func (db *DBWrapper) GetFile(filePath string) ([]byte, error) {
log := cylogger.Default.WithPrefix(fmt.Sprintf("GetFile: %q", filePath)) getFileLogger := dbLogger.WithPrefix("GetFile").WithField("filePath", filePath)
log.Debug("Getting file from database") getFileLogger.Debug("Getting file from database")
var fileSnapshot FileSnapshot var fileSnapshot FileSnapshot
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error
if err != nil { if err != nil {
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 return nil, err
} }
log.Debug("File found in database") getFileLogger.Debug("File found in database")
getFileLogger.Trace("Retrieved file data length: %d", len(fileSnapshot.FileData))
return fileSnapshot.FileData, nil return fileSnapshot.FileData, nil
} }
func (db *DBWrapper) GetAllFiles() ([]FileSnapshot, error) { func (db *DBWrapper) GetAllFiles() ([]FileSnapshot, error) {
log := cylogger.Default.WithPrefix("GetAllFiles") getAllFilesLogger := dbLogger.WithPrefix("GetAllFiles")
log.Debug("Getting all files from database") getAllFilesLogger.Debug("Getting all files from database")
var fileSnapshots []FileSnapshot var fileSnapshots []FileSnapshot
err := db.db.Model(&FileSnapshot{}).Find(&fileSnapshots).Error err := db.db.Model(&FileSnapshot{}).Find(&fileSnapshots).Error
if err != nil { if err != nil {
getAllFilesLogger.Error("Failed to get all files from database: %v", err)
return nil, err return nil, err
} }
log.Debug("Found %d files in database", len(fileSnapshots)) getAllFilesLogger.Debug("Found %d files in database", len(fileSnapshots))
getAllFilesLogger.Trace("File snapshots retrieved: %v", fileSnapshots)
return fileSnapshots, nil return fileSnapshots, nil
} }
func (db *DBWrapper) RemoveAllFiles() error { func (db *DBWrapper) RemoveAllFiles() error {
log := cylogger.Default.WithPrefix("RemoveAllFiles") removeAllFilesLogger := dbLogger.WithPrefix("RemoveAllFiles")
log.Debug("Removing all files from database") removeAllFilesLogger.Debug("Removing all files from database")
err := db.db.Exec("DELETE FROM file_snapshots").Error err := db.db.Exec("DELETE FROM file_snapshots").Error
if err != nil { if err != nil {
return err removeAllFilesLogger.Error("Failed to remove all files from database: %v", err)
} else {
removeAllFilesLogger.Debug("All files removed from database")
} }
log.Debug("All files removed from database") return err
return nil
} }

View File

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

View File

@@ -1,10 +0,0 @@
package utils
import (
"flag"
)
var (
ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel")
Filter = flag.String("f", "", "Filter commands before running them")
)

View File

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

View File

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