Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98e05f4998 | |||
| 2fa99ec3a2 | |||
| 8dd212fc71 | |||
| a4bbaf9f27 | |||
| 419a8118fc | |||
| da5b621cb6 | |||
| 1df0263a42 | |||
| 74394cbde9 | |||
| f1ea0f9156 | |||
| fff8869aff | |||
| a0c5a5f18c | |||
| b309e3e6f0 | |||
| 09cdc91761 | |||
| a18573c9f8 | |||
| eacc92ce4b | |||
| 3bcc958dda | |||
| 11f0bbee53 | |||
| c145ad0900 | |||
| e02c1f018f | |||
| 07fea6238f | |||
| 5f1fdfa6c1 | |||
| 4fb25d0463 | |||
| bf23894188 | |||
| aec0f9f171 | |||
| 83fed68432 | |||
| 4311533445 | |||
| ce28b948d0 | |||
| efc602e0ba | |||
| 917063db0c | |||
| 3e552428a5 | |||
| 50455c491d | |||
| 12ec399b09 | |||
| 5a49998c2c | |||
| 590f19603e | |||
| ee8c4b9aa5 | |||
| e8d6613ac8 | |||
| 91ad9006fa | |||
| 60ba3ad417 | |||
| b74e4724d4 | |||
| 30246fd626 | |||
| 06aed7b27a | |||
| b001dfe667 | |||
| d905ad027a | |||
| 3f6a03aee8 | |||
| 302e874710 | |||
| 9d9820072a | |||
| 53d14345b9 | |||
| 67c3346f2f | |||
| 346afdd143 | |||
| 48729cdfa4 | |||
| b9574f0106 | |||
| 635ca463c0 | |||
| 2459988ff0 | |||
| 6ab08fe97f | |||
| 2dafe4a981 | |||
| ec24e0713d | |||
| 969ccae25c | |||
| 5b46ff0efd | |||
| d234616406 | |||
| af3e55e518 | |||
| 13b48229ac | |||
| 670f6ed7a0 | |||
| bbc7c50fae | |||
| 779d1e0a0e | |||
| 54581f0216 | |||
| 3d01822e77 | |||
| 4e0ca92c77 | |||
| 388e54b3e3 | |||
| 6f2e76221a | |||
| e0d3b938e3 | |||
| 491a030bf8 | |||
| bff7cc2a27 | |||
| ff30b00e71 | |||
| e1eb5eeaa6 | |||
| 2a2e11d8e0 | |||
| 6eb4f31127 | |||
| 4b58e00c26 | |||
| 8ffd8af13c | |||
| 67861d4455 | |||
| 299e6d8bfe | |||
| 388822e90a | |||
| 91993b4548 | |||
| bb69558aaa | |||
| 052c670627 | |||
| 67fd215d0e | |||
| 9ecbbff6fa | |||
| 774ac0f0ca | |||
| b785d24a08 | |||
| 22f991e72e | |||
| 5518b27663 | |||
| 0b899dea2c | |||
| 3424fea8ad | |||
| ddc1d83d58 | |||
| 4b0a85411d | |||
| 46e871b626 | |||
| 258dcc88e7 | |||
| 75bf449bed | |||
| 58586395fb | |||
| c5a68af5e6 | |||
| b4c0284734 | |||
| c5d1dad8de | |||
| 4ff2ee80ee | |||
| 633eebfd2a | |||
| 5a31703840 | |||
| 162d0c758d | |||
| 14d64495b6 | |||
| fe6e97e832 | |||
| 35b3d8b099 | |||
| 2e3e958e15 | |||
| 955afc4295 | |||
| 2c487bc443 | |||
| b77224176b | |||
| a2201053c5 | |||
| 04cedf5ece | |||
| ebb07854cc | |||
| 8a86ae2f40 | |||
| e8f16dda2b | |||
| 513773f641 | |||
| 22914fe243 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
*.exe
|
||||
.qodo
|
||||
*.sqlite
|
||||
.cursor/rules
|
||||
|
||||
79
.vscode/launch.json
vendored
79
.vscode/launch.json
vendored
@@ -12,11 +12,23 @@
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Barotrauma",
|
||||
"args": [
|
||||
"loglevel",
|
||||
"-loglevel",
|
||||
"trace",
|
||||
"(?-s)LightComponent!anyrange=\"(!num)\"",
|
||||
"*4",
|
||||
"**/Outpost*.xml"
|
||||
"-cook",
|
||||
"*.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Payday 2)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Payday2",
|
||||
"args": [
|
||||
"-loglevel",
|
||||
"trace",
|
||||
"*.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -33,18 +45,71 @@
|
||||
"cookassistant.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Quasimorph cookfile)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Quasimorph",
|
||||
"args": [
|
||||
"cook.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Rimworld cookfile)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Rimworld/294100",
|
||||
"args": [
|
||||
"cookVehicles.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Workspace)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"args": [
|
||||
"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",
|
||||
"(?-s)LightComponent!anyrange=\"(!num)\"",
|
||||
"*4",
|
||||
"**/Outpost*.xml"
|
||||
"cook_processorrecipes.yml",
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
82
README.md
82
README.md
@@ -16,6 +16,7 @@ A Go-based tool for modifying XML, JSON, and text documents using XPath/JSONPath
|
||||
- String manipulations
|
||||
- Date conversions
|
||||
- Structural changes
|
||||
- CSV/TSV parsing with comments and headers
|
||||
- Whole ass Lua environment
|
||||
- **Error Handling**: Comprehensive error detection for:
|
||||
- Invalid XML/JSON
|
||||
@@ -101,6 +102,87 @@ chef -xml "//item" "if tonumber(v.stock) > 0 then v.price = v.price * 0.8 end" i
|
||||
<item stock="5" price="8.00"/>
|
||||
```
|
||||
|
||||
### 6. CSV/TSV Processing
|
||||
The Lua environment includes CSV parsing functions that support comments, headers, and custom delimiters.
|
||||
|
||||
```lua
|
||||
-- Basic CSV parsing
|
||||
local rows = fromCSV(csvText)
|
||||
|
||||
-- With options
|
||||
local rows = fromCSV(csvText, {
|
||||
delimiter = "\t", -- Tab delimiter for TSV (default: ",")
|
||||
hasHeaders = true, -- First row is headers (default: false)
|
||||
hasComments = true -- Filter lines starting with # (default: false)
|
||||
})
|
||||
|
||||
-- Access by index
|
||||
local value = rows[1][2]
|
||||
|
||||
-- Access by header name (when hasHeaders = true)
|
||||
local value = rows[1].Name
|
||||
|
||||
-- Convert back to CSV
|
||||
local csv = toCSV(rows, "\t") -- Optional delimiter parameter
|
||||
```
|
||||
|
||||
**Example with commented TSV file:**
|
||||
```lua
|
||||
-- Input file:
|
||||
-- #mercenary_profiles
|
||||
-- Id Name Value
|
||||
-- 1 Test 100
|
||||
-- 2 Test2 200
|
||||
|
||||
local csv = readFile("mercenaries.tsv")
|
||||
local rows = fromCSV(csv, {
|
||||
delimiter = "\t",
|
||||
hasHeaders = true,
|
||||
hasComments = true
|
||||
})
|
||||
|
||||
-- Access data
|
||||
rows[1].Name -- "Test"
|
||||
rows[2].Value -- "200"
|
||||
```
|
||||
|
||||
## Lua Helper Functions
|
||||
|
||||
The Lua environment includes many helper functions:
|
||||
|
||||
### Math Functions
|
||||
- `min(a, b)`, `max(a, b)` - Min/max of two numbers
|
||||
- `round(x, n)` - Round to n decimal places
|
||||
- `floor(x)`, `ceil(x)` - Floor/ceiling functions
|
||||
|
||||
### String Functions
|
||||
- `upper(s)`, `lower(s)` - Case conversion
|
||||
- `trim(s)` - Remove leading/trailing whitespace
|
||||
- `format(s, ...)` - String formatting
|
||||
- `strsplit(inputstr, sep)` - Split string by separator
|
||||
|
||||
### CSV Functions
|
||||
- `fromCSV(csv, options)` - Parse CSV/TSV text into table of rows
|
||||
- Options: `delimiter` (default: ","), `hasHeaders` (default: false), `hasComments` (default: false)
|
||||
- `toCSV(rows, delimiter)` - Convert table of rows back to CSV text
|
||||
|
||||
### Conversion Functions
|
||||
- `num(str)` - Convert string to number (returns 0 if invalid)
|
||||
- `str(num)` - Convert number to string
|
||||
- `is_number(str)` - Check if string is numeric
|
||||
|
||||
### Table Functions
|
||||
- `isArray(t)` - Check if table is a sequential array
|
||||
- `dump(table, depth)` - Print table structure recursively
|
||||
|
||||
### HTTP Functions
|
||||
- `fetch(url, options)` - Make HTTP request, returns response table
|
||||
- Options: `method`, `headers`, `body`
|
||||
- Returns: `{status, statusText, ok, body, headers}`
|
||||
|
||||
### Regex Functions
|
||||
- `re(pattern, input)` - Apply regex pattern, returns table with matches
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"modify/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize logger with DEBUG level
|
||||
logger.Init(logger.LevelDebug)
|
||||
|
||||
// Test different log levels
|
||||
logger.Info("This is an info message")
|
||||
logger.Debug("This is a debug message")
|
||||
logger.Warning("This is a warning message")
|
||||
logger.Error("This is an error message")
|
||||
logger.Trace("This is a trace message (not visible at DEBUG level)")
|
||||
|
||||
// Test with a goroutine
|
||||
logger.SafeGo(func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
logger.Info("Message from goroutine")
|
||||
})
|
||||
|
||||
// Wait for goroutine to complete
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
544
example_cook.toml
Normal file
544
example_cook.toml
Normal file
@@ -0,0 +1,544 @@
|
||||
# Global variables - available to all commands
|
||||
[variables]
|
||||
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']
|
||||
|
||||
# HTTP fetch example - get version from API and update config
|
||||
[[commands]]
|
||||
name = 'UpdateVersionFromAPI'
|
||||
regex = 'version\s*=\s*"(?P<version>[^"]+)"'
|
||||
lua = '''
|
||||
local response = fetch("https://api.example.com/version", {
|
||||
method = "GET",
|
||||
headers = { ["Accept"] = "application/json" }
|
||||
})
|
||||
if response and response.body then
|
||||
local data = fromJSON(response.body)
|
||||
if data.latest then
|
||||
version = data.latest
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
'''
|
||||
files = ['version.conf']
|
||||
|
||||
# Complex multiline block replacement with state machine
|
||||
[[commands]]
|
||||
name = 'ModifyConfigBlock'
|
||||
regex = '''(?x)
|
||||
\[server\]
|
||||
\s+host\s*=\s*"(?P<host>[^"]+)"
|
||||
\s+port\s*=\s*(?P<port>\d+)
|
||||
\s+ssl\s*=\s*(?P<ssl>true|false)'''
|
||||
lua = '''
|
||||
port = num(port) + 1000
|
||||
ssl = "true"
|
||||
replacement = format('[server]\n host = "%s"\n port = %d\n ssl = %s', host, port, ssl)
|
||||
return true
|
||||
'''
|
||||
files = ['server.conf']
|
||||
|
||||
# Regex with !any to capture entire sections
|
||||
[[commands]]
|
||||
name = 'WrapInComment'
|
||||
regex = 'FEATURE_START\n(?P<feature>!any)\nFEATURE_END'
|
||||
lua = '''
|
||||
replacement = "FEATURE_START\n# " .. feature:gsub("\n", "\n# ") .. "\nFEATURE_END"
|
||||
return true
|
||||
'''
|
||||
files = ['features/**/*.txt']
|
||||
|
||||
# Advanced capture groups with complex logic
|
||||
[[commands]]
|
||||
name = 'UpdateDependencies'
|
||||
regex = 'dependency\("(?P<group>[^"]+)", "(?P<name>[^"]+)", "(?P<version>[^"]+)"\)'
|
||||
lua = '''
|
||||
local major, minor, patch = version:match("(%d+)%.(%d+)%.(%d+)")
|
||||
if major and minor and patch then
|
||||
-- Bump minor version
|
||||
minor = num(minor) + 1
|
||||
version = format("%s.%s.0", major, minor)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
'''
|
||||
files = ['build.gradle', 'build.gradle.kts']
|
||||
|
||||
# JSON mode examples - modify single field
|
||||
[[commands]]
|
||||
name = 'JSONModifyField'
|
||||
json = true
|
||||
lua = '''
|
||||
data.value = 84
|
||||
modified = true
|
||||
'''
|
||||
files = ['data/**/*.json']
|
||||
|
||||
# JSON mode - add new field
|
||||
[[commands]]
|
||||
name = 'JSONAddField'
|
||||
json = true
|
||||
lua = '''
|
||||
data.newField = "added"
|
||||
modified = true
|
||||
'''
|
||||
files = ['config/**/*.json']
|
||||
|
||||
# JSON mode - modify nested fields
|
||||
[[commands]]
|
||||
name = 'JSONNestedModify'
|
||||
json = true
|
||||
lua = '''
|
||||
if data.config and data.config.settings then
|
||||
data.config.settings.enabled = true
|
||||
data.config.settings.timeout = 60
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['settings/**/*.json']
|
||||
|
||||
# JSON mode - modify array elements
|
||||
[[commands]]
|
||||
name = 'JSONArrayMultiply'
|
||||
json = true
|
||||
lua = '''
|
||||
if data.items then
|
||||
for i, item in ipairs(data.items) do
|
||||
data.items[i].value = item.value * multiply
|
||||
end
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['data/**/*.json']
|
||||
|
||||
# JSON mode - modify object version
|
||||
[[commands]]
|
||||
name = 'JSONObjectUpdate'
|
||||
json = true
|
||||
lua = '''
|
||||
data.version = "2.0.0"
|
||||
data.enabled = enabled
|
||||
modified = true
|
||||
'''
|
||||
files = ['config/**/*.json']
|
||||
|
||||
# JSON mode - surgical editing of specific row
|
||||
[[commands]]
|
||||
name = 'JSONSurgicalEdit'
|
||||
json = true
|
||||
lua = '''
|
||||
if data.Rows and data.Rows[1] then
|
||||
data.Rows[1].Weight = 999
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['items/**/*.json']
|
||||
|
||||
# JSON mode - remove array elements conditionally
|
||||
[[commands]]
|
||||
name = 'JSONRemoveDisabled'
|
||||
json = true
|
||||
lua = '''
|
||||
if data.features then
|
||||
local i = 1
|
||||
while i <= #data.features do
|
||||
if data.features[i].enabled == false then
|
||||
table.remove(data.features, i)
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['config/**/*.json']
|
||||
|
||||
# JSON mode - deep nested object manipulation
|
||||
[[commands]]
|
||||
name = 'JSONDeepUpdate'
|
||||
json = true
|
||||
lua = '''
|
||||
if data.game and data.game.balance and data.game.balance.economy then
|
||||
local econ = data.game.balance.economy
|
||||
econ.inflation = (econ.inflation or 1.0) * 1.05
|
||||
econ.taxRate = 0.15
|
||||
econ.lastUpdate = os.date("%Y-%m-%d")
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['settings/**/*.json']
|
||||
|
||||
# JSON mode - iterate and transform all matching objects
|
||||
[[commands]]
|
||||
name = 'JSONTransformItems'
|
||||
json = true
|
||||
lua = '''
|
||||
local function processItem(item)
|
||||
if item.type == "weapon" and item.damage then
|
||||
item.damage = item.damage * multiply
|
||||
item.modified = true
|
||||
end
|
||||
end
|
||||
|
||||
if data.items then
|
||||
for _, item in ipairs(data.items) do
|
||||
processItem(item)
|
||||
end
|
||||
modified = true
|
||||
elseif data.inventory then
|
||||
for _, item in ipairs(data.inventory) do
|
||||
processItem(item)
|
||||
end
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['data/**/*.json']
|
||||
|
||||
# CSV processing example - read, modify, write
|
||||
[[commands]]
|
||||
name = 'CSVProcess'
|
||||
regex = '(?P<csv>!any)'
|
||||
lua = '''
|
||||
local rows = fromCSV(csv, { hasheader = true })
|
||||
for i, row in ipairs(rows) do
|
||||
if row.Value then
|
||||
row.Value = num(row.Value) * multiply
|
||||
end
|
||||
end
|
||||
replacement = toCSV(rows, { hasheader = true })
|
||||
return true
|
||||
'''
|
||||
files = ['data/**/*.csv']
|
||||
|
||||
# CSV processing with custom delimiter (TSV)
|
||||
[[commands]]
|
||||
name = 'TSVProcess'
|
||||
regex = '(?P<tsv>!any)'
|
||||
lua = '''
|
||||
local rows = fromCSV(tsv, { delimiter = "\t", hasheader = true, hascomments = true })
|
||||
for i, row in ipairs(rows) do
|
||||
if row.Price then
|
||||
row.Price = num(row.Price) * 1.1
|
||||
end
|
||||
end
|
||||
replacement = toCSV(rows, { delimiter = "\t", hasheader = true })
|
||||
return true
|
||||
'''
|
||||
files = ['data/**/*.tsv']
|
||||
|
||||
# CSV processing - modify specific columns
|
||||
[[commands]]
|
||||
name = 'CSVModifyColumns'
|
||||
regex = '(?P<csv>!any)'
|
||||
lua = '''
|
||||
local rows = fromCSV(csv, { hasheader = true })
|
||||
for i, row in ipairs(rows) do
|
||||
if row.Name then
|
||||
row.Name = prefix .. row.Name
|
||||
end
|
||||
if row.Status then
|
||||
row.Status = upper(row.Status)
|
||||
end
|
||||
end
|
||||
replacement = toCSV(rows, { hasheader = true })
|
||||
return true
|
||||
'''
|
||||
files = ['exports/**/*.csv']
|
||||
|
||||
# XML mode - multiply numeric attributes using helper functions
|
||||
[[commands]]
|
||||
name = 'XMLMultiplyAttributes'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
visitElements(data, function(elem)
|
||||
if elem._tag == "Item" then
|
||||
modifyNumAttr(elem, "Weight", function(val) return val * multiply end)
|
||||
modifyNumAttr(elem, "Value", function(val) return val * foobar end)
|
||||
end
|
||||
end)
|
||||
modified = true
|
||||
'''
|
||||
files = ['game/**/*.xml']
|
||||
|
||||
# XML mode - modify specific element attributes
|
||||
[[commands]]
|
||||
name = 'XMLUpdateAfflictions'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
local afflictions = findElements(data, "Affliction")
|
||||
for _, affliction in ipairs(afflictions) do
|
||||
local id = getAttr(affliction, "identifier")
|
||||
if id == "burn" or id == "bleeding" then
|
||||
modifyNumAttr(affliction, "strength", function(val) return val * 0.5 end)
|
||||
setAttr(affliction, "description", "Weakened effect")
|
||||
end
|
||||
end
|
||||
modified = true
|
||||
'''
|
||||
files = ['config/Afflictions.xml']
|
||||
|
||||
# XML mode - add new elements using helpers
|
||||
[[commands]]
|
||||
name = 'XMLAddItems'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
local items = findFirstElement(data, "Items")
|
||||
if items then
|
||||
local newItem = {
|
||||
_tag = "Item",
|
||||
_attr = {
|
||||
identifier = "new_item",
|
||||
Weight = "10",
|
||||
Value = "500"
|
||||
}
|
||||
}
|
||||
addChild(items, newItem)
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['items/**/*.xml']
|
||||
|
||||
# XML mode - remove elements by attribute value
|
||||
[[commands]]
|
||||
name = 'XMLRemoveDisabled'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
visitElements(data, function(elem)
|
||||
if elem._tag == "Feature" and getAttr(elem, "enabled") == "false" then
|
||||
-- Mark for removal (actual removal happens via parent)
|
||||
elem._remove = true
|
||||
end
|
||||
end)
|
||||
|
||||
-- Remove marked children
|
||||
visitElements(data, function(elem)
|
||||
if elem._children then
|
||||
local i = 1
|
||||
while i <= #elem._children do
|
||||
if elem._children[i]._remove then
|
||||
table.remove(elem._children, i)
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
modified = true
|
||||
'''
|
||||
files = ['config/**/*.xml']
|
||||
|
||||
# XML mode - conditional attribute updates based on other attributes
|
||||
[[commands]]
|
||||
name = 'XMLConditionalUpdate'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
visitElements(data, function(elem)
|
||||
if elem._tag == "Weapon" then
|
||||
local tier = getAttr(elem, "tier")
|
||||
if tier and num(tier) >= 3 then
|
||||
-- High tier weapons get damage boost
|
||||
modifyNumAttr(elem, "damage", function(val) return val * 1.5 end)
|
||||
setAttr(elem, "rarity", "legendary")
|
||||
end
|
||||
end
|
||||
end)
|
||||
modified = true
|
||||
'''
|
||||
files = ['weapons/**/*.xml']
|
||||
|
||||
# XML mode - modify nested elements
|
||||
[[commands]]
|
||||
name = 'XMLNestedModify'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
local config = findFirstElement(data, "Configuration")
|
||||
if config then
|
||||
local settings = findFirstElement(config, "Settings")
|
||||
if settings then
|
||||
setAttr(settings, "timeout", "120")
|
||||
setAttr(settings, "maxRetries", "5")
|
||||
|
||||
-- Add or update nested element
|
||||
local logging = findFirstElement(settings, "Logging")
|
||||
if not logging then
|
||||
logging = {
|
||||
_tag = "Logging",
|
||||
_attr = { level = "DEBUG", enabled = "true" }
|
||||
}
|
||||
addChild(settings, logging)
|
||||
else
|
||||
setAttr(logging, "level", "INFO")
|
||||
end
|
||||
end
|
||||
end
|
||||
modified = true
|
||||
'''
|
||||
files = ['config/**/*.xml']
|
||||
|
||||
# XML mode - batch attribute operations
|
||||
[[commands]]
|
||||
name = 'XMLBatchAttributeUpdate'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
-- Update all Price attributes across entire document
|
||||
visitElements(data, function(elem)
|
||||
if hasAttr(elem, "Price") then
|
||||
modifyNumAttr(elem, "Price", function(val) return val * 1.1 end)
|
||||
end
|
||||
if hasAttr(elem, "Cost") then
|
||||
modifyNumAttr(elem, "Cost", function(val) return val * 0.9 end)
|
||||
end
|
||||
end)
|
||||
modified = true
|
||||
'''
|
||||
files = ['economy/**/*.xml']
|
||||
|
||||
# XML mode - clone and modify elements
|
||||
[[commands]]
|
||||
name = 'XMLCloneItems'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
local items = findElements(data, "Item")
|
||||
local newItems = {}
|
||||
|
||||
for _, item in ipairs(items) do
|
||||
local id = getAttr(item, "identifier")
|
||||
if id and id:match("^weapon_") then
|
||||
-- Clone weapon as upgraded version
|
||||
local upgraded = {
|
||||
_tag = "Item",
|
||||
_attr = {
|
||||
identifier = id .. "_mk2",
|
||||
Weight = getAttr(item, "Weight"),
|
||||
Value = tostring(num(getAttr(item, "Value")) * 2)
|
||||
}
|
||||
}
|
||||
table.insert(newItems, upgraded)
|
||||
end
|
||||
end
|
||||
|
||||
-- Add all new items
|
||||
for _, newItem in ipairs(newItems) do
|
||||
addChild(data, newItem)
|
||||
end
|
||||
|
||||
if #newItems > 0 then
|
||||
modified = true
|
||||
end
|
||||
'''
|
||||
files = ['items/**/*.xml']
|
||||
|
||||
# XML mode - remove all children with specific tag
|
||||
[[commands]]
|
||||
name = 'XMLRemoveObsolete'
|
||||
regex = '(?P<xml>!any)'
|
||||
lua = '''
|
||||
visitElements(data, function(elem)
|
||||
-- Remove all "Deprecated" children
|
||||
removeChildren(elem, "Deprecated")
|
||||
removeChildren(elem, "Legacy")
|
||||
end)
|
||||
modified = true
|
||||
'''
|
||||
files = ['config/**/*.xml']
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"modify/utils"
|
||||
"cook/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -82,7 +82,7 @@ func TestGlobExpansion(t *testing.T) {
|
||||
for _, pattern := range tc.patterns {
|
||||
patternMap[pattern] = struct{}{}
|
||||
}
|
||||
files, err := utils.ExpandGLobs(patternMap)
|
||||
files, err := utils.ExpandGlobs(patternMap)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandGLobs failed: %v", err)
|
||||
}
|
||||
|
||||
47
go.mod
47
go.mod
@@ -1,38 +1,41 @@
|
||||
module modify
|
||||
module cook
|
||||
|
||||
go 1.24.1
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
git.site.quack-lab.dev/dave/cylogger v1.3.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/yuin/gopher-lua v1.1.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
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/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // 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/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/hexops/valast v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
mvdan.cc/gofumpt v0.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-git/go-git/v5 v5.14.0
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
)
|
||||
|
||||
131
go.sum
131
go.sum
@@ -1,106 +1,75 @@
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
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=
|
||||
git.site.quack-lab.dev/dave/cylogger v1.3.0 h1:eTWPUD+ThVi8kGIsRcE0XDeoH3yFb5miFEODyKUdWJw=
|
||||
git.site.quack-lab.dev/dave/cylogger v1.3.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
|
||||
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
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/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
|
||||
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hexops/autogold v0.8.1 h1:wvyd/bAJ+Dy+DcE09BoLk6r4Fa5R5W+O+GUzmR985WM=
|
||||
github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y=
|
||||
github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
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/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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
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/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
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/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 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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
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/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
417
isolate_test.go
Normal 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)
|
||||
}
|
||||
465
logger/logger.go
465
logger/logger.go
@@ -1,465 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogLevel defines the severity of log messages
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
// LevelError is for critical errors that should always be displayed
|
||||
LevelError LogLevel = iota
|
||||
// LevelWarning is for important warnings
|
||||
LevelWarning
|
||||
// LevelInfo is for informational messages
|
||||
LevelInfo
|
||||
// LevelDebug is for detailed debugging information
|
||||
LevelDebug
|
||||
// LevelTrace is for very detailed tracing information
|
||||
LevelTrace
|
||||
// LevelLua is specifically for output from Lua scripts
|
||||
LevelLua
|
||||
)
|
||||
|
||||
var levelNames = map[LogLevel]string{
|
||||
LevelError: "ERROR",
|
||||
LevelWarning: "WARNING",
|
||||
LevelInfo: "INFO",
|
||||
LevelDebug: "DEBUG",
|
||||
LevelTrace: "TRACE",
|
||||
LevelLua: "LUA",
|
||||
}
|
||||
|
||||
var levelColors = map[LogLevel]string{
|
||||
LevelError: "\033[1;31m", // Bold Red
|
||||
LevelWarning: "\033[1;33m", // Bold Yellow
|
||||
LevelInfo: "\033[1;32m", // Bold Green
|
||||
LevelDebug: "\033[1;36m", // Bold Cyan
|
||||
LevelTrace: "\033[1;35m", // Bold Magenta
|
||||
LevelLua: "\033[1;34m", // Bold Blue
|
||||
}
|
||||
|
||||
// ResetColor is the ANSI code to reset text color
|
||||
const ResetColor = "\033[0m"
|
||||
|
||||
// Logger is our custom logger with level support
|
||||
type Logger struct {
|
||||
mu sync.Mutex
|
||||
out io.Writer
|
||||
currentLevel LogLevel
|
||||
prefix string
|
||||
flag int
|
||||
useColors bool
|
||||
callerOffset int
|
||||
defaultFields map[string]interface{}
|
||||
showGoroutine bool
|
||||
}
|
||||
|
||||
var (
|
||||
// DefaultLogger is the global logger instance
|
||||
DefaultLogger *Logger
|
||||
// defaultLogLevel is the default log level if not specified
|
||||
defaultLogLevel = LevelInfo
|
||||
// Global mutex for DefaultLogger initialization
|
||||
initMutex sync.Mutex
|
||||
)
|
||||
|
||||
// ParseLevel converts a string log level to LogLevel
|
||||
func ParseLevel(levelStr string) LogLevel {
|
||||
switch strings.ToUpper(levelStr) {
|
||||
case "ERROR":
|
||||
return LevelError
|
||||
case "WARNING", "WARN":
|
||||
return LevelWarning
|
||||
case "INFO":
|
||||
return LevelInfo
|
||||
case "DEBUG":
|
||||
return LevelDebug
|
||||
case "TRACE":
|
||||
return LevelTrace
|
||||
case "LUA":
|
||||
return LevelLua
|
||||
default:
|
||||
return defaultLogLevel
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation of the log level
|
||||
func (l LogLevel) String() string {
|
||||
if name, ok := levelNames[l]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("Level(%d)", l)
|
||||
}
|
||||
|
||||
// New creates a new Logger instance
|
||||
func New(out io.Writer, prefix string, flag int) *Logger {
|
||||
return &Logger{
|
||||
out: out,
|
||||
currentLevel: defaultLogLevel,
|
||||
prefix: prefix,
|
||||
flag: flag,
|
||||
useColors: true,
|
||||
callerOffset: 0,
|
||||
defaultFields: make(map[string]interface{}),
|
||||
showGoroutine: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the DefaultLogger
|
||||
func Init(level LogLevel) {
|
||||
initMutex.Lock()
|
||||
defer initMutex.Unlock()
|
||||
|
||||
if DefaultLogger == nil {
|
||||
DefaultLogger = New(os.Stdout, "", log.Lmicroseconds|log.Lshortfile)
|
||||
}
|
||||
DefaultLogger.SetLevel(level)
|
||||
}
|
||||
|
||||
// SetLevel sets the current log level
|
||||
func (l *Logger) SetLevel(level LogLevel) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.currentLevel = level
|
||||
}
|
||||
|
||||
// GetLevel returns the current log level
|
||||
func (l *Logger) GetLevel() LogLevel {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.currentLevel
|
||||
}
|
||||
|
||||
// SetCallerOffset sets the caller offset for correct file and line reporting
|
||||
func (l *Logger) SetCallerOffset(offset int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.callerOffset = offset
|
||||
}
|
||||
|
||||
// SetShowGoroutine sets whether to include goroutine ID in log messages
|
||||
func (l *Logger) SetShowGoroutine(show bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.showGoroutine = show
|
||||
}
|
||||
|
||||
// ShowGoroutine returns whether goroutine ID is included in log messages
|
||||
func (l *Logger) ShowGoroutine() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.showGoroutine
|
||||
}
|
||||
|
||||
// WithField adds a field to the logger's context
|
||||
func (l *Logger) WithField(key string, value interface{}) *Logger {
|
||||
newLogger := &Logger{
|
||||
out: l.out,
|
||||
currentLevel: l.currentLevel,
|
||||
prefix: l.prefix,
|
||||
flag: l.flag,
|
||||
useColors: l.useColors,
|
||||
callerOffset: l.callerOffset,
|
||||
defaultFields: make(map[string]interface{}),
|
||||
showGoroutine: l.showGoroutine,
|
||||
}
|
||||
|
||||
// Copy existing fields
|
||||
for k, v := range l.defaultFields {
|
||||
newLogger.defaultFields[k] = v
|
||||
}
|
||||
|
||||
// Add new field
|
||||
newLogger.defaultFields[key] = value
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// WithFields adds multiple fields to the logger's context
|
||||
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
|
||||
newLogger := &Logger{
|
||||
out: l.out,
|
||||
currentLevel: l.currentLevel,
|
||||
prefix: l.prefix,
|
||||
flag: l.flag,
|
||||
useColors: l.useColors,
|
||||
callerOffset: l.callerOffset,
|
||||
defaultFields: make(map[string]interface{}),
|
||||
showGoroutine: l.showGoroutine,
|
||||
}
|
||||
|
||||
// Copy existing fields
|
||||
for k, v := range l.defaultFields {
|
||||
newLogger.defaultFields[k] = v
|
||||
}
|
||||
|
||||
// Add new fields
|
||||
for k, v := range fields {
|
||||
newLogger.defaultFields[k] = v
|
||||
}
|
||||
return newLogger
|
||||
}
|
||||
|
||||
// GetGoroutineID extracts the goroutine ID from the runtime stack
|
||||
func GetGoroutineID() string {
|
||||
buf := make([]byte, 64)
|
||||
n := runtime.Stack(buf, false)
|
||||
// Format of first line is "goroutine N [state]:"
|
||||
// We only need the N part
|
||||
buf = buf[:n]
|
||||
idField := bytes.Fields(bytes.Split(buf, []byte{':'})[0])[1]
|
||||
return string(idField)
|
||||
}
|
||||
|
||||
// formatMessage formats a log message with level, time, file, and line information
|
||||
func (l *Logger) formatMessage(level LogLevel, format string, args ...interface{}) string {
|
||||
var msg string
|
||||
if len(args) > 0 {
|
||||
msg = fmt.Sprintf(format, args...)
|
||||
} else {
|
||||
msg = format
|
||||
}
|
||||
|
||||
// Format default fields if any
|
||||
var fields string
|
||||
if len(l.defaultFields) > 0 {
|
||||
var pairs []string
|
||||
for k, v := range l.defaultFields {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
fields = " " + strings.Join(pairs, " ")
|
||||
}
|
||||
|
||||
var levelColor, resetColor string
|
||||
if l.useColors {
|
||||
levelColor = levelColors[level]
|
||||
resetColor = ResetColor
|
||||
}
|
||||
|
||||
var caller string
|
||||
if l.flag&log.Lshortfile != 0 || l.flag&log.Llongfile != 0 {
|
||||
// Find the actual caller by scanning up the stack
|
||||
// until we find a function outside the logger package
|
||||
var file string
|
||||
var line int
|
||||
var ok bool
|
||||
|
||||
// Start at a reasonable depth and scan up to 10 frames
|
||||
for depth := 4; depth < 15; depth++ {
|
||||
_, file, line, ok = runtime.Caller(depth)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
// If the caller is not in the logger package, we found our caller
|
||||
if !strings.Contains(file, "logger/logger.go") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
file = "???"
|
||||
line = 0
|
||||
}
|
||||
|
||||
if l.flag&log.Lshortfile != 0 {
|
||||
file = filepath.Base(file)
|
||||
}
|
||||
caller = fmt.Sprintf("%-25s ", file+":"+strconv.Itoa(line))
|
||||
}
|
||||
|
||||
// Format the timestamp with fixed width
|
||||
var timeStr string
|
||||
if l.flag&(log.Ldate|log.Ltime|log.Lmicroseconds) != 0 {
|
||||
t := time.Now()
|
||||
if l.flag&log.Ldate != 0 {
|
||||
timeStr += fmt.Sprintf("%04d/%02d/%02d ", t.Year(), t.Month(), t.Day())
|
||||
}
|
||||
if l.flag&(log.Ltime|log.Lmicroseconds) != 0 {
|
||||
timeStr += fmt.Sprintf("%02d:%02d:%02d", t.Hour(), t.Minute(), t.Second())
|
||||
if l.flag&log.Lmicroseconds != 0 {
|
||||
timeStr += fmt.Sprintf(".%06d", t.Nanosecond()/1000)
|
||||
}
|
||||
}
|
||||
timeStr = fmt.Sprintf("%-15s ", timeStr)
|
||||
}
|
||||
|
||||
// Add goroutine ID if enabled, with fixed width
|
||||
var goroutineStr string
|
||||
if l.showGoroutine {
|
||||
goroutineID := GetGoroutineID()
|
||||
goroutineStr = fmt.Sprintf("[g:%-4s] ", goroutineID)
|
||||
}
|
||||
|
||||
// Create a colored level indicator with both brackets colored
|
||||
levelStr := fmt.Sprintf("%s[%s]%s", levelColor, levelNames[level], levelColor)
|
||||
// Add a space after the level and before the reset color
|
||||
levelColumn := fmt.Sprintf("%s %s", levelStr, resetColor)
|
||||
|
||||
return fmt.Sprintf("%s%s%s%s%s%s%s\n",
|
||||
l.prefix, timeStr, caller, goroutineStr, levelColumn, msg, fields)
|
||||
}
|
||||
|
||||
// log logs a message at the specified level
|
||||
func (l *Logger) log(level LogLevel, format string, args ...interface{}) {
|
||||
// Always show LUA level logs regardless of the current log level
|
||||
if level != LevelLua && level > l.currentLevel {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
msg := l.formatMessage(level, format, args...)
|
||||
fmt.Fprint(l.out, msg)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *Logger) Error(format string, args ...interface{}) {
|
||||
l.log(LevelError, format, args...)
|
||||
}
|
||||
|
||||
// Warning logs a warning message
|
||||
func (l *Logger) Warning(format string, args ...interface{}) {
|
||||
l.log(LevelWarning, format, args...)
|
||||
}
|
||||
|
||||
// Info logs an informational message
|
||||
func (l *Logger) Info(format string, args ...interface{}) {
|
||||
l.log(LevelInfo, format, args...)
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *Logger) Debug(format string, args ...interface{}) {
|
||||
l.log(LevelDebug, format, args...)
|
||||
}
|
||||
|
||||
// Trace logs a trace message
|
||||
func (l *Logger) Trace(format string, args ...interface{}) {
|
||||
l.log(LevelTrace, format, args...)
|
||||
}
|
||||
|
||||
// Lua logs a Lua message
|
||||
func (l *Logger) Lua(format string, args ...interface{}) {
|
||||
l.log(LevelLua, format, args...)
|
||||
}
|
||||
|
||||
// Global log functions that use DefaultLogger
|
||||
|
||||
// Error logs an error message using the default logger
|
||||
func Error(format string, args ...interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.Error(format, args...)
|
||||
}
|
||||
|
||||
// Warning logs a warning message using the default logger
|
||||
func Warning(format string, args ...interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.Warning(format, args...)
|
||||
}
|
||||
|
||||
// Info logs an informational message using the default logger
|
||||
func Info(format string, args ...interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.Info(format, args...)
|
||||
}
|
||||
|
||||
// Debug logs a debug message using the default logger
|
||||
func Debug(format string, args ...interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.Debug(format, args...)
|
||||
}
|
||||
|
||||
// Trace logs a trace message using the default logger
|
||||
func Trace(format string, args ...interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.Trace(format, args...)
|
||||
}
|
||||
|
||||
// Lua logs a Lua message using the default logger
|
||||
func Lua(format string, args ...interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.Lua(format, args...)
|
||||
}
|
||||
|
||||
// LogPanic logs a panic error and its stack trace
|
||||
func LogPanic(r interface{}) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
stack := make([]byte, 4096)
|
||||
n := runtime.Stack(stack, false)
|
||||
DefaultLogger.Error("PANIC: %v\n%s", r, stack[:n])
|
||||
}
|
||||
|
||||
// SetLevel sets the log level for the default logger
|
||||
func SetLevel(level LogLevel) {
|
||||
if DefaultLogger == nil {
|
||||
Init(level)
|
||||
return
|
||||
}
|
||||
DefaultLogger.SetLevel(level)
|
||||
}
|
||||
|
||||
// GetLevel gets the log level for the default logger
|
||||
func GetLevel() LogLevel {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
return DefaultLogger.GetLevel()
|
||||
}
|
||||
|
||||
// WithField returns a new logger with the field added to the default logger's context
|
||||
func WithField(key string, value interface{}) *Logger {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
return DefaultLogger.WithField(key, value)
|
||||
}
|
||||
|
||||
// WithFields returns a new logger with the fields added to the default logger's context
|
||||
func WithFields(fields map[string]interface{}) *Logger {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
return DefaultLogger.WithFields(fields)
|
||||
}
|
||||
|
||||
// SetShowGoroutine enables or disables goroutine ID display in the default logger
|
||||
func SetShowGoroutine(show bool) {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
DefaultLogger.SetShowGoroutine(show)
|
||||
}
|
||||
|
||||
// ShowGoroutine returns whether goroutine ID is included in default logger's messages
|
||||
func ShowGoroutine() bool {
|
||||
if DefaultLogger == nil {
|
||||
Init(defaultLogLevel)
|
||||
}
|
||||
return DefaultLogger.ShowGoroutine()
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// PanicHandler handles a panic and logs it
|
||||
func PanicHandler() {
|
||||
if r := recover(); r != nil {
|
||||
goroutineID := GetGoroutineID()
|
||||
stackTrace := debug.Stack()
|
||||
Error("PANIC in goroutine %s: %v\n%s", goroutineID, r, stackTrace)
|
||||
}
|
||||
}
|
||||
|
||||
// SafeGo launches a goroutine with panic recovery
|
||||
// Usage: logger.SafeGo(func() { ... your code ... })
|
||||
func SafeGo(f func()) {
|
||||
go func() {
|
||||
defer PanicHandler()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
|
||||
// SafeGoWithArgs launches a goroutine with panic recovery and passes arguments
|
||||
// Usage: logger.SafeGoWithArgs(func(arg1, arg2 interface{}) { ... }, "value1", 42)
|
||||
func SafeGoWithArgs(f func(...interface{}), args ...interface{}) {
|
||||
go func() {
|
||||
defer PanicHandler()
|
||||
f(args...)
|
||||
}()
|
||||
}
|
||||
|
||||
// SafeExec executes a function with panic recovery
|
||||
// Useful for code that should not panic
|
||||
func SafeExec(f func()) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
goroutineID := GetGoroutineID()
|
||||
stackTrace := debug.Stack()
|
||||
Error("PANIC in goroutine %s: %v\n%s", goroutineID, r, stackTrace)
|
||||
err = fmt.Errorf("panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
f()
|
||||
return nil
|
||||
}
|
||||
716
main.go
716
main.go
@@ -1,218 +1,672 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"modify/processor"
|
||||
"modify/utils"
|
||||
"cook/processor"
|
||||
"cook/utils"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
|
||||
"modify/logger"
|
||||
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 {
|
||||
TotalMatches int
|
||||
TotalModifications int
|
||||
ProcessedFiles int
|
||||
FailedFiles int
|
||||
ModificationsPerCommand map[string]int
|
||||
TotalMatches int64
|
||||
TotalModifications int64
|
||||
ProcessedFiles int64
|
||||
FailedFiles int64
|
||||
ModificationsPerCommand sync.Map
|
||||
}
|
||||
|
||||
var (
|
||||
repo *git.Repository
|
||||
worktree *git.Worktree
|
||||
stats GlobalStats = GlobalStats{
|
||||
ModificationsPerCommand: make(map[string]int),
|
||||
stats GlobalStats = GlobalStats{
|
||||
ModificationsPerCommand: sync.Map{},
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] <pattern> <lua_expression> <...files_or_globs>\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "\nOptions:\n")
|
||||
fmt.Fprintf(os.Stderr, " -git\n")
|
||||
fmt.Fprintf(os.Stderr, " Use git to manage files\n")
|
||||
fmt.Fprintf(os.Stderr, " -reset\n")
|
||||
fmt.Fprintf(os.Stderr, " Reset files to their original state\n")
|
||||
fmt.Fprintf(os.Stderr, " -loglevel string\n")
|
||||
fmt.Fprintf(os.Stderr, " Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE (default \"INFO\")\n")
|
||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
||||
fmt.Fprintf(os.Stderr, " Regex mode (default):\n")
|
||||
fmt.Fprintf(os.Stderr, " %s \"<value>(\\d+)</value>\" \"*1.5\" data.xml\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n")
|
||||
fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n")
|
||||
fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n")
|
||||
fmt.Fprintf(os.Stderr, " is_number(str) checks if a string is numeric\n")
|
||||
fmt.Fprintf(os.Stderr, " If expression starts with an operator like *, /, +, -, =, etc., v1 is automatically prepended\n")
|
||||
fmt.Fprintf(os.Stderr, " You can use any valid Lua code, including if statements, loops, etc.\n")
|
||||
fmt.Fprintf(os.Stderr, " Glob patterns are supported for file selection (*.xml, data/**.xml, etc.)\n")
|
||||
}
|
||||
flag.Parse()
|
||||
args := flag.Args()
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd *cobra.Command
|
||||
|
||||
level := logger.ParseLevel(*utils.LogLevel)
|
||||
logger.Init(level)
|
||||
logger.Info("Initializing with log level: %s", level.String())
|
||||
func init() {
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "modifier [options] <pattern> <lua_expression> <...files_or_globs>",
|
||||
Short: "A powerful file modification tool with Lua scripting",
|
||||
Long: `Modifier is a powerful file processing tool that supports regex patterns,
|
||||
JSON manipulation, and YAML to TOML conversion with Lua scripting capabilities.
|
||||
|
||||
Features:
|
||||
- Regex-based pattern matching and replacement
|
||||
- JSON file processing with query support
|
||||
- YAML to TOML conversion
|
||||
- Lua scripting for complex transformations
|
||||
- Parallel file processing
|
||||
- Command filtering and organization`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
logger.InitFlag()
|
||||
mainLogger.Info("Initializing with log level: %s", logger.GetLevel().String())
|
||||
mainLogger.Trace("Full argv: %v", os.Args)
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
exampleFlag, _ := cmd.Flags().GetBool("example")
|
||||
if exampleFlag {
|
||||
CreateExampleConfig()
|
||||
return
|
||||
}
|
||||
metaFlag, _ := cmd.Flags().GetBool("meta")
|
||||
if metaFlag {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
mainLogger.Error("Failed to get current directory: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
metaPath := filepath.Join(cwd, "meta.lua")
|
||||
if err := processor.GenerateMetaFile(metaPath); err != nil {
|
||||
mainLogger.Error("Failed to generate meta.lua: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) == 0 {
|
||||
cmd.Usage()
|
||||
return
|
||||
}
|
||||
runModifier(args, cmd)
|
||||
},
|
||||
}
|
||||
|
||||
// Global flags
|
||||
rootCmd.PersistentFlags().StringP("loglevel", "l", "INFO", "Set logging level: ERROR, WARNING, INFO, DEBUG, TRACE")
|
||||
|
||||
// Local flags
|
||||
rootCmd.Flags().IntP("parallel", "P", 100, "Number of files to process in parallel")
|
||||
rootCmd.Flags().StringP("filter", "f", "", "Filter commands before running them")
|
||||
rootCmd.Flags().Bool("json", false, "Enable JSON mode for processing JSON files")
|
||||
rootCmd.Flags().BoolP("conv", "c", false, "Convert YAML files to TOML format")
|
||||
rootCmd.Flags().BoolP("example", "e", false, "Generate example_cook.toml and exit")
|
||||
rootCmd.Flags().BoolP("meta", "m", false, "Generate meta.lua file for LuaLS autocomplete 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
|
||||
}
|
||||
|
||||
mainLogger.Debug("Getting database connection")
|
||||
db, err := utils.GetDB()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if workdone {
|
||||
mainLogger.Info("Special arguments handled, exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
// The plan is:
|
||||
// Load all commands
|
||||
commands, err := utils.LoadCommands(args)
|
||||
if err != nil {
|
||||
logger.Error("Failed to load commands: %v", err)
|
||||
flag.Usage()
|
||||
mainLogger.Debug("Loading commands from arguments")
|
||||
mainLogger.Trace("Arguments: %v", args)
|
||||
commands, variables, err := utils.LoadCommands(args)
|
||||
if err != nil || len(commands) == 0 {
|
||||
mainLogger.Error("Failed to load commands: %v", err)
|
||||
cmd.Usage()
|
||||
return
|
||||
}
|
||||
if len(variables) > 0 {
|
||||
mainLogger.Info("Loaded %d global variables", len(variables))
|
||||
processor.SetVariables(variables)
|
||||
}
|
||||
mainLogger.Info("Loaded %d commands", len(commands))
|
||||
|
||||
if filterFlag != "" {
|
||||
mainLogger.Info("Filtering commands by name: %s", filterFlag)
|
||||
commands = utils.FilterCommands(commands, filterFlag)
|
||||
mainLogger.Info("Filtered %d commands", len(commands))
|
||||
}
|
||||
|
||||
// Then aggregate all the globs and deduplicate them
|
||||
mainLogger.Debug("Aggregating globs and deduplicating")
|
||||
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 {
|
||||
mainLogger.Trace("Command: %s", command.Name)
|
||||
if len(command.Regexes) > 0 {
|
||||
mainLogger.Trace("Regexes: %v", command.Regexes)
|
||||
} else {
|
||||
mainLogger.Trace("Regex: %s", command.Regex)
|
||||
}
|
||||
mainLogger.Trace("Files: %v", command.Files)
|
||||
mainLogger.Trace("Lua: %s", command.Lua)
|
||||
mainLogger.Trace("Reset: %t", command.Reset)
|
||||
mainLogger.Trace("Isolate: %t", command.Isolate)
|
||||
mainLogger.Trace("LogLevel: %s", command.LogLevel)
|
||||
}
|
||||
|
||||
// Resolve all the files for all the globs
|
||||
logger.Info("Found %d unique file patterns", len(globs))
|
||||
files, err := utils.ExpandGLobs(globs)
|
||||
mainLogger.Info("Found %d unique file patterns", len(globs))
|
||||
mainLogger.Debug("Expanding glob patterns to files")
|
||||
files, err := utils.ExpandGlobs(globs)
|
||||
if err != nil {
|
||||
logger.Error("Failed to expand file patterns: %v", err)
|
||||
mainLogger.Error("Failed to expand file patterns: %v", err)
|
||||
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..
|
||||
// For each file check every glob of every command
|
||||
// Maybe memoize this part
|
||||
// That way we know what commands affect what files
|
||||
mainLogger.Debug("Associating files with commands")
|
||||
associations, err := utils.AssociateFilesWithCommands(files, commands)
|
||||
if err != nil {
|
||||
logger.Error("Failed to associate files with commands: %v", err)
|
||||
mainLogger.Error("Failed to associate files with commands: %v", err)
|
||||
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)
|
||||
if err != nil {
|
||||
mainLogger.Error("Failed to reset files where necessary: %v", err)
|
||||
return
|
||||
}
|
||||
mainLogger.Debug("Files reset where necessary")
|
||||
|
||||
// TODO: Utilize parallel workers for this
|
||||
// Then for each file run all commands associated with the file
|
||||
workers := make(chan struct{}, *utils.ParallelFiles)
|
||||
workers := make(chan struct{}, parallelFlag)
|
||||
wg := sync.WaitGroup{}
|
||||
mainLogger.Debug("Starting file processing with %d parallel workers", parallelFlag)
|
||||
|
||||
// Add performance tracking
|
||||
startTime := time.Now()
|
||||
var fileMutex sync.Mutex
|
||||
|
||||
for file, commands := range associations {
|
||||
// Create a map to store loggers for each command
|
||||
commandLoggers := make(map[string]*logger.Logger)
|
||||
for _, command := range commands {
|
||||
// Create a named logger for each command
|
||||
cmdName := command.Name
|
||||
if cmdName == "" {
|
||||
// If no name is provided, use a short version of the regex pattern
|
||||
if len(command.Regex) > 20 {
|
||||
cmdName = command.Regex[:17] + "..."
|
||||
} else {
|
||||
cmdName = command.Regex
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the log level for this specific command
|
||||
cmdLogLevel := logger.ParseLevel(command.LogLevel)
|
||||
|
||||
// Create a logger with the command name as a field
|
||||
commandLoggers[command.Name] = logger.Default.WithField("command", cmdName)
|
||||
commandLoggers[command.Name].SetLevel(cmdLogLevel)
|
||||
|
||||
mainLogger.Debug("Created logger for command %q with log level %s", cmdName, cmdLogLevel.String())
|
||||
}
|
||||
|
||||
for file, association := range associations {
|
||||
workers <- struct{}{}
|
||||
wg.Add(1)
|
||||
logger.SafeGoWithArgs(func(args ...interface{}) {
|
||||
defer func() { <-workers }()
|
||||
defer wg.Done()
|
||||
|
||||
// Track per-file processing time
|
||||
fileStartTime := time.Now()
|
||||
|
||||
mainLogger.Debug("Reading file %q", file)
|
||||
fileData, err := os.ReadFile(file)
|
||||
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
|
||||
}
|
||||
logger.Trace("Loaded %d bytes of data for file %q", len(fileData), file)
|
||||
fileDataStr := string(fileData)
|
||||
mainLogger.Trace("File %q content: %s", file, utils.LimitString(fileDataStr, 500))
|
||||
|
||||
// Aggregate all the modifications and execute them
|
||||
modifications := []utils.ReplaceCommand{}
|
||||
for _, command := range commands {
|
||||
logger.Info("Processing file %q with command %q", file, command.Regex)
|
||||
commands, err := processor.ProcessRegex(fileDataStr, command)
|
||||
isChanged := false
|
||||
mainLogger.Debug("Running isolate commands for file %q", file)
|
||||
fileDataStr, err = RunIsolateCommands(association, file, fileDataStr, jsonFlag)
|
||||
if err != nil && err != ErrNothingToDo {
|
||||
mainLogger.Error("Failed to run isolate commands for file %q: %v", file, err)
|
||||
atomic.AddInt64(&stats.FailedFiles, 1)
|
||||
return
|
||||
}
|
||||
if err != ErrNothingToDo {
|
||||
isChanged = true
|
||||
}
|
||||
|
||||
mainLogger.Debug("Running other commands for file %q", file)
|
||||
fileDataStr, err = RunOtherCommands(file, fileDataStr, association, commandLoggers, jsonFlag)
|
||||
if err != nil && err != ErrNothingToDo {
|
||||
mainLogger.Error("Failed to run other commands for file %q: %v", file, err)
|
||||
atomic.AddInt64(&stats.FailedFiles, 1)
|
||||
return
|
||||
}
|
||||
if err != ErrNothingToDo {
|
||||
isChanged = true
|
||||
}
|
||||
|
||||
if isChanged {
|
||||
mainLogger.Debug("Saving file %q to database", file)
|
||||
err = db.SaveFile(file, fileData)
|
||||
if err != nil {
|
||||
logger.Error("Failed to process file %q with command %q: %v", file, command.Regex, err)
|
||||
mainLogger.Error("Failed to save file %q to database: %v", file, err)
|
||||
atomic.AddInt64(&stats.FailedFiles, 1)
|
||||
return
|
||||
}
|
||||
modifications = append(modifications, commands...)
|
||||
// It is not guranteed that all the commands will be executed...
|
||||
// TODO: Make this better
|
||||
// We'd have to pass the map to executemodifications or something...
|
||||
stats.ModificationsPerCommand[command.Name] += len(commands)
|
||||
mainLogger.Debug("File %q saved to database", file)
|
||||
}
|
||||
|
||||
if len(modifications) == 0 {
|
||||
logger.Info("No modifications found for file %q", file)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort commands in reverse order for safe replacements
|
||||
fileDataStr, count := utils.ExecuteModifications(modifications, fileDataStr)
|
||||
|
||||
fileMutex.Lock()
|
||||
stats.ProcessedFiles++
|
||||
stats.TotalModifications += count
|
||||
fileMutex.Unlock()
|
||||
|
||||
logger.Info("Executed %d modifications for file %q", count, file)
|
||||
|
||||
mainLogger.Debug("Writing file %q", file)
|
||||
err = os.WriteFile(file, []byte(fileDataStr), 0644)
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
processingTime := time.Since(startTime)
|
||||
logger.Info("Processing completed in %v", processingTime)
|
||||
if stats.ProcessedFiles > 0 {
|
||||
logger.Info("Average time per file: %v", processingTime/time.Duration(stats.ProcessedFiles))
|
||||
mainLogger.Info("Processing completed in %v", processingTime)
|
||||
processedFiles := atomic.LoadInt64(&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?
|
||||
// Do that with logger.WithField("loglevel", level.String())
|
||||
// Since each command also has its own log level
|
||||
// TODO: Maybe even figure out how to run individual commands...?
|
||||
// TODO: What to do with git? Figure it out ....
|
||||
|
||||
// if *gitFlag {
|
||||
// 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
|
||||
if stats.TotalModifications == 0 {
|
||||
logger.Warning("No modifications were made in any files")
|
||||
fmt.Fprintf(os.Stderr, "No modifications were made in any files\n")
|
||||
totalModifications := atomic.LoadInt64(&stats.TotalModifications)
|
||||
if totalModifications == 0 {
|
||||
mainLogger.Warning("No modifications were made in any files")
|
||||
} else {
|
||||
logger.Info("Operation complete! Modified %d values in %d/%d files",
|
||||
stats.TotalModifications, stats.ProcessedFiles, stats.ProcessedFiles+stats.FailedFiles)
|
||||
fmt.Printf("Operation complete! Modified %d values in %d/%d files\n",
|
||||
stats.TotalModifications, stats.ProcessedFiles, stats.ProcessedFiles+stats.FailedFiles)
|
||||
failedFiles := atomic.LoadInt64(&stats.FailedFiles)
|
||||
mainLogger.Info("Operation complete! Modified %d values in %d/%d files",
|
||||
totalModifications, processedFiles, processedFiles+failedFiles)
|
||||
sortedCommands := []string{}
|
||||
stats.ModificationsPerCommand.Range(func(key, value interface{}) bool {
|
||||
sortedCommands = append(sortedCommands, key.(string))
|
||||
return true
|
||||
})
|
||||
sort.Strings(sortedCommands)
|
||||
|
||||
for _, command := range sortedCommands {
|
||||
count, _ := stats.ModificationsPerCommand.Load(command)
|
||||
if count.(int) > 0 {
|
||||
mainLogger.Info("\tCommand %q made %d modifications", command, count)
|
||||
} else {
|
||||
mainLogger.Warning("\tCommand %q made no modifications", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSpecialArgs(args []string, db utils.DB) (bool, error) {
|
||||
handleSpecialArgsLogger := logger.Default.WithPrefix("HandleSpecialArgs")
|
||||
handleSpecialArgsLogger.Debug("Handling special arguments: %v", args)
|
||||
if len(args) == 0 {
|
||||
handleSpecialArgsLogger.Warning("No arguments provided to HandleSpecialArgs")
|
||||
return false, nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
handleSpecialArgsLogger.Info("Resetting all files to their original state from database")
|
||||
err := utils.ResetAllFiles(db)
|
||||
if err != nil {
|
||||
handleSpecialArgsLogger.Error("Failed to reset all files: %v", err)
|
||||
return true, err
|
||||
}
|
||||
handleSpecialArgsLogger.Info("Successfully reset all files to original state")
|
||||
return true, nil
|
||||
case "dump":
|
||||
handleSpecialArgsLogger.Info("Dumping all files from database (clearing snapshots)")
|
||||
err := db.RemoveAllFiles()
|
||||
if err != nil {
|
||||
handleSpecialArgsLogger.Error("Failed to remove all files from database: %v", err)
|
||||
return true, err
|
||||
}
|
||||
handleSpecialArgsLogger.Info("Successfully cleared all file snapshots from database")
|
||||
return true, nil
|
||||
default:
|
||||
handleSpecialArgsLogger.Debug("Unknown special argument: %q", args[0])
|
||||
}
|
||||
handleSpecialArgsLogger.Debug("No special arguments handled, returning false")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func CreateExampleConfig() {
|
||||
createExampleConfigLogger := logger.Default.WithPrefix("CreateExampleConfig")
|
||||
createExampleConfigLogger.Debug("Creating example configuration file")
|
||||
|
||||
// Save the embedded TOML content to disk
|
||||
createExampleConfigLogger.Debug("Writing example_cook.toml")
|
||||
err := os.WriteFile("example_cook.toml", []byte(exampleTOMLContent), 0644)
|
||||
if err != nil {
|
||||
createExampleConfigLogger.Error("Failed to write example_cook.toml: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
createExampleConfigLogger.Info("Wrote example_cook.toml")
|
||||
}
|
||||
|
||||
var ErrNothingToDo = errors.New("nothing to do")
|
||||
|
||||
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 {
|
||||
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
|
||||
if cmdLog, ok := commandLoggers[command.Name]; ok {
|
||||
cmdLogger = cmdLog
|
||||
}
|
||||
|
||||
cmdLogger.Debug("Processing file with JSON mode for command %q", command.Name)
|
||||
newModifications, err := processor.ProcessJSON(fileDataStr, command, file)
|
||||
if err != nil {
|
||||
runOtherCommandsLogger.Error("Failed to process file with JSON command %q: %v", command.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply JSON modifications immediately
|
||||
if len(newModifications) > 0 {
|
||||
var count int
|
||||
fileDataStr, count = utils.ExecuteModifications(newModifications, fileDataStr)
|
||||
atomic.AddInt64(&stats.TotalModifications, int64(count))
|
||||
cmdLogger.Debug("Applied %d JSON modifications for command %q", count, command.Name)
|
||||
}
|
||||
|
||||
count, ok := stats.ModificationsPerCommand.Load(command.Name)
|
||||
if !ok {
|
||||
count = 0
|
||||
}
|
||||
stats.ModificationsPerCommand.Store(command.Name, count.(int)+len(newModifications))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
runOtherCommandsLogger.Warning("No modifications found for file")
|
||||
return fileDataStr, ErrNothingToDo
|
||||
}
|
||||
runOtherCommandsLogger.Debug("Executing %d modifications for file", len(modifications))
|
||||
|
||||
// Sort commands in reverse order for safe replacements
|
||||
var count int
|
||||
fileDataStr, count = utils.ExecuteModifications(modifications, fileDataStr)
|
||||
runOtherCommandsLogger.Trace("File data after modifications: %s", utils.LimitString(fileDataStr, 200))
|
||||
|
||||
atomic.AddInt64(&stats.TotalModifications, int64(count))
|
||||
|
||||
runOtherCommandsLogger.Info("Executed %d modifications for file", count)
|
||||
return fileDataStr, nil
|
||||
}
|
||||
|
||||
func RunIsolateCommands(association utils.FileCommandAssociation, file string, fileDataStr string, jsonFlag bool) (string, error) {
|
||||
runIsolateCommandsLogger := mainLogger.WithPrefix("RunIsolateCommands").WithField("file", file)
|
||||
runIsolateCommandsLogger.Debug("Running isolate commands for file")
|
||||
runIsolateCommandsLogger.Trace("File data before isolate modifications: %s", utils.LimitString(fileDataStr, 200))
|
||||
|
||||
anythingDone := false
|
||||
currentFileData := fileDataStr
|
||||
|
||||
for _, isolateCommand := range association.IsolateCommands {
|
||||
// Check if this isolate command should use JSON mode
|
||||
if isolateCommand.JSON || jsonFlag {
|
||||
runIsolateCommandsLogger.Debug("Begin processing file with JSON isolate command %q", isolateCommand.Name)
|
||||
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 !anythingDone {
|
||||
runIsolateCommandsLogger.Debug("No isolate modifications were made for file")
|
||||
return fileDataStr, ErrNothingToDo
|
||||
}
|
||||
return currentFileData, nil
|
||||
}
|
||||
|
||||
591
processor/json.go
Normal file
591
processor/json.go
Normal file
@@ -0,0 +1,591 @@
|
||||
// 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, command.SourceDir)
|
||||
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
|
||||
var arrayResult gjson.Result
|
||||
if arrayPath == "" {
|
||||
// Root-level array
|
||||
arrayResult = gjson.Parse(content)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: No default case needed - JSON data from unmarshaling is always
|
||||
// map[string]interface{} or []interface{} at the top level
|
||||
|
||||
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{} (map or array) to a Lua table
|
||||
// This should only be called with map[string]interface{} or []interface{} from JSON unmarshaling
|
||||
func ToLuaTable(L *lua.LState, data interface{}) (*lua.LTable, error) {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
table := L.CreateTable(0, len(v))
|
||||
for key, value := range v {
|
||||
table.RawSetString(key, ToLuaValue(L, value))
|
||||
}
|
||||
return table, nil
|
||||
|
||||
case []interface{}:
|
||||
table := L.CreateTable(len(v), 0)
|
||||
for i, value := range v {
|
||||
table.RawSetInt(i+1, ToLuaValue(L, value)) // Lua arrays are 1-indexed
|
||||
}
|
||||
return table, nil
|
||||
|
||||
default:
|
||||
// This should only happen with invalid JSON (root-level primitives)
|
||||
return nil, fmt.Errorf("expected table or array, got %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ToLuaValue converts a Go interface{} to a Lua value
|
||||
func ToLuaValue(L *lua.LState, data interface{}) lua.LValue {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
table := L.CreateTable(0, len(v))
|
||||
for key, value := range v {
|
||||
table.RawSetString(key, ToLuaValue(L, value))
|
||||
}
|
||||
return table
|
||||
|
||||
case []interface{}:
|
||||
table := L.CreateTable(len(v), 0)
|
||||
for i, value := range v {
|
||||
table.RawSetInt(i+1, ToLuaValue(L, value)) // Lua arrays are 1-indexed
|
||||
}
|
||||
return table
|
||||
|
||||
case string:
|
||||
return lua.LString(v)
|
||||
|
||||
case float64:
|
||||
return lua.LNumber(v)
|
||||
|
||||
case bool:
|
||||
return lua.LBool(v)
|
||||
|
||||
case nil:
|
||||
return lua.LNil
|
||||
|
||||
default:
|
||||
// This should never happen with JSON-unmarshaled data
|
||||
return lua.LNil
|
||||
}
|
||||
}
|
||||
283
processor/json_coverage_test.go
Normal file
283
processor/json_coverage_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestJSONFloat tests line 298 - float formatting for non-integer floats
|
||||
func TestJSONFloatFormatting(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"value": 10.5,
|
||||
"another": 3.14159
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_float",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
data.value = data.value * 2
|
||||
data.another = data.another * 10
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, "21") // 10.5 * 2
|
||||
assert.Contains(t, result, "31.4159") // 3.14159 * 10
|
||||
}
|
||||
|
||||
// TestJSONNestedObjectAddition tests lines 303-320 - map[string]interface{} case
|
||||
func TestJSONNestedObjectAddition(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"items": {}
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_nested",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
data.items.newObject = {
|
||||
name = "test",
|
||||
value = 42,
|
||||
enabled = true
|
||||
}
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, `"newObject"`)
|
||||
assert.Contains(t, result, `"name"`)
|
||||
assert.Contains(t, result, `"test"`)
|
||||
assert.Contains(t, result, `"value"`)
|
||||
assert.Contains(t, result, "42")
|
||||
}
|
||||
|
||||
// TestJSONKeyWithQuotes tests line 315 - key escaping with quotes
|
||||
func TestJSONKeyWithQuotes(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"data": {}
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_key_quotes",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
data.data["key-with-dash"] = "value1"
|
||||
data.data.normalKey = "value2"
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, `"key-with-dash"`)
|
||||
assert.Contains(t, result, `"normalKey"`)
|
||||
}
|
||||
|
||||
// TestJSONArrayInValue tests lines 321-327 - default case with json.Marshal for arrays
|
||||
func TestJSONArrayInValue(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"data": {}
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_array_value",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
data.data.items = {1, 2, 3, 4, 5}
|
||||
data.data.strings = {"a", "b", "c"}
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, `"items"`)
|
||||
assert.Contains(t, result, `[1,2,3,4,5]`)
|
||||
assert.Contains(t, result, `"strings"`)
|
||||
assert.Contains(t, result, `["a","b","c"]`)
|
||||
}
|
||||
|
||||
// TestJSONRootArrayElementRemoval tests line 422 - removing from root-level array
|
||||
func TestJSONRootArrayElementRemoval(t *testing.T) {
|
||||
jsonContent := `[
|
||||
{"id": 1, "name": "first"},
|
||||
{"id": 2, "name": "second"},
|
||||
{"id": 3, "name": "third"}
|
||||
]`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_root_array_removal",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
-- Remove the second element
|
||||
table.remove(data, 2)
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, `"first"`)
|
||||
assert.Contains(t, result, `"third"`)
|
||||
assert.NotContains(t, result, `"second"`)
|
||||
}
|
||||
|
||||
// TestJSONRootArrayElementChange tests lines 434 and 450 - changing primitive values in root array
|
||||
func TestJSONRootArrayElementChange(t *testing.T) {
|
||||
jsonContent := `[10, 20, 30, 40, 50]`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_root_array_change",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
-- Double all values
|
||||
for i = 1, #data do
|
||||
data[i] = data[i] * 2
|
||||
end
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, "20")
|
||||
assert.Contains(t, result, "40")
|
||||
assert.Contains(t, result, "60")
|
||||
assert.Contains(t, result, "80")
|
||||
assert.Contains(t, result, "100")
|
||||
assert.NotContains(t, result, "10,")
|
||||
}
|
||||
|
||||
// TestJSONRootArrayStringElements tests deepEqual with strings in root array
|
||||
func TestJSONRootArrayStringElements(t *testing.T) {
|
||||
jsonContent := `["apple", "banana", "cherry"]`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_root_array_strings",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
data[2] = "orange"
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, `"apple"`)
|
||||
assert.Contains(t, result, `"orange"`)
|
||||
assert.Contains(t, result, `"cherry"`)
|
||||
assert.NotContains(t, result, `"banana"`)
|
||||
}
|
||||
|
||||
// TestJSONComplexNestedStructure tests multiple untested paths together
|
||||
func TestJSONComplexNestedStructure(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"config": {
|
||||
"multiplier": 2.5
|
||||
}
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_complex",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
-- Add nested object with array
|
||||
data.config.settings = {
|
||||
enabled = true,
|
||||
values = {1.5, 2.5, 3.5},
|
||||
names = {"alpha", "beta"}
|
||||
}
|
||||
-- Change float
|
||||
data.config.multiplier = 7.777
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, "7.777")
|
||||
assert.Contains(t, result, `"settings"`)
|
||||
assert.Contains(t, result, `"values"`)
|
||||
assert.Contains(t, result, `[1.5,2.5,3.5]`)
|
||||
}
|
||||
|
||||
// TestJSONRemoveFirstArrayElement tests line 358-365 - removing first element with comma handling
|
||||
func TestJSONRemoveFirstArrayElement(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"items": [1, 2, 3, 4, 5]
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_remove_first",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
table.remove(data.items, 1)
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.NotContains(t, result, "[1,")
|
||||
assert.Contains(t, result, "2")
|
||||
assert.Contains(t, result, "5")
|
||||
}
|
||||
|
||||
// TestJSONRemoveLastArrayElement tests line 366-374 - removing last element with comma handling
|
||||
func TestJSONRemoveLastArrayElement(t *testing.T) {
|
||||
jsonContent := `{
|
||||
"items": [1, 2, 3, 4, 5]
|
||||
}`
|
||||
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test_remove_last",
|
||||
JSON: true,
|
||||
Lua: `
|
||||
table.remove(data.items, 5)
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessJSON(jsonContent, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, commands)
|
||||
|
||||
result, _ := utils.ExecuteModifications(commands, jsonContent)
|
||||
assert.Contains(t, result, "1")
|
||||
assert.Contains(t, result, "4")
|
||||
assert.NotContains(t, result, ", 5")
|
||||
}
|
||||
153
processor/json_deepequal_test.go
Normal file
153
processor/json_deepequal_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeepEqual(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a interface{}
|
||||
b interface{}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "both nil",
|
||||
a: nil,
|
||||
b: nil,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "first nil",
|
||||
a: nil,
|
||||
b: "something",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "second nil",
|
||||
a: "something",
|
||||
b: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "equal primitives",
|
||||
a: 42,
|
||||
b: 42,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "different primitives",
|
||||
a: 42,
|
||||
b: 43,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "equal strings",
|
||||
a: "hello",
|
||||
b: "hello",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "equal maps",
|
||||
a: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
b: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "maps different lengths",
|
||||
a: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
},
|
||||
b: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "maps different values",
|
||||
a: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
},
|
||||
b: map[string]interface{}{
|
||||
"key1": "value2",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "map vs non-map",
|
||||
a: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
},
|
||||
b: "not a map",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "equal arrays",
|
||||
a: []interface{}{1, 2, 3},
|
||||
b: []interface{}{1, 2, 3},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "arrays different lengths",
|
||||
a: []interface{}{1, 2},
|
||||
b: []interface{}{1, 2, 3},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "arrays different values",
|
||||
a: []interface{}{1, 2, 3},
|
||||
b: []interface{}{1, 2, 4},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "array vs non-array",
|
||||
a: []interface{}{1, 2, 3},
|
||||
b: "not an array",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nested equal structures",
|
||||
a: map[string]interface{}{
|
||||
"outer": map[string]interface{}{
|
||||
"inner": []interface{}{1, 2, 3},
|
||||
},
|
||||
},
|
||||
b: map[string]interface{}{
|
||||
"outer": map[string]interface{}{
|
||||
"inner": []interface{}{1, 2, 3},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nested different structures",
|
||||
a: map[string]interface{}{
|
||||
"outer": map[string]interface{}{
|
||||
"inner": []interface{}{1, 2, 3},
|
||||
},
|
||||
},
|
||||
b: map[string]interface{}{
|
||||
"outer": map[string]interface{}{
|
||||
"inner": []interface{}{1, 2, 4},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := deepEqual(tt.a, tt.b)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
95
processor/json_test.go
Normal file
95
processor/json_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
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 := ToLuaValue(L, tt.input)
|
||||
assert.Equal(t, tt.expected, result.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
191
processor/lua_external_integration_test.go
Normal file
191
processor/lua_external_integration_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cook/utils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestProcessRegexWithExternalLuaFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-integration-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file with replacement variable
|
||||
luaFile := filepath.Join(tmpDir, "multiply.lua")
|
||||
luaContent := `v1 = v1 * 2
|
||||
replacement = format("<value>%s</value>", v1)
|
||||
return true`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create test content
|
||||
content := `<value>10</value>`
|
||||
|
||||
// Create command with external Lua reference
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test",
|
||||
Regex: `<value>(\d+)</value>`,
|
||||
Lua: "@" + filepath.Base(luaFile),
|
||||
SourceDir: tmpDir,
|
||||
}
|
||||
|
||||
// Process
|
||||
modifications, err := ProcessRegex(content, command, "test.xml")
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, len(modifications), 0)
|
||||
|
||||
// Apply modifications
|
||||
result := content
|
||||
for _, mod := range modifications {
|
||||
result = result[:mod.From] + mod.With + result[mod.To:]
|
||||
}
|
||||
assert.Contains(t, result, "<value>20</value>")
|
||||
}
|
||||
|
||||
func TestProcessJSONWithExternalLuaFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-json-integration-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file
|
||||
luaFile := filepath.Join(tmpDir, "json_modify.lua")
|
||||
luaContent := `data.value = 84
|
||||
modified = true`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create test JSON content
|
||||
content := `{"value": 42}`
|
||||
|
||||
// Create command with external Lua reference
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test",
|
||||
JSON: true,
|
||||
Lua: "@" + filepath.Base(luaFile),
|
||||
SourceDir: tmpDir,
|
||||
}
|
||||
|
||||
// Process
|
||||
modifications, err := ProcessJSON(content, command, "test.json")
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, len(modifications), 0)
|
||||
|
||||
// Apply modifications to verify
|
||||
result := content
|
||||
for _, mod := range modifications {
|
||||
result = result[:mod.From] + mod.With + result[mod.To:]
|
||||
}
|
||||
// Check that value was changed to 84 (formatting may vary)
|
||||
assert.Contains(t, result, `"value"`)
|
||||
assert.Contains(t, result, `84`)
|
||||
assert.NotContains(t, result, `"value": 42`)
|
||||
assert.NotContains(t, result, `"value":42`)
|
||||
}
|
||||
|
||||
func TestProcessXMLWithExternalLuaFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-xml-integration-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file (XML uses 'root' not 'data')
|
||||
luaFile := filepath.Join(tmpDir, "xml_modify.lua")
|
||||
luaContent := `visitElements(root, function(elem)
|
||||
if elem._tag == "Item" then
|
||||
modifyNumAttr(elem, "Weight", function(val) return val * 2 end)
|
||||
end
|
||||
end)
|
||||
modified = true`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create test XML content
|
||||
content := `<Items><Item Weight="10" /></Items>`
|
||||
|
||||
// Create command with external Lua reference
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test",
|
||||
Lua: "@" + filepath.Base(luaFile),
|
||||
SourceDir: tmpDir,
|
||||
}
|
||||
|
||||
// Process
|
||||
modifications, err := ProcessXML(content, command, "test.xml")
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, len(modifications), 0)
|
||||
|
||||
// Apply modifications to verify
|
||||
result := content
|
||||
for _, mod := range modifications {
|
||||
result = result[:mod.From] + mod.With + result[mod.To:]
|
||||
}
|
||||
assert.Contains(t, result, `Weight="20"`)
|
||||
}
|
||||
|
||||
func TestExternalLuaFileWithVariables(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-vars-integration-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file with variable reference
|
||||
luaFile := filepath.Join(tmpDir, "with_vars.lua")
|
||||
luaContent := `v1 = v1 * $multiply
|
||||
replacement = format("<value>%s</value>", v1)
|
||||
return true`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Set global variable
|
||||
SetVariables(map[string]interface{}{"multiply": 1.5})
|
||||
defer SetVariables(map[string]interface{}{})
|
||||
|
||||
// Create test content
|
||||
content := `<value>10</value>`
|
||||
|
||||
// Create command with external Lua reference
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test",
|
||||
Regex: `<value>(\d+)</value>`,
|
||||
Lua: "@" + filepath.Base(luaFile),
|
||||
SourceDir: tmpDir,
|
||||
}
|
||||
|
||||
// Process
|
||||
modifications, err := ProcessRegex(content, command, "test.xml")
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, len(modifications), 0)
|
||||
|
||||
// Apply modifications
|
||||
result := content
|
||||
for _, mod := range modifications {
|
||||
result = result[:mod.From] + mod.With + result[mod.To:]
|
||||
}
|
||||
assert.Contains(t, result, "<value>15</value>")
|
||||
}
|
||||
|
||||
func TestExternalLuaFileErrorHandling(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-error-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create command with non-existent external Lua file
|
||||
command := utils.ModifyCommand{
|
||||
Name: "test",
|
||||
Regex: `<value>(\d+)</value>`,
|
||||
Lua: "@nonexistent.lua",
|
||||
SourceDir: tmpDir,
|
||||
}
|
||||
|
||||
// Process - the error script will be generated but execution will fail
|
||||
// ProcessRegex continues on Lua errors, so no modifications will be made
|
||||
content := `<value>10</value>`
|
||||
modifications, err := ProcessRegex(content, command, "test.xml")
|
||||
|
||||
// No error returned (ProcessRegex continues on Lua errors), but no modifications made
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, modifications)
|
||||
}
|
||||
224
processor/lua_external_test.go
Normal file
224
processor/lua_external_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoadExternalLuaFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file
|
||||
luaFile := filepath.Join(tmpDir, "test.lua")
|
||||
luaContent := `data.value = 42
|
||||
modified = true`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
luaPath string
|
||||
sourceDir string
|
||||
expected string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "Relative path with sourceDir",
|
||||
luaPath: "test.lua",
|
||||
sourceDir: tmpDir,
|
||||
expected: luaContent,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Absolute path",
|
||||
luaPath: luaFile,
|
||||
sourceDir: "",
|
||||
expected: luaContent,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Relative path without sourceDir (uses CWD)",
|
||||
luaPath: filepath.Base(luaFile),
|
||||
sourceDir: "",
|
||||
expected: luaContent,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "Nested relative path",
|
||||
luaPath: "scripts/test.lua",
|
||||
sourceDir: tmpDir,
|
||||
expected: "",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "Non-existent file",
|
||||
luaPath: "nonexistent.lua",
|
||||
sourceDir: tmpDir,
|
||||
expected: "",
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Change to tmpDir for CWD-based tests
|
||||
if tt.sourceDir == "" && !filepath.IsAbs(tt.luaPath) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
}
|
||||
|
||||
result, err := LoadExternalLuaFile(tt.luaPath, tt.sourceDir)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, result)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptWithExternalFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-build-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file
|
||||
luaFile := filepath.Join(tmpDir, "multiply.lua")
|
||||
luaContent := `v1 = v1 * 2`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test with relative path
|
||||
relativePath := filepath.Base(luaFile)
|
||||
result := BuildLuaScript("@"+relativePath, tmpDir)
|
||||
assert.Contains(t, result, "v1 = v1 * 2")
|
||||
assert.Contains(t, result, "function run()")
|
||||
|
||||
// Test with absolute path
|
||||
result = BuildLuaScript("@"+luaFile, "")
|
||||
assert.Contains(t, result, "v1 = v1 * 2")
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptWithExternalFileError(t *testing.T) {
|
||||
// Test that missing file returns error script
|
||||
result := BuildLuaScript("@nonexistent.lua", "/tmp")
|
||||
assert.Contains(t, result, "error(")
|
||||
assert.Contains(t, result, "Failed to load external Lua file")
|
||||
}
|
||||
|
||||
func TestBuildJSONLuaScriptWithExternalFile(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-json-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file
|
||||
luaFile := filepath.Join(tmpDir, "json_modify.lua")
|
||||
luaContent := `data.value = 84
|
||||
modified = true`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test with relative path
|
||||
relativePath := filepath.Base(luaFile)
|
||||
result := BuildJSONLuaScript("@"+relativePath, tmpDir)
|
||||
assert.Contains(t, result, "data.value = 84")
|
||||
assert.Contains(t, result, "modified = true")
|
||||
assert.Contains(t, result, "function run()")
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptWithInlineCode(t *testing.T) {
|
||||
// Test that inline code (without @) still works
|
||||
result := BuildLuaScript("v1 = v1 * 2", "")
|
||||
assert.Contains(t, result, "v1 = v1 * 2")
|
||||
assert.Contains(t, result, "function run()")
|
||||
assert.NotContains(t, result, "@")
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptExternalFileWithVariables(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-vars-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file with variable reference
|
||||
luaFile := filepath.Join(tmpDir, "with_vars.lua")
|
||||
luaContent := `v1 = v1 * $multiply`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Set a global variable
|
||||
SetVariables(map[string]interface{}{"multiply": 1.5})
|
||||
defer SetVariables(map[string]interface{}{})
|
||||
|
||||
// Test that variables are substituted in external files
|
||||
result := BuildLuaScript("@"+filepath.Base(luaFile), tmpDir)
|
||||
assert.Contains(t, result, "v1 = v1 * 1.5")
|
||||
assert.NotContains(t, result, "$multiply")
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptExternalFileNestedPath(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-nested-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create nested directory structure
|
||||
scriptsDir := filepath.Join(tmpDir, "scripts")
|
||||
err = os.MkdirAll(scriptsDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
luaFile := filepath.Join(scriptsDir, "test.lua")
|
||||
luaContent := `data.value = 100`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test with nested relative path
|
||||
result := BuildLuaScript("@scripts/test.lua", tmpDir)
|
||||
assert.Contains(t, result, "data.value = 100")
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptExternalFileWithPrependLuaAssignment(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-prepend-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file with operator prefix (should trigger prepend)
|
||||
luaFile := filepath.Join(tmpDir, "multiply.lua")
|
||||
luaContent := `* 2`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test that prepend still works with external files
|
||||
result := BuildLuaScript("@"+filepath.Base(luaFile), tmpDir)
|
||||
// PrependLuaAssignment adds "v1 = v1" + "* 2" = "v1 = v1* 2" (no space between v1 and *)
|
||||
assert.Contains(t, result, "v1 = v1* 2")
|
||||
}
|
||||
|
||||
func TestBuildLuaScriptExternalFilePreservesWhitespace(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "lua-external-whitespace-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test Lua file with multiline content
|
||||
luaFile := filepath.Join(tmpDir, "multiline.lua")
|
||||
luaContent := `if data.items then
|
||||
for i, item in ipairs(data.items) do
|
||||
item.value = item.value * 2
|
||||
end
|
||||
modified = true
|
||||
end`
|
||||
err = os.WriteFile(luaFile, []byte(luaContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test that whitespace and formatting is preserved
|
||||
result := BuildLuaScript("@"+filepath.Base(luaFile), tmpDir)
|
||||
assert.Contains(t, result, "if data.items then")
|
||||
assert.Contains(t, result, " for i, item in ipairs(data.items) do")
|
||||
assert.Contains(t, result, " item.value = item.value * 2")
|
||||
}
|
||||
43
processor/luahelper-test-regress.lua
Normal file
43
processor/luahelper-test-regress.lua
Normal 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!")
|
||||
224
processor/luahelper-test-xml.lua
Normal file
224
processor/luahelper-test-xml.lua
Normal file
@@ -0,0 +1,224 @@
|
||||
-- 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 findElements
|
||||
test("findElements finds all matching elements recursively", function()
|
||||
local testXML = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item", _attr = { name = "sword" } },
|
||||
{ _tag = "item", _attr = { name = "shield" } },
|
||||
{
|
||||
_tag = "container",
|
||||
_children = {
|
||||
{ _tag = "item", _attr = { name = "potion" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
local items = findElements(testXML, "item")
|
||||
assert(#items == 3, "Should find 3 items total (recursive)")
|
||||
assert(items[1]._attr.name == "sword", "First item should be sword")
|
||||
assert(items[3]._attr.name == "potion", "Third item should be potion (from nested)")
|
||||
end)
|
||||
|
||||
-- Test getNumAttr and setNumAttr
|
||||
test("getNumAttr gets numeric attribute", function()
|
||||
local elem = { _tag = "item", _attr = { damage = "10" } }
|
||||
local damage = getNumAttr(elem, "damage")
|
||||
assert(damage == 10, "Should get damage as number")
|
||||
end)
|
||||
|
||||
test("getNumAttr returns nil for missing attribute", function()
|
||||
local elem = { _tag = "item", _attr = {} }
|
||||
local damage = getNumAttr(elem, "damage")
|
||||
assert(damage == nil, "Should return nil for missing attribute")
|
||||
end)
|
||||
|
||||
test("setNumAttr sets numeric attribute", function()
|
||||
local elem = { _tag = "item", _attr = {} }
|
||||
setNumAttr(elem, "damage", 20)
|
||||
assert(elem._attr.damage == "20", "Should set damage as string")
|
||||
end)
|
||||
|
||||
-- Test modifyNumAttr
|
||||
test("modifyNumAttr modifies numeric attribute", function()
|
||||
local elem = { _tag = "item", _attr = { weight = "5.5" } }
|
||||
local modified = modifyNumAttr(elem, "weight", function(val) return val * 2 end)
|
||||
assert(modified == true, "Should return true when modified")
|
||||
assert(elem._attr.weight == "11.0", "Should double weight")
|
||||
end)
|
||||
|
||||
test("modifyNumAttr returns false for missing attribute", function()
|
||||
local elem = { _tag = "item", _attr = {} }
|
||||
local modified = modifyNumAttr(elem, "weight", function(val) return val * 2 end)
|
||||
assert(modified == false, "Should return false when attribute missing")
|
||||
end)
|
||||
|
||||
-- Test filterElements
|
||||
test("filterElements filters by predicate", function()
|
||||
local testXML = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item", _attr = { healing = "20" } },
|
||||
{ _tag = "item", _attr = { damage = "10" } },
|
||||
{ _tag = "item", _attr = { healing = "50" } },
|
||||
},
|
||||
}
|
||||
local healingItems = filterElements(testXML, function(elem) return hasAttr(elem, "healing") end)
|
||||
assert(#healingItems == 2, "Should find 2 healing items")
|
||||
end)
|
||||
|
||||
-- Test visitElements
|
||||
test("visitElements visits all elements", function()
|
||||
local testXML = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item" },
|
||||
{ _tag = "container", _children = {
|
||||
{ _tag = "item" },
|
||||
} },
|
||||
},
|
||||
}
|
||||
local count = 0
|
||||
visitElements(testXML, function(elem) count = count + 1 end)
|
||||
assert(count == 4, "Should visit 4 elements (root + 2 items + container)")
|
||||
end)
|
||||
|
||||
-- Test getText and setText
|
||||
test("getText gets text content", function()
|
||||
local elem = { _tag = "item", _text = "Iron Sword" }
|
||||
local text = getText(elem)
|
||||
assert(text == "Iron Sword", "Should get text content")
|
||||
end)
|
||||
|
||||
test("setText sets text content", function()
|
||||
local elem = { _tag = "item" }
|
||||
setText(elem, "New Text")
|
||||
assert(elem._text == "New Text", "Should set text content")
|
||||
end)
|
||||
|
||||
-- Test hasAttr and getAttr
|
||||
test("hasAttr checks attribute existence", function()
|
||||
local elem = { _tag = "item", _attr = { damage = "10" } }
|
||||
assert(hasAttr(elem, "damage") == true, "Should have damage")
|
||||
assert(hasAttr(elem, "magic") == false, "Should not have magic")
|
||||
end)
|
||||
|
||||
test("getAttr gets attribute value", function()
|
||||
local elem = { _tag = "item", _attr = { name = "sword" } }
|
||||
assert(getAttr(elem, "name") == "sword", "Should get name attribute")
|
||||
assert(getAttr(elem, "missing") == nil, "Should return nil for missing")
|
||||
end)
|
||||
|
||||
test("setAttr sets attribute value", function()
|
||||
local elem = { _tag = "item" }
|
||||
setAttr(elem, "name", "sword")
|
||||
assert(elem._attr.name == "sword", "Should set attribute")
|
||||
end)
|
||||
|
||||
-- Test findFirstElement
|
||||
test("findFirstElement finds first direct child", function()
|
||||
local parent = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item", _attr = { id = "1" } },
|
||||
{ _tag = "item", _attr = { id = "2" } },
|
||||
},
|
||||
}
|
||||
local first = findFirstElement(parent, "item")
|
||||
assert(first._attr.id == "1", "Should find first item")
|
||||
end)
|
||||
|
||||
test("findFirstElement returns nil when not found", function()
|
||||
local parent = { _tag = "root", _children = {} }
|
||||
local result = findFirstElement(parent, "item")
|
||||
assert(result == nil, "Should return nil when not found")
|
||||
end)
|
||||
|
||||
-- Test getChildren
|
||||
test("getChildren gets all direct children with tag", function()
|
||||
local parent = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item", _attr = { id = "1" } },
|
||||
{ _tag = "config" },
|
||||
{ _tag = "item", _attr = { id = "2" } },
|
||||
},
|
||||
}
|
||||
local items = getChildren(parent, "item")
|
||||
assert(#items == 2, "Should get 2 items")
|
||||
assert(items[1]._attr.id == "1", "First should have id=1")
|
||||
assert(items[2]._attr.id == "2", "Second should have id=2")
|
||||
end)
|
||||
|
||||
-- Test countChildren
|
||||
test("countChildren counts direct children with tag", function()
|
||||
local parent = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item" },
|
||||
{ _tag = "config" },
|
||||
{ _tag = "item" },
|
||||
},
|
||||
}
|
||||
assert(countChildren(parent, "item") == 2, "Should count 2 items")
|
||||
assert(countChildren(parent, "config") == 1, "Should count 1 config")
|
||||
end)
|
||||
|
||||
-- Test addChild
|
||||
test("addChild adds child element", function()
|
||||
local parent = { _tag = "root", _children = {} }
|
||||
addChild(parent, { _tag = "item" })
|
||||
assert(#parent._children == 1, "Should have 1 child")
|
||||
assert(parent._children[1]._tag == "item", "Child should be item")
|
||||
end)
|
||||
|
||||
test("addChild creates children array if needed", function()
|
||||
local parent = { _tag = "root" }
|
||||
addChild(parent, { _tag = "item" })
|
||||
assert(parent._children ~= nil, "Should create _children")
|
||||
assert(#parent._children == 1, "Should have 1 child")
|
||||
end)
|
||||
|
||||
-- Test removeChildren
|
||||
test("removeChildren removes all matching children", function()
|
||||
local parent = {
|
||||
_tag = "root",
|
||||
_children = {
|
||||
{ _tag = "item" },
|
||||
{ _tag = "config" },
|
||||
{ _tag = "item" },
|
||||
},
|
||||
}
|
||||
local removed = removeChildren(parent, "item")
|
||||
assert(removed == 2, "Should remove 2 items")
|
||||
assert(#parent._children == 1, "Should have 1 child left")
|
||||
assert(parent._children[1]._tag == "config", "Remaining should be config")
|
||||
end)
|
||||
|
||||
test("removeChildren returns 0 when none found", function()
|
||||
local parent = {
|
||||
_tag = "root",
|
||||
_children = { { _tag = "item" } },
|
||||
}
|
||||
local removed = removeChildren(parent, "config")
|
||||
assert(removed == 0, "Should remove 0")
|
||||
assert(#parent._children == 1, "Should still have 1 child")
|
||||
end)
|
||||
|
||||
print("\nAll tests completed!")
|
||||
534
processor/luahelper-test.lua
Normal file
534
processor/luahelper-test.lua
Normal 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!")
|
||||
624
processor/luahelper.lua
Normal file
624
processor/luahelper.lua
Normal file
@@ -0,0 +1,624 @@
|
||||
-- Custom Lua helpers for math operations
|
||||
|
||||
--- Returns the minimum of two numbers
|
||||
--- @param a number First number
|
||||
--- @param b number Second number
|
||||
--- @return number Minimum value
|
||||
function min(a, b) return math.min(a, b) end
|
||||
|
||||
--- Returns the maximum of two numbers
|
||||
--- @param a number First number
|
||||
--- @param b number Second number
|
||||
--- @return number Maximum value
|
||||
function max(a, b) return math.max(a, b) end
|
||||
|
||||
--- Rounds a number to n decimal places
|
||||
--- @param x number Number to round
|
||||
--- @param n number? Number of decimal places (default: 0)
|
||||
--- @return number Rounded number
|
||||
function round(x, n)
|
||||
if n == nil then n = 0 end
|
||||
return math.floor(x * 10 ^ n + 0.5) / 10 ^ n
|
||||
end
|
||||
|
||||
--- Returns the floor of a number
|
||||
--- @param x number Number to floor
|
||||
--- @return number Floored number
|
||||
function floor(x) return math.floor(x) end
|
||||
|
||||
--- Returns the ceiling of a number
|
||||
--- @param x number Number to ceil
|
||||
--- @return number Ceiled number
|
||||
function ceil(x) return math.ceil(x) end
|
||||
|
||||
--- Converts string to uppercase
|
||||
--- @param s string String to convert
|
||||
--- @return string Uppercase string
|
||||
function upper(s) return string.upper(s) end
|
||||
|
||||
--- Converts string to lowercase
|
||||
--- @param s string String to convert
|
||||
--- @return string Lowercase string
|
||||
function lower(s) return string.lower(s) end
|
||||
|
||||
--- Formats a string using Lua string.format
|
||||
--- @param s string Format string
|
||||
--- @param ... any Values to format
|
||||
--- @return string Formatted string
|
||||
function format(s, ...) return string.format(s, ...) end
|
||||
|
||||
--- Removes leading and trailing whitespace from string
|
||||
--- @param s string String to trim
|
||||
--- @return string Trimmed string
|
||||
function trim(s) return string.gsub(s, "^%s*(.-)%s*$", "%1") end
|
||||
|
||||
--- Splits a string by separator
|
||||
--- @param inputstr string String to split
|
||||
--- @param sep string? Separator pattern (default: whitespace)
|
||||
--- @return table Array of string parts
|
||||
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
|
||||
|
||||
--- Converts string to number, returns 0 if invalid
|
||||
--- @param str string String to convert
|
||||
--- @return number Numeric value or 0
|
||||
function num(str) return tonumber(str) or 0 end
|
||||
|
||||
--- Converts number to string
|
||||
--- @param num number Number to convert
|
||||
--- @return string String representation
|
||||
function str(num) return tostring(num) end
|
||||
|
||||
--- Checks if string is numeric
|
||||
--- @param str string String to check
|
||||
--- @return boolean True if string is numeric
|
||||
function is_number(str) return tonumber(str) ~= nil end
|
||||
|
||||
--- Checks if table is a sequential array (1-indexed with no gaps)
|
||||
--- @param t table Table to check
|
||||
--- @return boolean True if table is an array
|
||||
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
|
||||
|
||||
-- ============================================================================
|
||||
-- XML HELPER FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
--- Find all elements with a specific tag name (recursive search)
|
||||
--- @param root table The root XML element (with _tag, _attr, _children fields)
|
||||
--- @param tagName string The tag name to search for
|
||||
--- @return table Array of matching elements
|
||||
function findElements(root, tagName)
|
||||
local results = {}
|
||||
|
||||
local function search(element)
|
||||
if element._tag == tagName then table.insert(results, element) end
|
||||
if element._children then
|
||||
for _, child in ipairs(element._children) do
|
||||
search(child)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
search(root)
|
||||
return results
|
||||
end
|
||||
|
||||
--- Visit all elements recursively and call a function on each
|
||||
--- @param root table The root XML element
|
||||
--- @param callback function Function to call with each element: callback(element, depth, path)
|
||||
function visitElements(root, callback)
|
||||
local function visit(element, depth, path)
|
||||
callback(element, depth, path)
|
||||
if element._children then
|
||||
for i, child in ipairs(element._children) do
|
||||
local childPath = path .. "/" .. child._tag .. "[" .. i .. "]"
|
||||
visit(child, depth + 1, childPath)
|
||||
end
|
||||
end
|
||||
end
|
||||
visit(root, 0, "/" .. root._tag)
|
||||
end
|
||||
|
||||
--- Get numeric value from XML element attribute
|
||||
--- @param element table XML element with _attr field
|
||||
--- @param attrName string Attribute name
|
||||
--- @return number|nil The numeric value or nil if not found/not numeric
|
||||
function getNumAttr(element, attrName)
|
||||
if not element._attr then return nil end
|
||||
local value = element._attr[attrName]
|
||||
if not value then return nil end
|
||||
return tonumber(value)
|
||||
end
|
||||
|
||||
--- Set numeric value to XML element attribute
|
||||
--- @param element table XML element with _attr field
|
||||
--- @param attrName string Attribute name
|
||||
--- @param value number Numeric value to set
|
||||
function setNumAttr(element, attrName, value)
|
||||
if not element._attr then element._attr = {} end
|
||||
element._attr[attrName] = tostring(value)
|
||||
end
|
||||
|
||||
--- Modify numeric attribute by applying a function
|
||||
--- @param element table XML element
|
||||
--- @param attrName string Attribute name
|
||||
--- @param func function Function that takes current value and returns new value
|
||||
--- @return boolean True if modification was made
|
||||
function modifyNumAttr(element, attrName, func)
|
||||
local current = getNumAttr(element, attrName)
|
||||
if current then
|
||||
setNumAttr(element, attrName, func(current))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find all elements matching a predicate function
|
||||
--- @param root table The root XML element
|
||||
--- @param predicate function Function that takes element and returns true/false
|
||||
--- @return table Array of matching elements
|
||||
function filterElements(root, predicate)
|
||||
local results = {}
|
||||
visitElements(root, function(element)
|
||||
if predicate(element) then table.insert(results, element) end
|
||||
end)
|
||||
return results
|
||||
end
|
||||
|
||||
--- Get text content of an element
|
||||
--- @param element table XML element
|
||||
--- @return string|nil The text content or nil
|
||||
function getText(element) return element._text end
|
||||
|
||||
--- Set text content of an element
|
||||
--- @param element table XML element
|
||||
--- @param text string Text content to set
|
||||
function setText(element, text) element._text = text end
|
||||
|
||||
--- Check if element has an attribute
|
||||
--- @param element table XML element
|
||||
--- @param attrName string Attribute name
|
||||
--- @return boolean True if attribute exists
|
||||
function hasAttr(element, attrName) return element._attr and element._attr[attrName] ~= nil end
|
||||
|
||||
--- Get attribute value as string
|
||||
--- @param element table XML element
|
||||
--- @param attrName string Attribute name
|
||||
--- @return string|nil The attribute value or nil
|
||||
function getAttr(element, attrName)
|
||||
if not element._attr then return nil end
|
||||
return element._attr[attrName]
|
||||
end
|
||||
|
||||
--- Set attribute value
|
||||
--- @param element table XML element
|
||||
--- @param attrName string Attribute name
|
||||
--- @param value any Value to set (will be converted to string)
|
||||
function setAttr(element, attrName, value)
|
||||
if not element._attr then element._attr = {} end
|
||||
element._attr[attrName] = tostring(value)
|
||||
end
|
||||
|
||||
--- Find first element with a specific tag name (searches direct children only)
|
||||
--- @param parent table The parent XML element
|
||||
--- @param tagName string The tag name to search for
|
||||
--- @return table|nil The first matching element or nil
|
||||
function findFirstElement(parent, tagName)
|
||||
if not parent._children then return nil end
|
||||
for _, child in ipairs(parent._children) do
|
||||
if child._tag == tagName then return child end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Add a child element to a parent
|
||||
--- @param parent table The parent XML element
|
||||
--- @param child table The child element to add
|
||||
function addChild(parent, child)
|
||||
if not parent._children then parent._children = {} end
|
||||
table.insert(parent._children, child)
|
||||
end
|
||||
|
||||
--- Remove all children with a specific tag name
|
||||
--- @param parent table The parent XML element
|
||||
--- @param tagName string The tag name to remove
|
||||
--- @return number Count of removed children
|
||||
function removeChildren(parent, tagName)
|
||||
if not parent._children then return 0 end
|
||||
local removed = 0
|
||||
local i = 1
|
||||
while i <= #parent._children do
|
||||
if parent._children[i]._tag == tagName then
|
||||
table.remove(parent._children, i)
|
||||
removed = removed + 1
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
return removed
|
||||
end
|
||||
|
||||
--- Get all direct children with a specific tag name
|
||||
--- @param parent table The parent XML element
|
||||
--- @param tagName string The tag name to search for
|
||||
--- @return table Array of matching children
|
||||
function getChildren(parent, tagName)
|
||||
local results = {}
|
||||
if not parent._children then return results end
|
||||
for _, child in ipairs(parent._children) do
|
||||
if child._tag == tagName then table.insert(results, child) end
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
--- Count children with a specific tag name
|
||||
--- @param parent table The parent XML element
|
||||
--- @param tagName string The tag name to count
|
||||
--- @return number Count of matching children
|
||||
function countChildren(parent, tagName)
|
||||
if not parent._children then return 0 end
|
||||
local count = 0
|
||||
for _, child in ipairs(parent._children) do
|
||||
if child._tag == tagName then count = count + 1 end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- JSON HELPER FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
--- Recursively visit all values in a JSON structure
|
||||
--- @param data table JSON data (nested tables)
|
||||
--- @param callback function Function called with (value, key, parent)
|
||||
function visitJSON(data, callback)
|
||||
local function visit(obj, key, parent)
|
||||
callback(obj, key, parent)
|
||||
if type(obj) == "table" then
|
||||
for k, v in pairs(obj) do
|
||||
visit(v, k, obj)
|
||||
end
|
||||
end
|
||||
end
|
||||
visit(data, nil, nil)
|
||||
end
|
||||
|
||||
--- Find all values in JSON matching a predicate
|
||||
--- @param data table JSON data
|
||||
--- @param predicate function Function that takes (value, key, parent) and returns true/false
|
||||
--- @return table Array of matching values
|
||||
function findInJSON(data, predicate)
|
||||
local results = {}
|
||||
visitJSON(data, function(value, key, parent)
|
||||
if predicate(value, key, parent) then table.insert(results, value) end
|
||||
end)
|
||||
return results
|
||||
end
|
||||
|
||||
--- Modify all numeric values in JSON matching a condition
|
||||
--- @param data table JSON data
|
||||
--- @param predicate function Function that takes (value, key, parent) and returns true/false
|
||||
--- @param modifier function Function that takes current value and returns new value
|
||||
function modifyJSONNumbers(data, predicate, modifier)
|
||||
visitJSON(data, function(value, key, parent)
|
||||
if type(value) == "number" and predicate(value, key, parent) then
|
||||
if parent and key then parent[key] = modifier(value) end
|
||||
end
|
||||
end)
|
||||
end
|
||||
29
processor/meta.go
Normal file
29
processor/meta.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
)
|
||||
|
||||
//go:embed meta.lua
|
||||
var metaFileContent string
|
||||
|
||||
var metaLogger = logger.Default.WithPrefix("meta")
|
||||
|
||||
// GenerateMetaFile generates meta.lua with function signatures for LuaLS autocomplete
|
||||
func GenerateMetaFile(outputPath string) error {
|
||||
metaLogger.Info("Generating meta.lua file for LuaLS autocomplete")
|
||||
|
||||
// Write the embedded meta file
|
||||
err := os.WriteFile(outputPath, []byte(metaFileContent), 0644)
|
||||
if err != nil {
|
||||
metaLogger.Error("Failed to write meta.lua: %v", err)
|
||||
return fmt.Errorf("failed to write meta.lua: %w", err)
|
||||
}
|
||||
|
||||
metaLogger.Info("Successfully generated meta.lua at %q", outputPath)
|
||||
return nil
|
||||
}
|
||||
245
processor/meta.lua
Normal file
245
processor/meta.lua
Normal file
@@ -0,0 +1,245 @@
|
||||
---@meta
|
||||
|
||||
---@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).
|
||||
|
||||
---@class XMLElement
|
||||
---@field _tag string The XML tag name
|
||||
---@field _attr {[string]: string}? XML attributes as key-value pairs
|
||||
---@field _text string? Text content of the element
|
||||
---@field _children XMLElement[]? Child elements
|
||||
|
||||
---@class JSONNode
|
||||
---@field [string] string | number | boolean | nil | JSONNode | JSONArray JSON object fields
|
||||
---@alias JSONArray (string | number | boolean | nil | JSONNode)[]
|
||||
|
||||
---@class CSVRow
|
||||
---@field [integer] string Numeric indices for field access
|
||||
---@field Headers string[]? Header row if hasheader was true
|
||||
|
||||
--- Returns the minimum of two numbers
|
||||
---@param a number First number
|
||||
---@param b number Second number
|
||||
---@return number #Minimum value
|
||||
function min(a, b) end
|
||||
|
||||
--- Returns the maximum of two numbers
|
||||
---@param a number First number
|
||||
---@param b number Second number
|
||||
---@return number #Maximum value
|
||||
function max(a, b) end
|
||||
|
||||
--- Rounds a number to n decimal places
|
||||
---@param x number Number to round
|
||||
---@param n number? Number of decimal places (default: 0)
|
||||
---@return number #Rounded number
|
||||
function round(x, n) end
|
||||
|
||||
--- Returns the floor of a number
|
||||
---@param x number Number to floor
|
||||
---@return number #Floored number
|
||||
function floor(x) end
|
||||
|
||||
--- Returns the ceiling of a number
|
||||
---@param x number Number to ceil
|
||||
---@return number #Ceiled number
|
||||
function ceil(x) end
|
||||
|
||||
--- Converts string to uppercase
|
||||
---@param s string String to convert
|
||||
---@return string #Uppercase string
|
||||
function upper(s) end
|
||||
|
||||
--- Converts string to lowercase
|
||||
---@param s string String to convert
|
||||
---@return string #Lowercase string
|
||||
function lower(s) end
|
||||
|
||||
--- Formats a string using Lua string.format
|
||||
---@param s string Format string
|
||||
---@param ... any Values to format
|
||||
---@return string #Formatted string
|
||||
function format(s, ...) end
|
||||
|
||||
--- Removes leading and trailing whitespace from string
|
||||
---@param s string String to trim
|
||||
---@return string #Trimmed string
|
||||
function trim(s) end
|
||||
|
||||
--- Splits a string by separator
|
||||
---@param inputstr string String to split
|
||||
---@param sep string? Separator pattern (default: whitespace)
|
||||
---@return string[] #Array of string parts
|
||||
function strsplit(inputstr, sep) end
|
||||
|
||||
--- Prints table structure recursively
|
||||
---@param table {[any]: any} Table to dump
|
||||
---@param depth number? Current depth (default: 0)
|
||||
function dump(table, depth) end
|
||||
|
||||
--- Validates options against a set of valid option keys.
|
||||
---@param options ParserOptions? The options table to validate
|
||||
function areOptionsValid(options) end
|
||||
|
||||
--- Parses CSV text into rows and fields using a minimal RFC 4180 state machine.
|
||||
--- Requirements/assumptions:<br>
|
||||
--- Input is a single string containing the entire CSV content.<br>
|
||||
--- Field separators are specified by delimiter option (default: comma).<br>
|
||||
--- Newlines between rows may be "\n" or "\r\n". "\r\n" is treated as one line break.<br>
|
||||
--- Fields may be quoted with double quotes (").<br>
|
||||
--- Inside quoted fields, doubled quotes ("") represent a literal quote character.<br>
|
||||
--- No backslash escaping is supported (not part of RFC 4180).<br>
|
||||
--- Newlines inside quoted fields are preserved as part of the field.<br>
|
||||
--- Leading/trailing spaces are preserved; no trimming is performed.<br>
|
||||
--- Empty fields and empty rows are preserved.<br>
|
||||
--- The final row is emitted even if the text does not end with a newline.<br>
|
||||
--- Lines starting with '#' (after optional leading whitespace) are treated as comments and skipped if hascomments is true.<br>
|
||||
---@param csv string The CSV text to parse.
|
||||
---@param options ParserOptions? Options for the parser
|
||||
---@return CSVRow[] #A table (array) of rows; each row is a table with numeric indices and optionally header-named keys.
|
||||
function fromCSV(csv, options) end
|
||||
|
||||
--- Converts a table of rows back to CSV text format (RFC 4180 compliant).<br>
|
||||
--- Requirements:<br>
|
||||
--- Input is a table (array) of rows, where each row is a table (array) of field values.<br>
|
||||
--- Field values are converted to strings using tostring().<br>
|
||||
--- Fields are quoted if they contain the delimiter, newlines, or double quotes.<br>
|
||||
--- Double quotes inside quoted fields are doubled ("").<br>
|
||||
--- Fields are joined with the specified delimiter; rows are joined with newlines.<br>
|
||||
--- If includeHeaders is true and rows have a Headers field, headers are included as the first row.<br>
|
||||
---@param rows CSVRow[] 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) end
|
||||
|
||||
--- Converts string to number, returns 0 if invalid
|
||||
---@param str string String to convert
|
||||
---@return number #Numeric value or 0
|
||||
function num(str) end
|
||||
|
||||
--- Converts number to string
|
||||
---@param num number Number to convert
|
||||
---@return string #String representation
|
||||
function str(num) end
|
||||
|
||||
--- Checks if string is numeric
|
||||
---@param str string String to check
|
||||
---@return boolean #True if string is numeric
|
||||
function is_number(str) end
|
||||
|
||||
--- Checks if table is a sequential array (1-indexed with no gaps)
|
||||
---@param t {[integer]: any} Table to check
|
||||
---@return boolean #True if table is an array
|
||||
function isArray(t) end
|
||||
|
||||
--- Find all elements with a specific tag name (recursive search)
|
||||
---@param root XMLElement The root XML element (with _tag, _attr, _children fields)
|
||||
---@param tagName string The tag name to search for
|
||||
---@return XMLElement[] #Array of matching elements
|
||||
function findElements(root, tagName) end
|
||||
|
||||
--- Visit all elements recursively and call a function on each
|
||||
---@param root XMLElement The root XML element
|
||||
---@param callback fun(element: XMLElement, depth: number, path: string) Function to call with each element
|
||||
function visitElements(root, callback) end
|
||||
|
||||
--- Get numeric value from XML element attribute
|
||||
---@param element XMLElement XML element with _attr field
|
||||
---@param attrName string Attribute name
|
||||
---@return number? #The numeric value or nil if not found/not numeric
|
||||
function getNumAttr(element, attrName) end
|
||||
|
||||
--- Set numeric value to XML element attribute
|
||||
---@param element XMLElement XML element with _attr field
|
||||
---@param attrName string Attribute name
|
||||
---@param value number Numeric value to set
|
||||
function setNumAttr(element, attrName, value) end
|
||||
|
||||
--- Modify numeric attribute by applying a function
|
||||
---@param element XMLElement XML element
|
||||
---@param attrName string Attribute name
|
||||
---@param func fun(currentValue: number): number Function that takes current value and returns new value
|
||||
---@return boolean #True if modification was made
|
||||
function modifyNumAttr(element, attrName, func) end
|
||||
|
||||
--- Find all elements matching a predicate function
|
||||
---@param root XMLElement The root XML element
|
||||
---@param predicate fun(element: XMLElement): boolean Function that takes element and returns true/false
|
||||
---@return XMLElement[] #Array of matching elements
|
||||
function filterElements(root, predicate) end
|
||||
|
||||
--- Get text content of an element
|
||||
---@param element XMLElement XML element
|
||||
---@return string? #The text content or nil
|
||||
function getText(element) end
|
||||
|
||||
--- Set text content of an element
|
||||
---@param element XMLElement XML element
|
||||
---@param text string Text content to set
|
||||
function setText(element, text) end
|
||||
|
||||
--- Check if element has an attribute
|
||||
---@param element XMLElement XML element
|
||||
---@param attrName string Attribute name
|
||||
---@return boolean #True if attribute exists
|
||||
function hasAttr(element, attrName) end
|
||||
|
||||
--- Get attribute value as string
|
||||
---@param element XMLElement XML element
|
||||
---@param attrName string Attribute name
|
||||
---@return string? #The attribute value or nil
|
||||
function getAttr(element, attrName) end
|
||||
|
||||
--- Set attribute value
|
||||
---@param element XMLElement XML element
|
||||
---@param attrName string Attribute name
|
||||
---@param value string | number | boolean Value to set (will be converted to string)
|
||||
function setAttr(element, attrName, value) end
|
||||
|
||||
--- Find first element with a specific tag name (searches direct children only)
|
||||
---@param parent XMLElement The parent XML element
|
||||
---@param tagName string The tag name to search for
|
||||
---@return XMLElement? #The first matching element or nil
|
||||
function findFirstElement(parent, tagName) end
|
||||
|
||||
--- Add a child element to a parent
|
||||
---@param parent XMLElement The parent XML element
|
||||
---@param child XMLElement The child element to add
|
||||
function addChild(parent, child) end
|
||||
|
||||
--- Remove all children with a specific tag name
|
||||
---@param parent XMLElement The parent XML element
|
||||
---@param tagName string The tag name to remove
|
||||
---@return number #Count of removed children
|
||||
function removeChildren(parent, tagName) end
|
||||
|
||||
--- Get all direct children with a specific tag name
|
||||
---@param parent XMLElement The parent XML element
|
||||
---@param tagName string The tag name to search for
|
||||
---@return XMLElement[] #Array of matching children
|
||||
function getChildren(parent, tagName) end
|
||||
|
||||
--- Count children with a specific tag name
|
||||
---@param parent XMLElement The parent XML element
|
||||
---@param tagName string The tag name to count
|
||||
---@return number #Count of matching children
|
||||
function countChildren(parent, tagName) end
|
||||
|
||||
--- Recursively visit all values in a JSON structure
|
||||
---@param data JSONNode | JSONArray JSON data (nested tables)
|
||||
---@param callback fun(value: string | number | boolean | nil | JSONNode | JSONArray, key: string?, parent: JSONNode?): nil Function called with (value, key, parent)
|
||||
function visitJSON(data, callback) end
|
||||
|
||||
--- Find all values in JSON matching a predicate
|
||||
---@param data JSONNode | JSONArray JSON data
|
||||
---@param predicate fun(value: string | number | boolean | nil | JSONNode | JSONArray, key: string?, parent: JSONNode?): boolean Function that takes (value, key, parent) and returns true/false
|
||||
---@return (string | number | boolean | nil | JSONNode | JSONArray)[] #Array of matching values
|
||||
function findInJSON(data, predicate) end
|
||||
|
||||
--- Modify all numeric values in JSON matching a condition
|
||||
---@param data JSONNode | JSONArray JSON data
|
||||
---@param predicate fun(value: string | number | boolean | nil | JSONNode | JSONArray, key: string?, parent: JSONNode?): boolean Function that takes (value, key, parent) and returns true/false
|
||||
---@param modifier fun(currentValue: number): number Function that takes current value and returns new value
|
||||
function modifyJSONNumbers(data, predicate, modifier) end
|
||||
@@ -1,155 +1,154 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"cook/utils"
|
||||
|
||||
"modify/logger"
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
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
|
||||
// We will see, it could easily be...
|
||||
|
||||
var globalVariables = map[string]interface{}{}
|
||||
|
||||
func SetVariables(vars map[string]interface{}) {
|
||||
for k, v := range vars {
|
||||
globalVariables[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func NewLuaState() (*lua.LState, error) {
|
||||
newLStateLogger := processorLogger.WithPrefix("NewLuaState")
|
||||
newLStateLogger.Debug("Creating new Lua state")
|
||||
L := lua.NewState()
|
||||
// defer L.Close()
|
||||
|
||||
// Load math library
|
||||
logger.Debug("Loading Lua math library")
|
||||
newLStateLogger.Debug("Loading Lua math library")
|
||||
L.Push(L.GetGlobal("require"))
|
||||
L.Push(lua.LString("math"))
|
||||
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)
|
||||
}
|
||||
newLStateLogger.Debug("Lua math library loaded")
|
||||
|
||||
// Initialize helper functions
|
||||
logger.Debug("Initializing Lua helper functions")
|
||||
newLStateLogger.Debug("Initializing Lua helper functions")
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
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) {
|
||||
// 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:
|
||||
fromLuaLogger.Debug("Processing Lua table")
|
||||
isArray, err := IsLuaTableArray(L, v)
|
||||
if err != nil {
|
||||
fromLuaLogger.Error("Failed to determine if Lua table is array: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
fromLuaLogger.Debug("Lua table is array: %t", isArray)
|
||||
if isArray {
|
||||
fromLuaLogger.Debug("Converting Lua table to Go array")
|
||||
result := make([]interface{}, 0)
|
||||
v.ForEach(func(key lua.LValue, value lua.LValue) {
|
||||
converted, _ := FromLua(L, value)
|
||||
result = append(result, converted)
|
||||
})
|
||||
fromLuaLogger.Trace("Converted Go array: %v", result)
|
||||
return result, nil
|
||||
} else {
|
||||
fromLuaLogger.Debug("Converting Lua table to Go map")
|
||||
result := make(map[string]interface{})
|
||||
v.ForEach(func(key lua.LValue, value lua.LValue) {
|
||||
converted, _ := FromLua(L, value)
|
||||
result[key.String()] = converted
|
||||
})
|
||||
fromLuaLogger.Trace("Converted Go map: %v", result)
|
||||
return result, nil
|
||||
}
|
||||
case lua.LString:
|
||||
fromLuaLogger.Debug("Converting Lua string to Go string")
|
||||
fromLuaLogger.Trace("Lua string: %q", string(v))
|
||||
return string(v), nil
|
||||
case lua.LBool:
|
||||
fromLuaLogger.Debug("Converting Lua boolean to Go boolean")
|
||||
fromLuaLogger.Trace("Lua boolean: %t", bool(v))
|
||||
return bool(v), nil
|
||||
case lua.LNumber:
|
||||
fromLuaLogger.Debug("Converting Lua number to Go float64")
|
||||
fromLuaLogger.Trace("Lua number: %f", float64(v))
|
||||
return float64(v), nil
|
||||
default:
|
||||
fromLuaLogger.Debug("Unsupported Lua type, returning nil")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Use our predefined helper function from InitLuaHelpers
|
||||
err := L.DoString(`is_array = isArray(table_to_check)`)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -157,111 +156,33 @@ func IsLuaTableArray(L *lua.LState, v *lua.LTable) (bool, error) {
|
||||
isArray := L.GetGlobal("is_array")
|
||||
// LVIsFalse returns true if a given LValue is a nil or false otherwise false.
|
||||
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
|
||||
}
|
||||
|
||||
// InitLuaHelpers initializes common Lua helper functions
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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("fetch", L.NewFunction(fetch))
|
||||
L.SetGlobal("re", L.NewFunction(EvalRegex))
|
||||
initLuaHelpersLogger.Debug("Lua print and fetch functions bound to Go")
|
||||
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 {
|
||||
prependLuaAssignmentLogger := processorLogger.WithPrefix("PrependLuaAssignment").WithField("originalLuaExpr", luaExpr)
|
||||
prependLuaAssignmentLogger.Debug("Prepending Lua assignment if necessary")
|
||||
// Auto-prepend v1 for expressions starting with operators
|
||||
if strings.HasPrefix(luaExpr, "*") ||
|
||||
strings.HasPrefix(luaExpr, "/") ||
|
||||
@@ -270,30 +191,83 @@ func PrependLuaAssignment(luaExpr string) string {
|
||||
strings.HasPrefix(luaExpr, "^") ||
|
||||
strings.HasPrefix(luaExpr, "%") {
|
||||
luaExpr = "v1 = v1" + luaExpr
|
||||
prependLuaAssignmentLogger.Debug("Prepended 'v1 = v1' due to operator prefix")
|
||||
} else if strings.HasPrefix(luaExpr, "=") {
|
||||
// Handle direct assignment with = operator
|
||||
luaExpr = "v1 " + luaExpr
|
||||
prependLuaAssignmentLogger.Debug("Prepended 'v1' due to direct assignment operator")
|
||||
}
|
||||
|
||||
// Add assignment if needed
|
||||
if !strings.Contains(luaExpr, "=") {
|
||||
luaExpr = "v1 = " + luaExpr
|
||||
prependLuaAssignmentLogger.Debug("Prepended 'v1 =' as no assignment was found")
|
||||
}
|
||||
prependLuaAssignmentLogger.Trace("Final Lua expression after prepending: %q", luaExpr)
|
||||
return luaExpr
|
||||
}
|
||||
|
||||
// LoadExternalLuaFile loads Lua code from an external file
|
||||
func LoadExternalLuaFile(luaPath string, sourceDir string) (string, error) {
|
||||
loadLuaLogger := processorLogger.WithPrefix("LoadExternalLuaFile").WithField("luaPath", luaPath).WithField("sourceDir", sourceDir)
|
||||
loadLuaLogger.Debug("Loading external Lua file")
|
||||
|
||||
// Resolve path: if relative, resolve relative to sourceDir; if absolute, use as-is
|
||||
var resolvedPath string
|
||||
if filepath.IsAbs(luaPath) {
|
||||
resolvedPath = luaPath
|
||||
} else {
|
||||
if sourceDir == "" {
|
||||
// No source directory, use current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
loadLuaLogger.Error("Failed to get current working directory: %v", err)
|
||||
return "", fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
resolvedPath = filepath.Join(cwd, luaPath)
|
||||
} else {
|
||||
resolvedPath = filepath.Join(sourceDir, luaPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize path
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
loadLuaLogger.Debug("Resolved Lua file path: %q", resolvedPath)
|
||||
|
||||
// Read the file
|
||||
content, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
loadLuaLogger.Error("Failed to read Lua file %q: %v", resolvedPath, err)
|
||||
return "", fmt.Errorf("failed to read Lua file %q: %w", luaPath, err)
|
||||
}
|
||||
|
||||
loadLuaLogger.Debug("Successfully loaded %d bytes from Lua file %q", len(content), resolvedPath)
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// BuildLuaScript prepares a Lua expression from shorthand notation
|
||||
func BuildLuaScript(luaExpr string) string {
|
||||
logger.Debug("Building Lua script from expression: %s", luaExpr)
|
||||
func BuildLuaScript(luaExpr string, sourceDir string) string {
|
||||
buildLuaScriptLogger := processorLogger.WithPrefix("BuildLuaScript").WithField("inputLuaExpr", luaExpr)
|
||||
buildLuaScriptLogger.Debug("Building full Lua script from expression")
|
||||
|
||||
// Check if this is an external Lua file reference
|
||||
if strings.HasPrefix(luaExpr, "@") {
|
||||
luaPath := strings.TrimPrefix(luaExpr, "@")
|
||||
externalLua, err := LoadExternalLuaFile(luaPath, sourceDir)
|
||||
if err != nil {
|
||||
buildLuaScriptLogger.Error("Failed to load external Lua file: %v", err)
|
||||
// Return error script that will fail at runtime
|
||||
return fmt.Sprintf(`error("Failed to load external Lua file: %v")`, err)
|
||||
}
|
||||
luaExpr = externalLua
|
||||
buildLuaScriptLogger.Debug("Loaded external Lua file, %d characters", len(luaExpr))
|
||||
}
|
||||
|
||||
// Perform $var substitutions from globalVariables
|
||||
luaExpr = replaceVariables(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(`
|
||||
function run()
|
||||
%s
|
||||
@@ -301,11 +275,73 @@ func BuildLuaScript(luaExpr string) string {
|
||||
local res = run()
|
||||
modified = res == nil or res
|
||||
`, luaExpr)
|
||||
buildLuaScriptLogger.Trace("Generated full Lua script: %q", utils.LimitString(fullScript, 200))
|
||||
|
||||
return fullScript
|
||||
}
|
||||
|
||||
// BuildJSONLuaScript prepares a Lua expression for JSON mode
|
||||
func BuildJSONLuaScript(luaExpr string, sourceDir string) string {
|
||||
buildJSONLuaScriptLogger := processorLogger.WithPrefix("BuildJSONLuaScript").WithField("inputLuaExpr", luaExpr)
|
||||
buildJSONLuaScriptLogger.Debug("Building full Lua script for JSON mode from expression")
|
||||
|
||||
// Check if this is an external Lua file reference
|
||||
if strings.HasPrefix(luaExpr, "@") {
|
||||
luaPath := strings.TrimPrefix(luaExpr, "@")
|
||||
externalLua, err := LoadExternalLuaFile(luaPath, sourceDir)
|
||||
if err != nil {
|
||||
buildJSONLuaScriptLogger.Error("Failed to load external Lua file: %v", err)
|
||||
// Return error script that will fail at runtime
|
||||
return fmt.Sprintf(`error("Failed to load external Lua file: %v")`, err)
|
||||
}
|
||||
luaExpr = externalLua
|
||||
buildJSONLuaScriptLogger.Debug("Loaded external Lua file, %d characters", len(luaExpr))
|
||||
}
|
||||
|
||||
// Perform $var substitutions from globalVariables
|
||||
luaExpr = replaceVariables(luaExpr)
|
||||
|
||||
fullScript := fmt.Sprintf(`
|
||||
function run()
|
||||
%s
|
||||
end
|
||||
local res = run()
|
||||
modified = res == nil or res
|
||||
`, luaExpr)
|
||||
buildJSONLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
|
||||
|
||||
return fullScript
|
||||
}
|
||||
|
||||
func replaceVariables(expr string) string {
|
||||
// $varName -> literal value
|
||||
varNameRe := regexp.MustCompile(`\$(\w+)`)
|
||||
return varNameRe.ReplaceAllStringFunc(expr, func(m string) string {
|
||||
name := varNameRe.FindStringSubmatch(m)[1]
|
||||
if v, ok := globalVariables[name]; ok {
|
||||
switch val := v.(type) {
|
||||
case int, int64, float32, float64:
|
||||
return fmt.Sprintf("%v", val)
|
||||
case bool:
|
||||
if val {
|
||||
return "true"
|
||||
} else {
|
||||
return "false"
|
||||
}
|
||||
case string:
|
||||
// Quote strings for Lua literal
|
||||
return fmt.Sprintf("%q", val)
|
||||
default:
|
||||
return fmt.Sprintf("%q", fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
func printToGo(L *lua.LState) int {
|
||||
printToGoLogger := processorLogger.WithPrefix("printToGo")
|
||||
printToGoLogger.Debug("Lua print function called, redirecting to Go logger")
|
||||
top := L.GetTop()
|
||||
|
||||
args := make([]interface{}, top)
|
||||
@@ -319,8 +355,254 @@ func printToGo(L *lua.LState) int {
|
||||
parts = append(parts, fmt.Sprintf("%v", arg))
|
||||
}
|
||||
message := strings.Join(parts, " ")
|
||||
printToGoLogger.Trace("Lua print message: %q", message)
|
||||
|
||||
// Use the LUA log level with a script tag
|
||||
logger.Lua("%s", message)
|
||||
printToGoLogger.Debug("Message logged from Lua")
|
||||
return 0
|
||||
}
|
||||
|
||||
func fetch(L *lua.LState) int {
|
||||
fetchLogger := processorLogger.WithPrefix("fetch")
|
||||
fetchLogger.Debug("Lua fetch function called")
|
||||
// Get URL from first argument
|
||||
url := L.ToString(1)
|
||||
if url == "" {
|
||||
fetchLogger.Error("Fetch failed: URL is required")
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString("URL is required"))
|
||||
return 2
|
||||
}
|
||||
fetchLogger.Debug("Fetching URL: %q", url)
|
||||
|
||||
// Get options from second argument if provided
|
||||
var method = "GET"
|
||||
var headers = make(map[string]string)
|
||||
var body = ""
|
||||
|
||||
if L.GetTop() > 1 {
|
||||
options := L.ToTable(2)
|
||||
if options != nil {
|
||||
fetchLogger.Debug("Processing fetch options")
|
||||
// Get method
|
||||
if methodVal := options.RawGetString("method"); methodVal != lua.LNil {
|
||||
method = methodVal.String()
|
||||
fetchLogger.Trace("Method from options: %q", method)
|
||||
}
|
||||
|
||||
// Get headers
|
||||
if headersVal := options.RawGetString("headers"); headersVal != lua.LNil {
|
||||
if headersTable, ok := headersVal.(*lua.LTable); ok {
|
||||
fetchLogger.Trace("Processing headers table")
|
||||
headersTable.ForEach(func(key lua.LValue, value lua.LValue) {
|
||||
headers[key.String()] = value.String()
|
||||
fetchLogger.Trace("Header: %q = %q", key.String(), value.String())
|
||||
})
|
||||
}
|
||||
fetchLogger.Trace("All headers: %v", headers)
|
||||
}
|
||||
|
||||
// Get body
|
||||
if bodyVal := options.RawGetString("body"); bodyVal != lua.LNil {
|
||||
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
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
fetchLogger.Error("Error creating HTTP request: %v", err)
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(fmt.Sprintf("Error creating request: %v", err)))
|
||||
return 2
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
fetchLogger.Debug("HTTP request created and headers set")
|
||||
fetchLogger.Trace("HTTP Request: %+v", req)
|
||||
|
||||
// Make request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fetchLogger.Error("Error making HTTP request: %v", err)
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(fmt.Sprintf("Error making request: %v", err)))
|
||||
return 2
|
||||
}
|
||||
defer func() {
|
||||
fetchLogger.Debug("Closing HTTP response body")
|
||||
resp.Body.Close()
|
||||
}()
|
||||
fetchLogger.Debug("HTTP request executed. Status Code: %d", resp.StatusCode)
|
||||
|
||||
// Read response body
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fetchLogger.Error("Error reading response body: %v", err)
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(fmt.Sprintf("Error reading response: %v", err)))
|
||||
return 2
|
||||
}
|
||||
fetchLogger.Trace("Response body length: %d", len(bodyBytes))
|
||||
|
||||
// Create response table
|
||||
responseTable := L.NewTable()
|
||||
responseTable.RawSetString("status", lua.LNumber(resp.StatusCode))
|
||||
responseTable.RawSetString("statusText", lua.LString(resp.Status))
|
||||
responseTable.RawSetString("ok", lua.LBool(resp.StatusCode >= 200 && resp.StatusCode < 300))
|
||||
responseTable.RawSetString("body", lua.LString(string(bodyBytes)))
|
||||
fetchLogger.Debug("Created Lua response table")
|
||||
|
||||
// Set headers in response
|
||||
headersTable := L.NewTable()
|
||||
for key, values := range resp.Header {
|
||||
headersTable.RawSetString(key, lua.LString(values[0]))
|
||||
fetchLogger.Trace("Response header: %q = %q", key, values[0])
|
||||
}
|
||||
responseTable.RawSetString("headers", headersTable)
|
||||
fetchLogger.Trace("Full response table: %v", responseTable)
|
||||
|
||||
L.Push(responseTable)
|
||||
fetchLogger.Debug("Pushed response table to Lua stack")
|
||||
return 1
|
||||
}
|
||||
|
||||
func EvalRegex(L *lua.LState) int {
|
||||
evalRegexLogger := processorLogger.WithPrefix("evalRegex")
|
||||
evalRegexLogger.Debug("Lua evalRegex function called")
|
||||
|
||||
pattern := L.ToString(1)
|
||||
input := L.ToString(2)
|
||||
|
||||
evalRegexLogger.Debug("Pattern: %q, Input: %q", pattern, input)
|
||||
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(input)
|
||||
|
||||
evalRegexLogger.Debug("Go regex matches: %v (count: %d)", matches, len(matches))
|
||||
evalRegexLogger.Debug("Matches is nil: %t", matches == nil)
|
||||
|
||||
if len(matches) > 0 {
|
||||
matchesTable := L.NewTable()
|
||||
for i, match := range matches {
|
||||
matchesTable.RawSetInt(i+1, lua.LString(match))
|
||||
evalRegexLogger.Debug("Set table[%d] = %q", i+1, match)
|
||||
}
|
||||
L.Push(matchesTable)
|
||||
} else {
|
||||
L.Push(lua.LNil)
|
||||
}
|
||||
|
||||
evalRegexLogger.Debug("Pushed matches table to Lua stack")
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// GetLuaFunctionsHelp returns a comprehensive help string for all available Lua functions
|
||||
func GetLuaFunctionsHelp() string {
|
||||
return `Lua Functions Available in Global Environment:
|
||||
|
||||
MATH FUNCTIONS:
|
||||
min(a, b) - Returns the minimum of two numbers
|
||||
max(a, b) - Returns the maximum of two numbers
|
||||
round(x, n) - Rounds x to n decimal places (default 0)
|
||||
floor(x) - Returns the floor of x
|
||||
ceil(x) - Returns the ceiling of x
|
||||
|
||||
STRING FUNCTIONS:
|
||||
upper(s) - Converts string to uppercase
|
||||
lower(s) - Converts string to lowercase
|
||||
format(s, ...) - Formats string using Lua string.format
|
||||
trim(s) - Removes leading/trailing whitespace
|
||||
strsplit(inputstr, sep) - Splits string by separator (default: whitespace)
|
||||
fromCSV(csv, options) - Parses CSV text into rows of fields
|
||||
options: {delimiter=",", hasheader=false, hascomments=false}
|
||||
toCSV(rows, options) - Converts table of rows to CSV text format
|
||||
options: {delimiter=",", hasheader=false}
|
||||
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
|
||||
|
||||
XML HELPER FUNCTIONS:
|
||||
findElements(root, tagName) - Find all elements with specific tag name (recursive)
|
||||
findFirstElement(parent, tagName) - Find first direct child with specific tag name
|
||||
visitElements(root, callback) - Visit all elements recursively
|
||||
callback(element, depth, path)
|
||||
filterElements(root, predicate) - Find elements matching condition
|
||||
predicate(element) returns true/false
|
||||
getNumAttr(element, attrName) - Get numeric attribute value
|
||||
setNumAttr(element, attrName, value) - Set numeric attribute value
|
||||
modifyNumAttr(element, attrName, func)- Modify numeric attribute with function
|
||||
func(currentValue) returns newValue
|
||||
hasAttr(element, attrName) - Check if attribute exists
|
||||
getAttr(element, attrName) - Get attribute value as string
|
||||
setAttr(element, attrName, value) - Set attribute value
|
||||
getText(element) - Get element text content
|
||||
setText(element, text) - Set element text content
|
||||
addChild(parent, child) - Add child element to parent
|
||||
removeChildren(parent, tagName) - Remove all children with specific tag name
|
||||
getChildren(parent, tagName) - Get all direct children with specific tag name
|
||||
countChildren(parent, tagName) - Count direct children with specific tag name
|
||||
|
||||
JSON HELPER FUNCTIONS:
|
||||
visitJSON(data, callback) - Visit all values in JSON structure
|
||||
callback(value, key, parent)
|
||||
findInJSON(data, predicate) - Find values matching condition
|
||||
predicate(value, key, parent) returns true/false
|
||||
modifyJSONNumbers(data, predicate, modifier) - Modify numeric values
|
||||
predicate(value, key, parent) returns true/false
|
||||
modifier(currentValue) returns newValue
|
||||
|
||||
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:
|
||||
-- Math
|
||||
round(3.14159, 2) -> 3.14
|
||||
min(5, 3) -> 3
|
||||
|
||||
-- String
|
||||
strsplit("a,b,c", ",") -> {"a", "b", "c"}
|
||||
upper("hello") -> "HELLO"
|
||||
num("123") -> 123
|
||||
|
||||
-- XML (where root is XML element with _tag, _attr, _children fields)
|
||||
local items = findElements(root, "Item")
|
||||
for _, item in ipairs(items) do
|
||||
modifyNumAttr(item, "Weight", function(w) return w * 2 end)
|
||||
end
|
||||
|
||||
-- JSON (where data is parsed JSON object)
|
||||
visitJSON(data, function(value, key, parent)
|
||||
if type(value) == "number" and key == "price" then
|
||||
parent[key] = value * 1.5
|
||||
end
|
||||
end)
|
||||
|
||||
-- HTTP
|
||||
local response = fetch("https://api.example.com/data")
|
||||
if response.ok then
|
||||
print(response.body)
|
||||
end`
|
||||
}
|
||||
|
||||
218
processor/processor_coverage_test.go
Normal file
218
processor/processor_coverage_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// Test replaceVariables function
|
||||
func TestReplaceVariables(t *testing.T) {
|
||||
// Setup global variables
|
||||
globalVariables = map[string]interface{}{
|
||||
"multiplier": 2.5,
|
||||
"prefix": "TEST_",
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
"count": 42,
|
||||
}
|
||||
defer func() {
|
||||
globalVariables = make(map[string]interface{})
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Replace numeric variable",
|
||||
input: "v1 * $multiplier",
|
||||
expected: "v1 * 2.5",
|
||||
},
|
||||
{
|
||||
name: "Replace string variable",
|
||||
input: `s1 = $prefix .. "value"`,
|
||||
expected: `s1 = "TEST_" .. "value"`,
|
||||
},
|
||||
{
|
||||
name: "Replace boolean true",
|
||||
input: "enabled = $enabled",
|
||||
expected: "enabled = true",
|
||||
},
|
||||
{
|
||||
name: "Replace boolean false",
|
||||
input: "disabled = $disabled",
|
||||
expected: "disabled = false",
|
||||
},
|
||||
{
|
||||
name: "Replace integer",
|
||||
input: "count = $count",
|
||||
expected: "count = 42",
|
||||
},
|
||||
{
|
||||
name: "Multiple replacements",
|
||||
input: "$count * $multiplier",
|
||||
expected: "42 * 2.5",
|
||||
},
|
||||
{
|
||||
name: "No variables",
|
||||
input: "v1 * 2",
|
||||
expected: "v1 * 2",
|
||||
},
|
||||
{
|
||||
name: "Undefined variable",
|
||||
input: "v1 * $undefined",
|
||||
expected: "v1 * $undefined",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := replaceVariables(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test SetVariables with all type cases
|
||||
func TestSetVariablesAllTypes(t *testing.T) {
|
||||
vars := map[string]interface{}{
|
||||
"int_val": 42,
|
||||
"int64_val": int64(100),
|
||||
"float32_val": float32(3.14),
|
||||
"float64_val": 2.718,
|
||||
"bool_true": true,
|
||||
"bool_false": false,
|
||||
"string_val": "hello",
|
||||
}
|
||||
|
||||
SetVariables(vars)
|
||||
|
||||
// Create Lua state to verify
|
||||
L, err := NewLuaState()
|
||||
assert.NoError(t, err)
|
||||
defer L.Close()
|
||||
|
||||
// Verify int64
|
||||
int64Val := L.GetGlobal("int64_val")
|
||||
assert.Equal(t, lua.LTNumber, int64Val.Type())
|
||||
assert.Equal(t, 100.0, float64(int64Val.(lua.LNumber)))
|
||||
|
||||
// Verify float32
|
||||
float32Val := L.GetGlobal("float32_val")
|
||||
assert.Equal(t, lua.LTNumber, float32Val.Type())
|
||||
assert.InDelta(t, 3.14, float64(float32Val.(lua.LNumber)), 0.01)
|
||||
|
||||
// Verify bool true
|
||||
boolTrue := L.GetGlobal("bool_true")
|
||||
assert.Equal(t, lua.LTBool, boolTrue.Type())
|
||||
assert.True(t, bool(boolTrue.(lua.LBool)))
|
||||
|
||||
// Verify bool false
|
||||
boolFalse := L.GetGlobal("bool_false")
|
||||
assert.Equal(t, lua.LTBool, boolFalse.Type())
|
||||
assert.False(t, bool(boolFalse.(lua.LBool)))
|
||||
|
||||
// Verify string
|
||||
stringVal := L.GetGlobal("string_val")
|
||||
assert.Equal(t, lua.LTString, stringVal.Type())
|
||||
assert.Equal(t, "hello", string(stringVal.(lua.LString)))
|
||||
}
|
||||
|
||||
// Test HTTP fetch with test server
|
||||
func TestFetchWithTestServer(t *testing.T) {
|
||||
// Create test HTTP server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
// Send response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status": "success"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test fetch
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
L.SetGlobal("fetch", L.NewFunction(fetch))
|
||||
|
||||
script := `
|
||||
response = fetch("` + server.URL + `")
|
||||
assert(response ~= nil, "Expected response")
|
||||
assert(response.ok == true, "Expected ok to be true")
|
||||
assert(response.status == 200, "Expected status 200")
|
||||
assert(response.body == '{"status": "success"}', "Expected correct body")
|
||||
`
|
||||
|
||||
err := L.DoString(script)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFetchWithTestServerPOST(t *testing.T) {
|
||||
// Create test HTTP server
|
||||
receivedBody := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
// Read body
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := r.Body.Read(buf)
|
||||
receivedBody = string(buf[:n])
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"created": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
L.SetGlobal("fetch", L.NewFunction(fetch))
|
||||
|
||||
script := `
|
||||
local opts = {
|
||||
method = "POST",
|
||||
headers = {["Content-Type"] = "application/json"},
|
||||
body = '{"test": "data"}'
|
||||
}
|
||||
response = fetch("` + server.URL + `", opts)
|
||||
assert(response ~= nil, "Expected response")
|
||||
assert(response.ok == true, "Expected ok to be true")
|
||||
assert(response.status == 201, "Expected status 201")
|
||||
`
|
||||
|
||||
err := L.DoString(script)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `{"test": "data"}`, receivedBody)
|
||||
}
|
||||
|
||||
func TestFetchWithTestServer404(t *testing.T) {
|
||||
// Create test HTTP server that returns 404
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error": "not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
L.SetGlobal("fetch", L.NewFunction(fetch))
|
||||
|
||||
script := `
|
||||
response = fetch("` + server.URL + `")
|
||||
assert(response ~= nil, "Expected response")
|
||||
assert(response.ok == false, "Expected ok to be false for 404")
|
||||
assert(response.status == 404, "Expected status 404")
|
||||
`
|
||||
|
||||
err := L.DoString(script)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
366
processor/processor_helper_test.go
Normal file
366
processor/processor_helper_test.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestSetVariables(t *testing.T) {
|
||||
// Test with various variable types
|
||||
vars := map[string]interface{}{
|
||||
"multiplier": 2.5,
|
||||
"prefix": "TEST_",
|
||||
"enabled": true,
|
||||
"count": 42,
|
||||
}
|
||||
|
||||
SetVariables(vars)
|
||||
|
||||
// Create a new Lua state to verify variables are set
|
||||
L, err := NewLuaState()
|
||||
assert.NoError(t, err)
|
||||
defer L.Close()
|
||||
|
||||
// Verify the variables are accessible
|
||||
multiplier := L.GetGlobal("multiplier")
|
||||
assert.Equal(t, lua.LTNumber, multiplier.Type())
|
||||
assert.Equal(t, 2.5, float64(multiplier.(lua.LNumber)))
|
||||
|
||||
prefix := L.GetGlobal("prefix")
|
||||
assert.Equal(t, lua.LTString, prefix.Type())
|
||||
assert.Equal(t, "TEST_", string(prefix.(lua.LString)))
|
||||
|
||||
enabled := L.GetGlobal("enabled")
|
||||
assert.Equal(t, lua.LTBool, enabled.Type())
|
||||
assert.True(t, bool(enabled.(lua.LBool)))
|
||||
|
||||
count := L.GetGlobal("count")
|
||||
assert.Equal(t, lua.LTNumber, count.Type())
|
||||
assert.Equal(t, 42.0, float64(count.(lua.LNumber)))
|
||||
}
|
||||
|
||||
func TestSetVariablesEmpty(t *testing.T) {
|
||||
// Test with empty map
|
||||
vars := map[string]interface{}{}
|
||||
SetVariables(vars)
|
||||
|
||||
// Should not panic
|
||||
L, err := NewLuaState()
|
||||
assert.NoError(t, err)
|
||||
defer L.Close()
|
||||
}
|
||||
|
||||
func TestSetVariablesNil(t *testing.T) {
|
||||
// Test with nil map
|
||||
SetVariables(nil)
|
||||
|
||||
// Should not panic
|
||||
L, err := NewLuaState()
|
||||
assert.NoError(t, err)
|
||||
defer L.Close()
|
||||
}
|
||||
|
||||
func TestGetLuaFunctionsHelp(t *testing.T) {
|
||||
help := GetLuaFunctionsHelp()
|
||||
|
||||
// Verify help is not empty
|
||||
assert.NotEmpty(t, help)
|
||||
|
||||
// Verify it contains documentation for key functions
|
||||
assert.Contains(t, help, "MATH FUNCTIONS")
|
||||
assert.Contains(t, help, "STRING FUNCTIONS")
|
||||
assert.Contains(t, help, "TABLE FUNCTIONS")
|
||||
assert.Contains(t, help, "XML HELPER FUNCTIONS")
|
||||
assert.Contains(t, help, "JSON HELPER FUNCTIONS")
|
||||
assert.Contains(t, help, "HTTP FUNCTIONS")
|
||||
assert.Contains(t, help, "REGEX FUNCTIONS")
|
||||
assert.Contains(t, help, "UTILITY FUNCTIONS")
|
||||
assert.Contains(t, help, "EXAMPLES")
|
||||
|
||||
// Verify specific functions are documented
|
||||
assert.Contains(t, help, "min(a, b)")
|
||||
assert.Contains(t, help, "max(a, b)")
|
||||
assert.Contains(t, help, "round(x, n)")
|
||||
assert.Contains(t, help, "fetch(url, options)")
|
||||
assert.Contains(t, help, "findElements(root, tagName)")
|
||||
assert.Contains(t, help, "visitJSON(data, callback)")
|
||||
assert.Contains(t, help, "re(pattern, input)")
|
||||
assert.Contains(t, help, "print(...)")
|
||||
}
|
||||
|
||||
func TestFetchFunction(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
// Register the fetch function
|
||||
L.SetGlobal("fetch", L.NewFunction(fetch))
|
||||
|
||||
// Test 1: Missing URL should return nil and error
|
||||
err := L.DoString(`
|
||||
result, err = fetch("")
|
||||
assert(result == nil, "Expected nil result for empty URL")
|
||||
assert(err ~= nil, "Expected error for empty URL")
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 2: Invalid URL should return error
|
||||
err = L.DoString(`
|
||||
result, err = fetch("not-a-valid-url")
|
||||
assert(result == nil, "Expected nil result for invalid URL")
|
||||
assert(err ~= nil, "Expected error for invalid URL")
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFetchFunctionWithOptions(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
// Register the fetch function
|
||||
L.SetGlobal("fetch", L.NewFunction(fetch))
|
||||
|
||||
// Test with options (should fail gracefully with invalid URL)
|
||||
err := L.DoString(`
|
||||
local opts = {
|
||||
method = "POST",
|
||||
headers = {["Content-Type"] = "application/json"},
|
||||
body = '{"test": "data"}'
|
||||
}
|
||||
result, err = fetch("http://invalid-domain-that-does-not-exist.local", opts)
|
||||
-- Should get error due to invalid domain
|
||||
assert(result == nil, "Expected nil result for invalid domain")
|
||||
assert(err ~= nil, "Expected error for invalid domain")
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPrependLuaAssignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple assignment",
|
||||
input: "10",
|
||||
expected: "v1 = 10",
|
||||
},
|
||||
{
|
||||
name: "Expression",
|
||||
input: "v1 * 2",
|
||||
expected: "v1 = v1 * 2",
|
||||
},
|
||||
{
|
||||
name: "Assignment with equal sign",
|
||||
input: "= 5",
|
||||
expected: "v1 = 5",
|
||||
},
|
||||
{
|
||||
name: "Complex expression",
|
||||
input: "math.floor(v1 / 2)",
|
||||
expected: "v1 = math.floor(v1 / 2)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := PrependLuaAssignment(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestBuildJSONLuaScript(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "Simple JSON modification",
|
||||
input: "data.value = data.value * 2; modified = true",
|
||||
contains: []string{
|
||||
"data.value = data.value * 2",
|
||||
"modified = true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Complex JSON script",
|
||||
input: "for i, item in ipairs(data.items) do item.price = item.price * 1.5 end; modified = true",
|
||||
contains: []string{
|
||||
"for i, item in ipairs(data.items)",
|
||||
"modified = true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := BuildJSONLuaScript(tt.input, "")
|
||||
for _, substr := range tt.contains {
|
||||
assert.Contains(t, result, substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToGo(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
// Register the print function
|
||||
L.SetGlobal("print", L.NewFunction(printToGo))
|
||||
|
||||
// Test printing various types
|
||||
err := L.DoString(`
|
||||
print("Hello, World!")
|
||||
print(42)
|
||||
print(true)
|
||||
print(3.14)
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEvalRegex(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
|
||||
// Register the regex function
|
||||
L.SetGlobal("re", L.NewFunction(EvalRegex))
|
||||
|
||||
// Test 1: Simple match
|
||||
err := L.DoString(`
|
||||
matches = re("(\\d+)", "The answer is 42")
|
||||
assert(matches ~= nil, "Expected matches")
|
||||
assert(matches[1] == "42", "Expected full match to be 42")
|
||||
assert(matches[2] == "42", "Expected capture group to be 42")
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 2: No match
|
||||
err = L.DoString(`
|
||||
matches = re("(\\d+)", "No numbers here")
|
||||
assert(matches == nil, "Expected nil for no match")
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 3: Multiple capture groups
|
||||
err = L.DoString(`
|
||||
matches = re("(\\w+)\\s+(\\d+)", "item 123")
|
||||
assert(matches ~= nil, "Expected matches")
|
||||
assert(matches[1] == "item 123", "Expected full match")
|
||||
assert(matches[2] == "item", "Expected first capture group")
|
||||
assert(matches[3] == "123", "Expected second capture group")
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEstimatePatternComplexity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
minExpected int
|
||||
}{
|
||||
{
|
||||
name: "Simple literal",
|
||||
pattern: "hello",
|
||||
minExpected: 1,
|
||||
},
|
||||
{
|
||||
name: "With capture group",
|
||||
pattern: "(\\d+)",
|
||||
minExpected: 2,
|
||||
},
|
||||
{
|
||||
name: "Complex pattern",
|
||||
pattern: "(?P<name>\\w+)\\s+(?P<value>\\d+\\.\\d+)",
|
||||
minExpected: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
complexity := estimatePatternComplexity(tt.pattern)
|
||||
assert.GreaterOrEqual(t, complexity, tt.minExpected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestParseNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected float64
|
||||
shouldOk bool
|
||||
}{
|
||||
{"Integer", "42", 42.0, true},
|
||||
{"Float", "3.14", 3.14, true},
|
||||
{"Negative", "-10", -10.0, true},
|
||||
{"Invalid", "not a number", 0, false},
|
||||
{"Empty", "", 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, ok := parseNumeric(tt.input)
|
||||
assert.Equal(t, tt.shouldOk, ok)
|
||||
if tt.shouldOk {
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{"Integer value", 42.0, "42"},
|
||||
{"Float value", 3.14, "3.14"},
|
||||
{"Negative integer", -10.0, "-10"},
|
||||
{"Negative float", -3.14, "-3.14"},
|
||||
{"Zero", 0.0, "0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatNumeric(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaHelperFunctionsDocumentation(t *testing.T) {
|
||||
help := GetLuaFunctionsHelp()
|
||||
|
||||
// All main function categories should be documented
|
||||
expectedCategories := []string{
|
||||
"MATH FUNCTIONS",
|
||||
"STRING FUNCTIONS",
|
||||
"XML HELPER FUNCTIONS",
|
||||
"JSON HELPER FUNCTIONS",
|
||||
}
|
||||
|
||||
for _, category := range expectedCategories {
|
||||
assert.Contains(t, help, category, "Help should contain category: %s", category)
|
||||
}
|
||||
|
||||
// Verify some key functions are mentioned
|
||||
keyFunctions := []string{
|
||||
"findElements",
|
||||
"visitElements",
|
||||
"visitJSON",
|
||||
"round",
|
||||
"fetch",
|
||||
}
|
||||
|
||||
for _, fn := range keyFunctions {
|
||||
assert.Contains(t, help, fn, "Help should mention function: %s", fn)
|
||||
}
|
||||
}
|
||||
147
processor/processor_test.go
Normal file
147
processor/processor_test.go
Normal 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)
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
|
||||
"modify/logger"
|
||||
"modify/utils"
|
||||
)
|
||||
|
||||
// regexLogger is a scoped logger for the processor/regex package.
|
||||
var regexLogger = logger.Default.WithPrefix("processor/regex")
|
||||
|
||||
type CaptureGroup struct {
|
||||
Name string
|
||||
Value string
|
||||
@@ -20,49 +22,63 @@ type CaptureGroup struct {
|
||||
Range [2]int
|
||||
}
|
||||
|
||||
// ProcessContent applies regex replacement with Lua processing
|
||||
func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceCommand, error) {
|
||||
var commands []utils.ReplaceCommand
|
||||
logger.Trace("Processing regex: %q", command.Regex)
|
||||
// ProcessRegex applies regex replacement with Lua processing.
|
||||
// The filename here exists ONLY so we can pass it to the lua environment.
|
||||
// It's not used for anything else.
|
||||
func ProcessRegex(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) {
|
||||
processRegexLogger := regexLogger.WithPrefix("ProcessRegex").WithField("commandName", command.Name).WithField("file", filename)
|
||||
processRegexLogger.Debug("Starting regex processing for file")
|
||||
processRegexLogger.Trace("Initial file content length: %d", len(content))
|
||||
processRegexLogger.Trace("Command details: %+v", command)
|
||||
|
||||
var commands []utils.ReplaceCommand
|
||||
// Start timing the regex processing
|
||||
startTime := time.Now()
|
||||
|
||||
// We don't HAVE to do this multiple times for a pattern
|
||||
// But it's quick enough for us to not care
|
||||
pattern := resolveRegexPlaceholders(command.Regex)
|
||||
logger.Debug("Compiling regex pattern: %s", pattern)
|
||||
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
|
||||
// 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
|
||||
// And a bunch of other shit
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
processRegexLogger.Debug("Trimmed regex pattern: %s", pattern)
|
||||
|
||||
patternCompileStart := time.Now()
|
||||
compiledPattern, err := regexp.Compile(pattern)
|
||||
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)
|
||||
}
|
||||
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
|
||||
// More important is that we don't fuck up the command
|
||||
// But we shouldn't be able to since it's passed by value
|
||||
previous := command.Lua
|
||||
luaExpr := BuildLuaScript(command.Lua)
|
||||
logger.Debug("Transformed Lua expression: %q → %q", previous, luaExpr)
|
||||
previousLuaExpr := command.Lua
|
||||
luaExpr := BuildLuaScript(command.Lua, command.SourceDir)
|
||||
processRegexLogger.Debug("Transformed Lua expression: %q → %q", previousLuaExpr, luaExpr)
|
||||
processRegexLogger.Trace("Full Lua script: %q", utils.LimitString(luaExpr, 200))
|
||||
|
||||
// Process all regex matches
|
||||
matchFindStart := time.Now()
|
||||
indices := compiledPattern.FindAllStringSubmatchIndex(content, -1)
|
||||
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)
|
||||
processRegexLogger.Trace("Match indices: %v", indices)
|
||||
|
||||
// Log pattern complexity metrics
|
||||
patternComplexity := estimatePatternComplexity(pattern)
|
||||
logger.Debug("Pattern complexity estimate: %d", patternComplexity)
|
||||
processRegexLogger.Debug("Pattern complexity estimate: %d", patternComplexity)
|
||||
|
||||
if len(indices) == 0 {
|
||||
logger.Warning("No matches found for regex: %q", pattern)
|
||||
logger.Debug("Total regex processing time: %v", time.Since(startTime))
|
||||
processRegexLogger.Warning("No matches found for regex: %s", pattern)
|
||||
processRegexLogger.Debug("Total regex processing time: %v", time.Since(startTime))
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
@@ -71,19 +87,21 @@ func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceC
|
||||
// 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)
|
||||
for i, matchIndices := range indices {
|
||||
logger.Debug("Processing match %d of %d", i+1, len(indices))
|
||||
logger.Trace("Match indices: %v (match position %d-%d)", matchIndices, matchIndices[0], matchIndices[1])
|
||||
matchLogger := processRegexLogger.WithField("matchNum", i+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()
|
||||
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)
|
||||
}
|
||||
L.SetGlobal("file", lua.LString(filename))
|
||||
// Hmm... Maybe we don't want to defer this..
|
||||
// Maybe we want to close them every iteration
|
||||
// We'll leave it as is for now
|
||||
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
|
||||
// Plus it's a little cleaner to surgically replace our matches
|
||||
@@ -92,20 +110,17 @@ func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceC
|
||||
// So when we're cutting open the array we say 0:7 + modified + 7:end
|
||||
// As if concatenating in the middle of the array
|
||||
// Plus it supports lookarounds
|
||||
match := content[matchIndices[0]:matchIndices[1]]
|
||||
matchPreview := match
|
||||
if len(match) > 50 {
|
||||
matchPreview = match[:47] + "..."
|
||||
}
|
||||
logger.Trace("Matched content: %q (length: %d)", matchPreview, len(match))
|
||||
matchContent := content[matchIndices[0]:matchIndices[1]]
|
||||
matchPreview := utils.LimitString(matchContent, 50)
|
||||
matchLogger.Trace("Matched content: %q (length: %d)", matchPreview, len(matchContent))
|
||||
|
||||
groups := matchIndices[2:]
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -116,11 +131,11 @@ func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceC
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -135,6 +150,7 @@ func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceC
|
||||
start := groups[i*2]
|
||||
end := groups[i*2+1]
|
||||
if start == -1 || end == -1 {
|
||||
matchLogger.Debug("Skipping empty or unmatched capture group #%d (name: %q)", i+1, name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -147,71 +163,77 @@ func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceC
|
||||
|
||||
// Include name info in log if available
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
captureGroups = deduplicateGroups(captureGroups)
|
||||
// Use the DeduplicateGroups flag to control whether to deduplicate capture groups
|
||||
if !command.NoDedup {
|
||||
matchLogger.Debug("Deduplicating capture groups as specified in command settings")
|
||||
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 {
|
||||
logger.Error("Failed to set Lua variables: %v", err)
|
||||
matchLogger.Error("Failed to set Lua variables for capture groups: %v", err)
|
||||
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 {
|
||||
logger.Error("Lua script execution failed: %v\nScript: %s\nCapture Groups: %+v",
|
||||
err, luaExpr, captureGroups)
|
||||
matchLogger.Error("Lua script execution failed: %v\nScript: %s\nCapture Groups: %+v",
|
||||
err, utils.LimitString(luaExpr, 200), captureGroups)
|
||||
continue
|
||||
}
|
||||
logger.Trace("Lua script executed successfully")
|
||||
matchLogger.Debug("Lua script executed successfully")
|
||||
|
||||
// Get modifications from Lua
|
||||
captureGroups, err = fromLua(L, captureGroups)
|
||||
updatedCaptureGroups, err := fromLua(L, captureGroups)
|
||||
if err != nil {
|
||||
logger.Error("Failed to retrieve modifications from Lua: %v", err)
|
||||
matchLogger.Error("Failed to retrieve modifications from Lua: %v", err)
|
||||
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 := ""
|
||||
replacementVar := L.GetGlobal("replacement")
|
||||
if replacementVar.Type() != lua.LTNil {
|
||||
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
|
||||
modifiedVal := L.GetGlobal("modified")
|
||||
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
|
||||
}
|
||||
|
||||
if replacement == "" {
|
||||
// Apply the modifications to the original match
|
||||
replacement = match
|
||||
|
||||
// Count groups that were actually modified
|
||||
modifiedGroups := 0
|
||||
for _, capture := range captureGroups {
|
||||
modifiedGroupsCount := 0
|
||||
for _, capture := range updatedCaptureGroups {
|
||||
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 {
|
||||
logger.Info("Capture group unchanged: %s", capture.Value)
|
||||
matchLogger.Debug("Capture group unchanged: %s", utils.LimitString(capture.Value, 50))
|
||||
continue
|
||||
}
|
||||
|
||||
// Log what changed with context
|
||||
logger.Debug("Capture group %s scheduled for modification: %q → %q",
|
||||
capture.Name, capture.Value, capture.Updated)
|
||||
matchLogger.Debug("Capture group %q scheduled for modification: %q → %q",
|
||||
capture.Name, utils.LimitString(capture.Value, 50), utils.LimitString(capture.Updated, 50))
|
||||
|
||||
// Indices of the group are relative to content
|
||||
// To relate them to match we have to subtract the match start index
|
||||
@@ -221,42 +243,57 @@ func ProcessRegex(content string, command utils.ModifyCommand) ([]utils.ReplaceC
|
||||
To: capture.Range[1],
|
||||
With: capture.Updated,
|
||||
})
|
||||
matchLogger.Trace("Added replacement command: %+v", commands[len(commands)-1])
|
||||
}
|
||||
} else {
|
||||
matchLogger.Debug("Using full replacement string from Lua: %q", utils.LimitString(replacement, 50))
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: matchIndices[0],
|
||||
To: matchIndices[1],
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
groupLogger := deduplicateGroupsLogger.WithField("groupName", group.Name).WithField("groupRange", group.Range)
|
||||
groupLogger.Debug("Processing capture group")
|
||||
|
||||
overlaps := false
|
||||
logger.Debug("Checking capture group: %s with range %v", group.Name, group.Range)
|
||||
for _, existingGroup := range deduplicatedGroups {
|
||||
logger.Debug("Comparing with existing group: %s with range %v", existingGroup.Name, existingGroup.Range)
|
||||
if group.Range[0] < existingGroup.Range[1] && group.Range[1] > existingGroup.Range[0] {
|
||||
for _, kept := range accepted {
|
||||
// Overlap if start < keptEnd and end > keptStart (adjacent is allowed)
|
||||
if group.Range[0] < kept.Range[1] && group.Range[1] > kept.Range[0] {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if overlaps {
|
||||
// We CAN just continue despite this fuckup
|
||||
logger.Error("Overlapping capture group: %s", group.Name)
|
||||
groupLogger.Warning("Overlapping capture group detected and skipped.")
|
||||
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
|
||||
@@ -265,107 +302,186 @@ func deduplicateGroups(captureGroups []*CaptureGroup) []*CaptureGroup {
|
||||
// Expand to another capture group in the capture group
|
||||
// We really only want one (our named) capture group
|
||||
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
|
||||
if !strings.HasPrefix(pattern, "(?s)") {
|
||||
pattern = "(?s)" + pattern
|
||||
// Use fmt.Printf for test compatibility
|
||||
fmt.Printf("Pattern modified to include (?s): %s\n", pattern)
|
||||
resolveLogger.Debug("Prepended '(?s)' to pattern for single-line mode")
|
||||
}
|
||||
|
||||
namedGroupNum := regexp.MustCompile(`(?:(\?<[^>]+>)(!num))`)
|
||||
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)
|
||||
if len(parts) != 3 {
|
||||
funcLogger.Warning("Unexpected number of submatches for namedGroupNum: %d. Returning original match.", len(parts))
|
||||
return match
|
||||
}
|
||||
replacement := `-?\d*\.?\d+`
|
||||
funcLogger.Trace("Replacing !num in named group with: %q", replacement)
|
||||
return parts[1] + replacement
|
||||
})
|
||||
resolveLogger.Debug("Handled named group !num placeholders")
|
||||
|
||||
pattern = strings.ReplaceAll(pattern, "!num", `(-?\d*\.?\d+)`)
|
||||
resolveLogger.Debug("Replaced !num with numeric capture group")
|
||||
|
||||
pattern = strings.ReplaceAll(pattern, "!any", `.*?`)
|
||||
resolveLogger.Debug("Replaced !any with non-greedy wildcard")
|
||||
|
||||
pattern = strings.ReplaceAll(pattern, "\n", "\r?\n")
|
||||
resolveLogger.Debug("Added optional carriage return support for Windows line endings")
|
||||
|
||||
repPattern := regexp.MustCompile(`!rep\(([^,]+),\s*(\d+)\)`)
|
||||
// !rep(pattern, count) repeats the pattern n times
|
||||
// Inserting !any between each repetition
|
||||
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)
|
||||
if len(parts) != 3 {
|
||||
funcLogger.Warning("Unexpected number of submatches for repPattern: %d. Returning original match.", len(parts))
|
||||
return match
|
||||
}
|
||||
repeatedPattern := parts[1]
|
||||
count := parts[2]
|
||||
repetitions, _ := strconv.Atoi(count)
|
||||
return strings.Repeat(repeatedPattern+".*?", repetitions-1) + repeatedPattern
|
||||
countStr := parts[2]
|
||||
repetitions, err := strconv.Atoi(countStr)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
toLuaLogger := regexLogger.WithPrefix("toLua")
|
||||
toLuaLogger.Debug("Setting capture groups as Lua variables")
|
||||
|
||||
captureGroups, ok := data.([]*CaptureGroup)
|
||||
if !ok {
|
||||
toLuaLogger.Error("Invalid data type for toLua. Expected []*CaptureGroup, got %T", data)
|
||||
return fmt.Errorf("expected []*CaptureGroup for captures, got %T", data)
|
||||
}
|
||||
toLuaLogger.Trace("Input capture groups: %v", captureGroups)
|
||||
|
||||
groupindex := 0
|
||||
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 == "" {
|
||||
// We don't want to change the name of the capture group
|
||||
// Even if it's empty
|
||||
tempName := fmt.Sprintf("%d", groupindex+1)
|
||||
groupindex++
|
||||
groupLogger.Debug("Unnamed capture group, assigning temporary name: %q", tempName)
|
||||
|
||||
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)
|
||||
if err == nil {
|
||||
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 {
|
||||
val, err := strconv.ParseFloat(capture.Value, 64)
|
||||
if err == nil {
|
||||
L.SetGlobal(capture.Name, lua.LNumber(val))
|
||||
groupLogger.Trace("Set Lua global %s = %f (numeric)", capture.Name, val)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// FromLua implements the Processor interface for RegexProcessor
|
||||
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
|
||||
for _, capture := range captureGroups {
|
||||
if capture.Name == "" {
|
||||
capture.Name = fmt.Sprintf("%d", captureIndex+1)
|
||||
groupLogger := fromLuaLogger.WithField("originalCaptureName", capture.Name).WithField("originalValue", utils.LimitString(capture.Value, 50))
|
||||
groupLogger.Debug("Processing capture group to retrieve updated value")
|
||||
|
||||
vVarName := fmt.Sprintf("v%s", capture.Name)
|
||||
sVarName := fmt.Sprintf("s%s", capture.Name)
|
||||
if 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++
|
||||
|
||||
vLuaVal := L.GetGlobal(vVarName)
|
||||
sLuaVal := L.GetGlobal(sVarName)
|
||||
|
||||
groupLogger.Trace("Lua values for unnamed group: v=%v, s=%v", vLuaVal, sLuaVal)
|
||||
|
||||
if sLuaVal.Type() == lua.LTString {
|
||||
capture.Updated = sLuaVal.String()
|
||||
groupLogger.Trace("Updated value from s%s (string): %q", tempName, capture.Updated)
|
||||
}
|
||||
// Numbers have priority
|
||||
if vLuaVal.Type() == lua.LTNumber {
|
||||
capture.Updated = vLuaVal.String()
|
||||
groupLogger.Trace("Updated value from v%s (numeric): %q", tempName, capture.Updated)
|
||||
}
|
||||
} else {
|
||||
// Easy shit
|
||||
capture.Updated = L.GetGlobal(capture.Name).String()
|
||||
// Easy shit, directly use the named capture group
|
||||
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
|
||||
}
|
||||
|
||||
// estimatePatternComplexity gives a rough estimate of regex pattern complexity
|
||||
// This can help identify potentially problematic patterns
|
||||
func estimatePatternComplexity(pattern string) int {
|
||||
estimateComplexityLogger := regexLogger.WithPrefix("estimatePatternComplexity").WithField("pattern", utils.LimitString(pattern, 100))
|
||||
estimateComplexityLogger.Debug("Estimating regex pattern complexity")
|
||||
complexity := len(pattern)
|
||||
|
||||
// Add complexity for potentially expensive operations
|
||||
@@ -378,5 +494,6 @@ func estimatePatternComplexity(pattern string) int {
|
||||
complexity += strings.Count(pattern, "\\1") * 3 // Backreferences
|
||||
complexity += strings.Count(pattern, "{") * 2 // Counted repetition
|
||||
|
||||
estimateComplexityLogger.Debug("Estimated pattern complexity: %d", complexity)
|
||||
return complexity
|
||||
}
|
||||
|
||||
87
processor/regex_coverage_test.go
Normal file
87
processor/regex_coverage_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Test named capture group fallback when value is not in Lua
|
||||
func TestNamedCaptureGroupFallback(t *testing.T) {
|
||||
pattern := `value = (?P<myvalue>\d+)`
|
||||
input := `value = 42`
|
||||
// Don't set myvalue in Lua, but do something else so we get a match
|
||||
lua := `v1 = v1 * 2 -- Set v1 but not myvalue, test fallback`
|
||||
|
||||
cmd := utils.ModifyCommand{
|
||||
Name: "test_fallback",
|
||||
Regex: pattern,
|
||||
Lua: lua,
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatchIndex(input)
|
||||
assert.NotNil(t, matches)
|
||||
|
||||
replacements, err := ProcessRegex(input, cmd, "test.txt")
|
||||
|
||||
// Should not error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Since only v1 is set, myvalue should keep original
|
||||
// Should have 1 replacement for v1
|
||||
if replacements != nil {
|
||||
assert.GreaterOrEqual(t, len(replacements), 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Test named capture groups with nil value in Lua
|
||||
func TestNamedCaptureGroupNilInLua(t *testing.T) {
|
||||
pattern := `value = (?P<num>\d+)`
|
||||
input := `value = 123`
|
||||
// Set num to nil explicitly, and also set v1 to get a modification
|
||||
lua := `v1 = v1 .. "_test"; num = nil -- v1 modified, num set to nil`
|
||||
|
||||
cmd := utils.ModifyCommand{
|
||||
Name: "test_nil",
|
||||
Regex: pattern,
|
||||
Lua: lua,
|
||||
}
|
||||
|
||||
replacements, err := ProcessRegex(input, cmd, "test.txt")
|
||||
|
||||
// Should not error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should have replacements for v1, num should fallback to original
|
||||
if replacements != nil {
|
||||
assert.GreaterOrEqual(t, len(replacements), 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Test multiple named capture groups with some undefined
|
||||
func TestMixedNamedCaptureGroups(t *testing.T) {
|
||||
pattern := `(?P<key>\w+) = (?P<value>\d+)`
|
||||
input := `count = 100`
|
||||
lua := `key = key .. "_modified" -- Only modify key, leave value undefined`
|
||||
|
||||
cmd := utils.ModifyCommand{
|
||||
Name: "test_mixed",
|
||||
Regex: pattern,
|
||||
Lua: lua,
|
||||
}
|
||||
|
||||
replacements, err := ProcessRegex(input, cmd, "test.txt")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, replacements)
|
||||
|
||||
// Apply replacements
|
||||
result, _ := utils.ExecuteModifications(replacements, input)
|
||||
|
||||
// key should be modified, value should remain unchanged
|
||||
assert.Contains(t, result, "count_modified")
|
||||
assert.Contains(t, result, "100")
|
||||
}
|
||||
@@ -2,9 +2,8 @@ package processor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"cook/utils"
|
||||
"io"
|
||||
"modify/utils"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -31,14 +30,14 @@ func normalizeWhitespace(s string) string {
|
||||
return re.ReplaceAllString(strings.TrimSpace(s), " ")
|
||||
}
|
||||
|
||||
func ApiAdaptor(content string, regex string, lua string) (string, int, int, error) {
|
||||
func APIAdaptor(content string, regex string, lua string) (string, int, int, error) {
|
||||
command := utils.ModifyCommand{
|
||||
Regex: regex,
|
||||
Lua: lua,
|
||||
LogLevel: "TRACE",
|
||||
}
|
||||
|
||||
commands, err := ProcessRegex(content, command)
|
||||
commands, err := ProcessRegex(content, command, "test")
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
@@ -80,7 +79,7 @@ func TestSimpleValueMultiplication(t *testing.T) {
|
||||
</item>
|
||||
</config>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(\d+)</value>`, "v1 = v1*1.5")
|
||||
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(\d+)</value>`, "v1 = v1*1.5")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
|
||||
@@ -101,7 +100,7 @@ func TestShorthandNotation(t *testing.T) {
|
||||
</item>
|
||||
</config>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(\d+)</value>`, "v1*1.5")
|
||||
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(\d+)</value>`, "v1*1.5")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
|
||||
@@ -122,7 +121,7 @@ func TestShorthandNotationFloats(t *testing.T) {
|
||||
</item>
|
||||
</config>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(\d+\.\d+)</value>`, "v1*1.5")
|
||||
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(\d+\.\d+)</value>`, "v1*1.5")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
|
||||
@@ -147,7 +146,7 @@ func TestArrayNotation(t *testing.T) {
|
||||
</prices>
|
||||
</config>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `(?s)<price>(\d+)</price>`, "v1*2")
|
||||
result, mods, matches, err := APIAdaptor(content, `(?s)<price>(\d+)</price>`, "v1*2")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches)
|
||||
@@ -168,7 +167,7 @@ func TestMultipleNumericMatches(t *testing.T) {
|
||||
<entry>400</entry>
|
||||
</data>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `<entry>(\d+)</entry>`, "v1*2")
|
||||
result, mods, matches, err := APIAdaptor(content, `<entry>(\d+)</entry>`, "v1*2")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 3, matches, "Expected 3 matches, got %d", matches)
|
||||
@@ -187,7 +186,7 @@ func TestMultipleStringMatches(t *testing.T) {
|
||||
<name>Mary_modified</name>
|
||||
</data>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `<name>([A-Za-z]+)</name>`, `s1 = s1 .. "_modified"`)
|
||||
result, mods, matches, err := APIAdaptor(content, `<name>([A-Za-z]+)</name>`, `s1 = s1 .. "_modified"`)
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
|
||||
@@ -206,7 +205,7 @@ func TestStringUpperCase(t *testing.T) {
|
||||
<user>MARY</user>
|
||||
</users>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `<user>([A-Za-z]+)</user>`, `s1 = string.upper(s1)`)
|
||||
result, mods, matches, err := APIAdaptor(content, `<user>([A-Za-z]+)</user>`, `s1 = string.upper(s1)`)
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
|
||||
@@ -225,7 +224,7 @@ func TestStringConcatenation(t *testing.T) {
|
||||
<product>Banana_fruit</product>
|
||||
</products>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `<product>([A-Za-z]+)</product>`, `s1 = s1 .. "_fruit"`)
|
||||
result, mods, matches, err := APIAdaptor(content, `<product>([A-Za-z]+)</product>`, `s1 = s1 .. "_fruit"`)
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 2, matches, "Expected 2 matches, got %d", matches)
|
||||
@@ -253,9 +252,9 @@ func TestDecimalValues(t *testing.T) {
|
||||
`
|
||||
|
||||
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)
|
||||
|
||||
normalizedModified := normalizeWhitespace(result)
|
||||
@@ -281,9 +280,9 @@ func TestLuaMathFunctions(t *testing.T) {
|
||||
`
|
||||
|
||||
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)
|
||||
|
||||
normalizedModified := normalizeWhitespace(modifiedContent)
|
||||
@@ -309,9 +308,9 @@ func TestDirectAssignment(t *testing.T) {
|
||||
`
|
||||
|
||||
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)
|
||||
|
||||
normalizedModified := normalizeWhitespace(modifiedContent)
|
||||
@@ -367,10 +366,10 @@ func TestStringAndNumericOperations(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Compile the regex pattern with multiline support
|
||||
pattern := "(?s)" + tt.regexPattern
|
||||
luaExpr := BuildLuaScript(tt.luaExpression)
|
||||
luaExpr := BuildLuaScript(tt.luaExpression, "")
|
||||
|
||||
// Process with our function
|
||||
result, modCount, _, err := ApiAdaptor(tt.input, pattern, luaExpr)
|
||||
result, modCount, _, err := APIAdaptor(tt.input, pattern, luaExpr)
|
||||
assert.NoError(t, err, "Process function failed: %v", err)
|
||||
|
||||
// Check results
|
||||
@@ -428,10 +427,10 @@ func TestEdgeCases(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Make sure the regex can match across multiple lines
|
||||
pattern := "(?s)" + tt.regexPattern
|
||||
luaExpr := BuildLuaScript(tt.luaExpression)
|
||||
luaExpr := BuildLuaScript(tt.luaExpression, "")
|
||||
|
||||
// Process with our function
|
||||
result, modCount, _, err := ApiAdaptor(tt.input, pattern, luaExpr)
|
||||
result, modCount, _, err := APIAdaptor(tt.input, pattern, luaExpr)
|
||||
assert.NoError(t, err, "Process function failed: %v", err)
|
||||
|
||||
// Check results
|
||||
@@ -454,7 +453,7 @@ func TestNamedCaptureGroups(t *testing.T) {
|
||||
</item>
|
||||
</config>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(?<amount>\d+)</value>`, "amount = amount * 2")
|
||||
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(?<amount>\d+)</value>`, "amount = amount * 2")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
|
||||
@@ -475,7 +474,7 @@ func TestNamedCaptureGroupsNum(t *testing.T) {
|
||||
</item>
|
||||
</config>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content, `(?s)<value>(?<amount>!num)</value>`, "amount = amount * 2")
|
||||
result, mods, matches, err := APIAdaptor(content, `(?s)<value>(?<amount>!num)</value>`, "amount = amount * 2")
|
||||
|
||||
assert.NoError(t, err, "Error processing content: %v", err)
|
||||
assert.Equal(t, 1, matches, "Expected 1 match, got %d", matches)
|
||||
@@ -496,7 +495,7 @@ func TestMultipleNamedCaptureGroups(t *testing.T) {
|
||||
<quantity>15</quantity>
|
||||
</product>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`(?s)<name>(?<prodName>[^<]+)</name>.*?<price>(?<prodPrice>\d+\.\d+)</price>.*?<quantity>(?<prodQty>\d+)</quantity>`,
|
||||
`prodName = string.upper(prodName)
|
||||
prodPrice = round(prodPrice + 8, 2)
|
||||
@@ -519,7 +518,7 @@ func TestMixedIndexedAndNamedCaptures(t *testing.T) {
|
||||
<data>VALUE</data>
|
||||
</entry>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`(?s)<id>(\d+)</id>.*?<data>(?<dataField>[^<]+)</data>`,
|
||||
`v1 = v1 * 2
|
||||
dataField = string.upper(dataField)`)
|
||||
@@ -551,7 +550,7 @@ func TestComplexNestedNamedCaptures(t *testing.T) {
|
||||
</contact>
|
||||
</person>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`(?s)<details>.*?<name>(?<fullName>[^<]+)</name>.*?<age>(?<age>\d+)</age>`,
|
||||
`fullName = string.upper(fullName) .. " (" .. age .. ")"`)
|
||||
|
||||
@@ -572,7 +571,7 @@ func TestNamedCaptureWithVariableReadback(t *testing.T) {
|
||||
<mana>300</mana>
|
||||
</stats>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`(?s)<health>(?<hp>\d+)</health>.*?<mana>(?<mp>\d+)</mana>`,
|
||||
`hp = hp * 1.5
|
||||
mp = mp * 1.5`)
|
||||
@@ -588,7 +587,7 @@ func TestNamedCaptureWithSpecialCharsInName(t *testing.T) {
|
||||
|
||||
expected := `<data value="84" min="10" max="100" />`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<data value="(?<val_1>\d+)"`,
|
||||
`val_1 = val_1 * 2`)
|
||||
|
||||
@@ -603,7 +602,7 @@ func TestEmptyNamedCapture(t *testing.T) {
|
||||
|
||||
expected := `<tag attr="default" />`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`attr="(?<value>.*?)"`,
|
||||
`value = value == "" and "default" or value`)
|
||||
|
||||
@@ -618,7 +617,7 @@ func TestMultipleNamedCapturesInSameLine(t *testing.T) {
|
||||
|
||||
expected := `<rect x="20" y="40" width="200" height="100" />`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`x="(?<x>\d+)" y="(?<y>\d+)" width="(?<w>\d+)" height="(?<h>\d+)"`,
|
||||
`x = x * 2
|
||||
y = y * 2
|
||||
@@ -642,7 +641,7 @@ func TestConditionalNamedCapture(t *testing.T) {
|
||||
<item status="inactive" count="10" />
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<item status="(?<status>[^"]+)" count="(?<count>\d+)"`,
|
||||
`count = status == "active" and count * 2 or count`)
|
||||
|
||||
@@ -663,7 +662,7 @@ func TestLuaFunctionsOnNamedCaptures(t *testing.T) {
|
||||
<user name="JANE SMITH" role="admin" />
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<user name="(?<name>[^"]+)" role="(?<role>[^"]+)"`,
|
||||
`-- Capitalize first letters for regular users
|
||||
if role == "user" then
|
||||
@@ -693,7 +692,7 @@ func TestNamedCaptureWithMath(t *testing.T) {
|
||||
<item price="19.99" quantity="3" total="59.97" />
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<item price="(?<price>\d+\.\d+)" quantity="(?<qty>\d+)"!any$`,
|
||||
`-- Calculate and add total
|
||||
replacement = string.format('<item price="%s" quantity="%s" total="%.2f" />',
|
||||
@@ -713,7 +712,7 @@ func TestNamedCaptureWithGlobals(t *testing.T) {
|
||||
|
||||
expected := `<temp unit="F">77</temp>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<temp unit="(?<unit>[CF]?)">(?<value>\d+)</temp>`,
|
||||
`if unit == "C" then
|
||||
value = value * 9/5 + 32
|
||||
@@ -740,7 +739,7 @@ func TestMixedDynamicAndNamedCaptures(t *testing.T) {
|
||||
<color rgb="0,255,0" name="GREEN" hex="#00FF00" />
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<color rgb="(?<r>\d+),(?<g>\d+),(?<b>\d+)" name="(?<colorName>[^"]+)" />`,
|
||||
`-- Uppercase the name
|
||||
colorName = string.upper(colorName)
|
||||
@@ -766,7 +765,7 @@ func TestNamedCapturesWithMultipleReferences(t *testing.T) {
|
||||
|
||||
expected := `<text format="uppercase" length="11">HELLO WORLD</text>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<text>(?<content>[^<]+)</text>`,
|
||||
`local uppercaseContent = string.upper(content)
|
||||
local contentLength = string.len(content)
|
||||
@@ -784,7 +783,7 @@ func TestNamedCaptureWithJsonData(t *testing.T) {
|
||||
|
||||
expected := `<data>{"name":"JOHN","age":30}</data>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<data>(?<json>\{.*?\})</data>`,
|
||||
`-- Parse JSON (simplified, assumes valid JSON)
|
||||
local name = json:match('"name":"([^"]+)"')
|
||||
@@ -814,7 +813,7 @@ func TestNamedCaptureInXML(t *testing.T) {
|
||||
</product>
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`(?s)<price currency="(?<currency>[^"]+)">(?<price>\d+\.\d+)</price>.*?<stock>(?<stock>\d+)</stock>`,
|
||||
`-- Add 20% to price if USD
|
||||
if currency == "USD" then
|
||||
@@ -871,7 +870,7 @@ func TestComprehensiveNamedCaptures(t *testing.T) {
|
||||
</products>
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`(?s)<product sku="(?<sku>[^"]+)" status="(?<status>[^"]+)"[^>]*>\s*<name>(?<product_name>[^<]+)</name>\s*<price currency="(?<currency>[^"]+)">(?<price>\d+\.\d+)</price>\s*<quantity>(?<qty>\d+)</quantity>`,
|
||||
`-- Only process in-stock items
|
||||
if status == "in-stock" then
|
||||
@@ -925,7 +924,7 @@ func TestVariousNamedCaptureFormats(t *testing.T) {
|
||||
</data>
|
||||
`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`<entry id="(?<id_num>\d+)" value="(?<val>\d+)"(?: status="(?<status>[^"]*)")? />`,
|
||||
`-- Prefix the ID with "ID-"
|
||||
id_num = "ID-" .. id_num
|
||||
@@ -964,7 +963,7 @@ func TestSimpleNamedCapture(t *testing.T) {
|
||||
|
||||
expected := `<product name="WIDGET" price="19.99"/>`
|
||||
|
||||
result, mods, matches, err := ApiAdaptor(content,
|
||||
result, mods, matches, err := APIAdaptor(content,
|
||||
`name="(?<product_name>[^"]+)"`,
|
||||
`product_name = string.upper(product_name)`)
|
||||
|
||||
@@ -979,29 +978,12 @@ func TestPatternWithoutPrefixGetsModified(t *testing.T) {
|
||||
// Setup
|
||||
pattern := "some.*pattern"
|
||||
|
||||
// Redirect stdout to capture fmt.Printf output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Execute function
|
||||
result := resolveRegexPlaceholders(pattern)
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
// Read captured output
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Verify results
|
||||
expectedPattern := "(?s)some.*pattern"
|
||||
assert.Equal(t, expectedPattern, result, "Expected pattern to be %q, got %q", expectedPattern, result)
|
||||
|
||||
expectedOutput := fmt.Sprintf("Pattern modified to include (?s): %s\n", expectedPattern)
|
||||
assert.Equal(t, expectedOutput, output, "Expected output message %q, got %q", expectedOutput, output)
|
||||
}
|
||||
|
||||
// Empty string input returns "(?s)"
|
||||
@@ -1009,29 +991,12 @@ func TestEmptyStringReturnsWithPrefix(t *testing.T) {
|
||||
// Setup
|
||||
pattern := ""
|
||||
|
||||
// Redirect stdout to capture fmt.Printf output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Execute function
|
||||
result := resolveRegexPlaceholders(pattern)
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
// Read captured output
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Verify results
|
||||
expectedPattern := "(?s)"
|
||||
assert.Equal(t, expectedPattern, result, "Expected pattern to be %q, got %q", expectedPattern, result)
|
||||
|
||||
expectedOutput := fmt.Sprintf("Pattern modified to include (?s): %s\n", expectedPattern)
|
||||
assert.Equal(t, expectedOutput, output, "Expected output message %q, got %q", expectedOutput, output)
|
||||
}
|
||||
|
||||
// Named group with "!num" pattern gets replaced with proper regex for numbers
|
||||
|
||||
847
processor/surgical_json_test.go
Normal file
847
processor/surgical_json_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"modify/logger"
|
||||
"os"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Only modify logger in test mode
|
||||
// This checks if we're running under 'go test'
|
||||
if os.Getenv("GO_TESTING") == "1" || os.Getenv("TESTING") == "1" {
|
||||
// Initialize logger with ERROR level for tests
|
||||
// to minimize noise in test output
|
||||
logger.Init(logger.LevelError)
|
||||
|
||||
// Optionally redirect logger output to discard
|
||||
// This prevents logger output from interfering with test output
|
||||
disableTestLogs := os.Getenv("ENABLE_TEST_LOGS") != "1"
|
||||
if disableTestLogs {
|
||||
// Create a new logger that writes to nowhere
|
||||
silentLogger := logger.New(io.Discard, "", 0)
|
||||
logger.DefaultLogger = silentLogger
|
||||
}
|
||||
}
|
||||
}
|
||||
533
processor/xml.go
Normal file
533
processor/xml.go
Normal file
@@ -0,0 +1,533 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
var xmlLogger = logger.Default.WithPrefix("processor/xml")
|
||||
|
||||
// XMLElement represents a parsed XML element with position tracking
|
||||
type XMLElement struct {
|
||||
Tag string
|
||||
Attributes map[string]XMLAttribute
|
||||
Text string
|
||||
Children []*XMLElement
|
||||
StartPos int64
|
||||
EndPos int64
|
||||
TextStart int64
|
||||
TextEnd int64
|
||||
}
|
||||
|
||||
// XMLAttribute represents an attribute with its position in the source
|
||||
type XMLAttribute struct {
|
||||
Value string
|
||||
ValueStart int64
|
||||
ValueEnd int64
|
||||
}
|
||||
|
||||
// parseXMLWithPositions parses XML while tracking byte positions of all elements and attributes
|
||||
func parseXMLWithPositions(content string) (*XMLElement, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(content))
|
||||
var root *XMLElement
|
||||
var stack []*XMLElement
|
||||
var lastPos int64
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse XML: %v", err)
|
||||
}
|
||||
|
||||
offset := decoder.InputOffset()
|
||||
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
// Find the actual start position of this element by searching for "<tagname"
|
||||
tagSearchPattern := "<" + t.Name.Local
|
||||
startPos := int64(strings.LastIndex(content[:offset], tagSearchPattern))
|
||||
|
||||
element := &XMLElement{
|
||||
Tag: t.Name.Local,
|
||||
Attributes: make(map[string]XMLAttribute),
|
||||
StartPos: startPos,
|
||||
Children: []*XMLElement{},
|
||||
}
|
||||
|
||||
// Parse attributes - search within the tag boundaries
|
||||
if len(t.Attr) > 0 {
|
||||
tagEnd := offset
|
||||
tagSection := content[startPos:tagEnd]
|
||||
|
||||
for _, attr := range t.Attr {
|
||||
// Find attribute in the tag section: attrname="value"
|
||||
attrPattern := attr.Name.Local + `="`
|
||||
attrIdx := strings.Index(tagSection, attrPattern)
|
||||
if attrIdx >= 0 {
|
||||
valueStart := startPos + int64(attrIdx) + int64(len(attrPattern))
|
||||
valueEnd := valueStart + int64(len(attr.Value))
|
||||
element.Attributes[attr.Name.Local] = XMLAttribute{
|
||||
Value: attr.Value,
|
||||
ValueStart: valueStart,
|
||||
ValueEnd: valueEnd,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(stack) > 0 {
|
||||
parent := stack[len(stack)-1]
|
||||
parent.Children = append(parent.Children, element)
|
||||
} else {
|
||||
root = element
|
||||
}
|
||||
|
||||
stack = append(stack, element)
|
||||
lastPos = offset
|
||||
|
||||
case xml.CharData:
|
||||
rawText := string(t)
|
||||
text := strings.TrimSpace(rawText)
|
||||
if len(stack) > 0 && text != "" {
|
||||
current := stack[len(stack)-1]
|
||||
current.Text = text
|
||||
|
||||
// The text content is between lastPos (after >) and offset (before </)
|
||||
// Search for the trimmed text within the raw content
|
||||
textInContent := content[lastPos:offset]
|
||||
trimmedStart := strings.Index(textInContent, text)
|
||||
if trimmedStart >= 0 {
|
||||
current.TextStart = lastPos + int64(trimmedStart)
|
||||
current.TextEnd = current.TextStart + int64(len(text))
|
||||
}
|
||||
}
|
||||
lastPos = offset
|
||||
|
||||
case xml.EndElement:
|
||||
if len(stack) > 0 {
|
||||
current := stack[len(stack)-1]
|
||||
current.EndPos = offset
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
lastPos = offset
|
||||
}
|
||||
}
|
||||
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// XMLChange represents a detected difference between original and modified XML structures
|
||||
type XMLChange struct {
|
||||
Type string // "text", "attribute", "add_element", "remove_element"
|
||||
Path string
|
||||
OldValue string
|
||||
NewValue string
|
||||
StartPos int64
|
||||
EndPos int64
|
||||
InsertText string
|
||||
}
|
||||
|
||||
func findXMLChanges(original, modified *XMLElement, path string) []XMLChange {
|
||||
var changes []XMLChange
|
||||
|
||||
// Check text content changes
|
||||
if original.Text != modified.Text {
|
||||
changes = append(changes, XMLChange{
|
||||
Type: "text",
|
||||
Path: path,
|
||||
OldValue: original.Text,
|
||||
NewValue: modified.Text,
|
||||
StartPos: original.TextStart,
|
||||
EndPos: original.TextEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// Check attribute changes
|
||||
for attrName, origAttr := range original.Attributes {
|
||||
if modAttr, exists := modified.Attributes[attrName]; exists {
|
||||
if origAttr.Value != modAttr.Value {
|
||||
changes = append(changes, XMLChange{
|
||||
Type: "attribute",
|
||||
Path: path + "/@" + attrName,
|
||||
OldValue: origAttr.Value,
|
||||
NewValue: modAttr.Value,
|
||||
StartPos: origAttr.ValueStart,
|
||||
EndPos: origAttr.ValueEnd,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Attribute removed
|
||||
changes = append(changes, XMLChange{
|
||||
Type: "remove_attribute",
|
||||
Path: path + "/@" + attrName,
|
||||
OldValue: origAttr.Value,
|
||||
StartPos: origAttr.ValueStart - int64(len(attrName)+2), // Include attr=" part
|
||||
EndPos: origAttr.ValueEnd + 1, // Include closing "
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check for added attributes
|
||||
for attrName, modAttr := range modified.Attributes {
|
||||
if _, exists := original.Attributes[attrName]; !exists {
|
||||
changes = append(changes, XMLChange{
|
||||
Type: "add_attribute",
|
||||
Path: path + "/@" + attrName,
|
||||
NewValue: modAttr.Value,
|
||||
StartPos: original.StartPos, // Will be adjusted to insert after tag name
|
||||
InsertText: fmt.Sprintf(` %s="%s"`, attrName, modAttr.Value),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check children recursively
|
||||
origChildMap := make(map[string][]*XMLElement)
|
||||
for _, child := range original.Children {
|
||||
origChildMap[child.Tag] = append(origChildMap[child.Tag], child)
|
||||
}
|
||||
|
||||
modChildMap := make(map[string][]*XMLElement)
|
||||
for _, child := range modified.Children {
|
||||
modChildMap[child.Tag] = append(modChildMap[child.Tag], child)
|
||||
}
|
||||
|
||||
// Compare children by tag name
|
||||
processedTags := make(map[string]bool)
|
||||
|
||||
for tag, origChildren := range origChildMap {
|
||||
processedTags[tag] = true
|
||||
modChildren := modChildMap[tag]
|
||||
|
||||
// Match children by index
|
||||
maxLen := len(origChildren)
|
||||
if len(modChildren) > maxLen {
|
||||
maxLen = len(modChildren)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
childPath := fmt.Sprintf("%s/%s[%d]", path, tag, i)
|
||||
if i < len(origChildren) && i < len(modChildren) {
|
||||
// Both exist, compare recursively
|
||||
childChanges := findXMLChanges(origChildren[i], modChildren[i], childPath)
|
||||
changes = append(changes, childChanges...)
|
||||
} else if i < len(origChildren) {
|
||||
// Child removed
|
||||
changes = append(changes, XMLChange{
|
||||
Type: "remove_element",
|
||||
Path: childPath,
|
||||
StartPos: origChildren[i].StartPos,
|
||||
EndPos: origChildren[i].EndPos,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle added children
|
||||
if len(modChildren) > len(origChildren) {
|
||||
for i := len(origChildren); i < len(modChildren); i++ {
|
||||
childPath := fmt.Sprintf("%s/%s[%d]", path, tag, i)
|
||||
// Generate XML text for the new element
|
||||
xmlText := serializeXMLElement(modChildren[i], " ")
|
||||
changes = append(changes, XMLChange{
|
||||
Type: "add_element",
|
||||
Path: childPath,
|
||||
InsertText: xmlText,
|
||||
StartPos: original.EndPos - int64(len(original.Tag)+3), // Before closing tag
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// serializeXMLElement converts an XMLElement back to XML text
|
||||
func serializeXMLElement(elem *XMLElement, indent string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(indent)
|
||||
sb.WriteString("<")
|
||||
sb.WriteString(elem.Tag)
|
||||
|
||||
// Write attributes
|
||||
attrNames := make([]string, 0, len(elem.Attributes))
|
||||
for name := range elem.Attributes {
|
||||
attrNames = append(attrNames, name)
|
||||
}
|
||||
sort.Strings(attrNames)
|
||||
|
||||
for _, name := range attrNames {
|
||||
attr := elem.Attributes[name]
|
||||
sb.WriteString(fmt.Sprintf(` %s="%s"`, name, attr.Value))
|
||||
}
|
||||
|
||||
if elem.Text == "" && len(elem.Children) == 0 {
|
||||
sb.WriteString(" />")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
sb.WriteString(">")
|
||||
|
||||
if elem.Text != "" {
|
||||
sb.WriteString(elem.Text)
|
||||
}
|
||||
|
||||
if len(elem.Children) > 0 {
|
||||
sb.WriteString("\n")
|
||||
for _, child := range elem.Children {
|
||||
sb.WriteString(serializeXMLElement(child, indent+" "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(indent)
|
||||
}
|
||||
|
||||
sb.WriteString("</")
|
||||
sb.WriteString(elem.Tag)
|
||||
sb.WriteString(">")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// applyXMLChanges generates ReplaceCommands from detected XML changes
|
||||
func applyXMLChanges(changes []XMLChange) []utils.ReplaceCommand {
|
||||
var commands []utils.ReplaceCommand
|
||||
|
||||
for _, change := range changes {
|
||||
switch change.Type {
|
||||
case "text":
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.EndPos),
|
||||
With: change.NewValue,
|
||||
})
|
||||
|
||||
case "attribute":
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.EndPos),
|
||||
With: change.NewValue,
|
||||
})
|
||||
|
||||
case "add_attribute":
|
||||
// Insert after tag name, before > or />
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.StartPos),
|
||||
With: change.InsertText,
|
||||
})
|
||||
|
||||
case "remove_attribute":
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.EndPos),
|
||||
With: "",
|
||||
})
|
||||
|
||||
case "add_element":
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.StartPos),
|
||||
With: "\n" + change.InsertText,
|
||||
})
|
||||
|
||||
case "remove_element":
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.EndPos),
|
||||
With: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
// deepCopyXMLElement creates a deep copy of an XMLElement
|
||||
func deepCopyXMLElement(elem *XMLElement) *XMLElement {
|
||||
if elem == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := &XMLElement{
|
||||
Tag: elem.Tag,
|
||||
Text: elem.Text,
|
||||
StartPos: elem.StartPos,
|
||||
EndPos: elem.EndPos,
|
||||
TextStart: elem.TextStart,
|
||||
TextEnd: elem.TextEnd,
|
||||
Attributes: make(map[string]XMLAttribute),
|
||||
Children: make([]*XMLElement, len(elem.Children)),
|
||||
}
|
||||
|
||||
for k, v := range elem.Attributes {
|
||||
copied.Attributes[k] = v
|
||||
}
|
||||
|
||||
for i, child := range elem.Children {
|
||||
copied.Children[i] = deepCopyXMLElement(child)
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
|
||||
// Helper function to parse numeric values
|
||||
func parseNumeric(s string) (float64, bool) {
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Helper function to format numeric values
|
||||
func formatNumeric(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// ProcessXML applies Lua processing to XML content with surgical editing
|
||||
func ProcessXML(content string, command utils.ModifyCommand, filename string) ([]utils.ReplaceCommand, error) {
|
||||
processXMLLogger := xmlLogger.WithPrefix("ProcessXML").WithField("commandName", command.Name).WithField("file", filename)
|
||||
processXMLLogger.Debug("Starting XML processing for file")
|
||||
|
||||
// Parse XML with position tracking
|
||||
originalElem, err := parseXMLWithPositions(content)
|
||||
if err != nil {
|
||||
processXMLLogger.Error("Failed to parse XML: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse XML: %v", err)
|
||||
}
|
||||
processXMLLogger.Debug("Successfully parsed XML content")
|
||||
|
||||
// Create Lua state
|
||||
L, err := NewLuaState()
|
||||
if err != nil {
|
||||
processXMLLogger.Error("Error creating Lua state: %v", err)
|
||||
return nil, fmt.Errorf("error creating Lua state: %v", err)
|
||||
}
|
||||
defer L.Close()
|
||||
|
||||
// Set filename global
|
||||
L.SetGlobal("file", lua.LString(filename))
|
||||
|
||||
// Create modifiable copy
|
||||
modifiedElem := deepCopyXMLElement(originalElem)
|
||||
|
||||
// Convert to Lua table and set as global
|
||||
luaTable := xmlElementToLuaTable(L, modifiedElem)
|
||||
L.SetGlobal("root", luaTable)
|
||||
processXMLLogger.Debug("Set XML data as Lua global 'root'")
|
||||
|
||||
// Build and execute Lua script
|
||||
luaExpr := BuildJSONLuaScript(command.Lua, command.SourceDir) // Reuse JSON script builder
|
||||
processXMLLogger.Debug("Built Lua script from expression: %q", command.Lua)
|
||||
|
||||
if err := L.DoString(luaExpr); err != nil {
|
||||
processXMLLogger.Error("Lua script execution failed: %v\nScript: %s", err, luaExpr)
|
||||
return nil, fmt.Errorf("lua script execution failed: %v", err)
|
||||
}
|
||||
processXMLLogger.Debug("Lua script executed successfully")
|
||||
|
||||
// Check if modification flag is set
|
||||
modifiedVal := L.GetGlobal("modified")
|
||||
if modifiedVal.Type() != lua.LTBool || !lua.LVAsBool(modifiedVal) {
|
||||
processXMLLogger.Debug("Skipping - no modifications indicated by Lua script")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get the modified data back from Lua
|
||||
modifiedTable := L.GetGlobal("root")
|
||||
if modifiedTable.Type() != lua.LTTable {
|
||||
processXMLLogger.Error("Expected 'root' to be a table after Lua processing")
|
||||
return nil, fmt.Errorf("expected 'root' to be a table after Lua processing")
|
||||
}
|
||||
|
||||
// Apply Lua modifications back to XMLElement
|
||||
luaTableToXMLElement(L, modifiedTable.(*lua.LTable), modifiedElem)
|
||||
|
||||
// Find changes between original and modified
|
||||
changes := findXMLChanges(originalElem, modifiedElem, "")
|
||||
processXMLLogger.Debug("Found %d changes", len(changes))
|
||||
|
||||
if len(changes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Generate surgical replace commands
|
||||
commands := applyXMLChanges(changes)
|
||||
processXMLLogger.Debug("Generated %d replace commands", len(commands))
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// xmlElementToLuaTable converts an XMLElement to a Lua table
|
||||
func xmlElementToLuaTable(L *lua.LState, elem *XMLElement) *lua.LTable {
|
||||
table := L.CreateTable(0, 4)
|
||||
table.RawSetString("_tag", lua.LString(elem.Tag))
|
||||
|
||||
if len(elem.Attributes) > 0 {
|
||||
attrs := L.CreateTable(0, len(elem.Attributes))
|
||||
for name, attr := range elem.Attributes {
|
||||
attrs.RawSetString(name, lua.LString(attr.Value))
|
||||
}
|
||||
table.RawSetString("_attr", attrs)
|
||||
}
|
||||
|
||||
if len(elem.Children) > 0 {
|
||||
children := L.CreateTable(len(elem.Children), 0)
|
||||
for i, child := range elem.Children {
|
||||
children.RawSetInt(i+1, xmlElementToLuaTable(L, child))
|
||||
}
|
||||
table.RawSetString("_children", children)
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
// luaTableToXMLElement applies Lua table modifications back to XMLElement
|
||||
func luaTableToXMLElement(L *lua.LState, table *lua.LTable, elem *XMLElement) {
|
||||
// Update attributes
|
||||
if attrVal := table.RawGetString("_attr"); attrVal.Type() == lua.LTTable {
|
||||
attrTable := attrVal.(*lua.LTable)
|
||||
// Clear and rebuild attributes
|
||||
elem.Attributes = make(map[string]XMLAttribute)
|
||||
attrTable.ForEach(func(key lua.LValue, value lua.LValue) {
|
||||
if key.Type() == lua.LTString && value.Type() == lua.LTString {
|
||||
attrName := string(key.(lua.LString))
|
||||
attrValue := string(value.(lua.LString))
|
||||
elem.Attributes[attrName] = XMLAttribute{Value: attrValue}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update children
|
||||
if childrenVal := table.RawGetString("_children"); childrenVal.Type() == lua.LTTable {
|
||||
childrenTable := childrenVal.(*lua.LTable)
|
||||
newChildren := []*XMLElement{}
|
||||
|
||||
// Iterate over array indices
|
||||
for i := 1; ; i++ {
|
||||
childVal := childrenTable.RawGetInt(i)
|
||||
if childVal.Type() == lua.LTNil {
|
||||
break
|
||||
}
|
||||
if childVal.Type() == lua.LTTable {
|
||||
if i-1 < len(elem.Children) {
|
||||
// Update existing child
|
||||
luaTableToXMLElement(L, childVal.(*lua.LTable), elem.Children[i-1])
|
||||
newChildren = append(newChildren, elem.Children[i-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
elem.Children = newChildren
|
||||
}
|
||||
}
|
||||
346
processor/xml_integration_test.go
Normal file
346
processor/xml_integration_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cook/utils"
|
||||
)
|
||||
|
||||
// TestRealWorldGameXML tests with game-like XML structure
|
||||
func TestRealWorldGameXML(t *testing.T) {
|
||||
original := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<Items>
|
||||
<Item name="Fiber" identifier="Item_Fiber" category="Resource">
|
||||
<Icon texture="Items/Fiber.png" />
|
||||
<Weight value="0.01" />
|
||||
<MaxStack value="1000" />
|
||||
<Description text="Soft plant fibers useful for crafting." />
|
||||
</Item>
|
||||
<Item name="Wood" identifier="Item_Wood" category="Resource">
|
||||
<Icon texture="Items/Wood.png" />
|
||||
<Weight value="0.05" />
|
||||
<MaxStack value="500" />
|
||||
<Description text="Basic building material." />
|
||||
</Item>
|
||||
</Items>`
|
||||
|
||||
// Parse
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Modify: Double all MaxStack values and change Wood weight
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
|
||||
// Fiber MaxStack: 1000 → 2000
|
||||
fiberItem := modElem.Children[0]
|
||||
fiberMaxStack := fiberItem.Children[2]
|
||||
valueAttr := fiberMaxStack.Attributes["value"]
|
||||
valueAttr.Value = "2000"
|
||||
fiberMaxStack.Attributes["value"] = valueAttr
|
||||
|
||||
// Wood MaxStack: 500 → 1000
|
||||
woodItem := modElem.Children[1]
|
||||
woodMaxStack := woodItem.Children[2]
|
||||
valueAttr2 := woodMaxStack.Attributes["value"]
|
||||
valueAttr2.Value = "1000"
|
||||
woodMaxStack.Attributes["value"] = valueAttr2
|
||||
|
||||
// Wood Weight: 0.05 → 0.10
|
||||
woodWeight := woodItem.Children[1]
|
||||
weightAttr := woodWeight.Attributes["value"]
|
||||
weightAttr.Value = "0.10"
|
||||
woodWeight.Attributes["value"] = weightAttr
|
||||
|
||||
// Generate changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("Expected 3 changes, got %d", len(changes))
|
||||
}
|
||||
|
||||
// Apply
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify changes
|
||||
if !strings.Contains(result, `<MaxStack value="2000"`) {
|
||||
t.Errorf("Failed to update Fiber MaxStack")
|
||||
}
|
||||
if !strings.Contains(result, `<MaxStack value="1000"`) {
|
||||
t.Errorf("Failed to update Wood MaxStack")
|
||||
}
|
||||
if !strings.Contains(result, `<Weight value="0.10"`) {
|
||||
t.Errorf("Failed to update Wood Weight")
|
||||
}
|
||||
|
||||
// Verify formatting preserved (check XML declaration and indentation)
|
||||
if !strings.HasPrefix(result, `<?xml version="1.0" encoding="utf-8"?>`) {
|
||||
t.Errorf("XML declaration not preserved")
|
||||
}
|
||||
if !strings.Contains(result, "\n <Item") {
|
||||
t.Errorf("Indentation not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRemoveMultipleChildren tests adding and removing multiple elements
|
||||
func TestAddRemoveMultipleChildren(t *testing.T) {
|
||||
original := `<inventory>
|
||||
<item name="sword" />
|
||||
<item name="shield" />
|
||||
<item name="potion" />
|
||||
<item name="scroll" />
|
||||
</inventory>`
|
||||
|
||||
// Parse
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Remove middle two items, add a new one
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
|
||||
// Remove shield and potion (indices 1 and 2)
|
||||
modElem.Children = []*XMLElement{
|
||||
modElem.Children[0], // sword
|
||||
modElem.Children[3], // scroll
|
||||
}
|
||||
|
||||
// Add a new item
|
||||
newItem := &XMLElement{
|
||||
Tag: "item",
|
||||
Attributes: map[string]XMLAttribute{
|
||||
"name": {Value: "helmet"},
|
||||
},
|
||||
Children: []*XMLElement{},
|
||||
}
|
||||
modElem.Children = append(modElem.Children, newItem)
|
||||
|
||||
// Generate changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
// The algorithm compares by matching indices:
|
||||
// orig[0]=sword vs mod[0]=sword (no change)
|
||||
// orig[1]=shield vs mod[1]=scroll (treated as replace - shows as attribute changes)
|
||||
// orig[2]=potion vs mod[2]=helmet (treated as replace)
|
||||
// orig[3]=scroll (removed)
|
||||
// This is fine - the actual edits will be correct
|
||||
|
||||
if len(changes) == 0 {
|
||||
t.Fatalf("Expected changes, got none")
|
||||
}
|
||||
|
||||
// Apply
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify
|
||||
if strings.Contains(result, `name="shield"`) {
|
||||
t.Errorf("Shield not removed")
|
||||
}
|
||||
if strings.Contains(result, `name="potion"`) {
|
||||
t.Errorf("Potion not removed")
|
||||
}
|
||||
if !strings.Contains(result, `name="sword"`) {
|
||||
t.Errorf("Sword incorrectly removed")
|
||||
}
|
||||
if !strings.Contains(result, `name="scroll"`) {
|
||||
t.Errorf("Scroll incorrectly removed")
|
||||
}
|
||||
if !strings.Contains(result, `name="helmet"`) {
|
||||
t.Errorf("Helmet not added")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModifyAttributesAndText tests changing both attributes and text content
|
||||
func TestModifyAttributesAndText(t *testing.T) {
|
||||
original := `<weapon>
|
||||
<item type="sword" damage="10">Iron Sword</item>
|
||||
<item type="axe" damage="15">Battle Axe</item>
|
||||
</weapon>`
|
||||
|
||||
// Parse
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Modify both items
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
|
||||
// First item: change damage and text
|
||||
item1 := modElem.Children[0]
|
||||
dmgAttr := item1.Attributes["damage"]
|
||||
dmgAttr.Value = "20"
|
||||
item1.Attributes["damage"] = dmgAttr
|
||||
item1.Text = "Steel Sword"
|
||||
|
||||
// Second item: change damage and type
|
||||
item2 := modElem.Children[1]
|
||||
dmgAttr2 := item2.Attributes["damage"]
|
||||
dmgAttr2.Value = "30"
|
||||
item2.Attributes["damage"] = dmgAttr2
|
||||
typeAttr := item2.Attributes["type"]
|
||||
typeAttr.Value = "greataxe"
|
||||
item2.Attributes["type"] = typeAttr
|
||||
|
||||
// Generate and apply changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify
|
||||
if !strings.Contains(result, `damage="20"`) {
|
||||
t.Errorf("First item damage not updated")
|
||||
}
|
||||
if !strings.Contains(result, "Steel Sword") {
|
||||
t.Errorf("First item text not updated")
|
||||
}
|
||||
if !strings.Contains(result, `damage="30"`) {
|
||||
t.Errorf("Second item damage not updated")
|
||||
}
|
||||
if !strings.Contains(result, `type="greataxe"`) {
|
||||
t.Errorf("Second item type not updated")
|
||||
}
|
||||
if strings.Contains(result, "Iron Sword") {
|
||||
t.Errorf("Old text still present")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfClosingTagPreservation tests that self-closing tags work correctly
|
||||
func TestSelfClosingTagPreservation(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="test" />
|
||||
<empty></empty>
|
||||
</root>`
|
||||
|
||||
// Parse
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Modify first item's attribute
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
item := modElem.Children[0]
|
||||
nameAttr := item.Attributes["name"]
|
||||
nameAttr.Value = "modified"
|
||||
item.Attributes["name"] = nameAttr
|
||||
|
||||
// Generate and apply changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify the change was made
|
||||
if !strings.Contains(result, `name="modified"`) {
|
||||
t.Errorf("Attribute not updated: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNumericAttributeModification tests numeric attribute changes
|
||||
func TestNumericAttributeModification(t *testing.T) {
|
||||
original := `<stats health="100" mana="50" stamina="75.5" />`
|
||||
|
||||
// Parse
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Double all numeric values
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
|
||||
// Helper to modify numeric attributes
|
||||
modifyNumericAttr := func(attrName string, multiplier float64) {
|
||||
if attr, exists := modElem.Attributes[attrName]; exists {
|
||||
if val, ok := parseNumeric(attr.Value); ok {
|
||||
attr.Value = formatNumeric(val * multiplier)
|
||||
modElem.Attributes[attrName] = attr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modifyNumericAttr("health", 2.0)
|
||||
modifyNumericAttr("mana", 2.0)
|
||||
modifyNumericAttr("stamina", 2.0)
|
||||
|
||||
// Generate and apply changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("Expected 3 changes, got %d", len(changes))
|
||||
}
|
||||
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify numeric changes
|
||||
if !strings.Contains(result, `health="200"`) {
|
||||
t.Errorf("Health not doubled: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, `mana="100"`) {
|
||||
t.Errorf("Mana not doubled: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, `stamina="151"`) {
|
||||
t.Errorf("Stamina not doubled: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimalGitDiff verifies that only changed parts are modified
|
||||
func TestMinimalGitDiff(t *testing.T) {
|
||||
original := `<config>
|
||||
<setting name="volume" value="50" />
|
||||
<setting name="brightness" value="75" />
|
||||
<setting name="contrast" value="100" />
|
||||
</config>`
|
||||
|
||||
// Parse
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Change only brightness
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
brightnessItem := modElem.Children[1]
|
||||
valueAttr := brightnessItem.Attributes["value"]
|
||||
valueAttr.Value = "90"
|
||||
brightnessItem.Attributes["value"] = valueAttr
|
||||
|
||||
// Generate changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
// Should be exactly 1 change
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected exactly 1 change for minimal diff, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].OldValue != "75" || changes[0].NewValue != "90" {
|
||||
t.Errorf("Wrong change detected: %v", changes[0])
|
||||
}
|
||||
|
||||
// Apply
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Calculate diff size (rough approximation)
|
||||
diffChars := len(changes[0].OldValue) + len(changes[0].NewValue)
|
||||
if diffChars > 10 {
|
||||
t.Errorf("Diff too large: %d characters changed (expected < 10)", diffChars)
|
||||
}
|
||||
|
||||
// Verify only brightness changed
|
||||
if !strings.Contains(result, `value="50"`) {
|
||||
t.Errorf("Volume incorrectly modified")
|
||||
}
|
||||
if !strings.Contains(result, `value="90"`) {
|
||||
t.Errorf("Brightness not modified")
|
||||
}
|
||||
if !strings.Contains(result, `value="100"`) {
|
||||
t.Errorf("Contrast incorrectly modified")
|
||||
}
|
||||
}
|
||||
165
processor/xml_real_test.go
Normal file
165
processor/xml_real_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cook/utils"
|
||||
)
|
||||
|
||||
func TestRealAfflictionsXML(t *testing.T) {
|
||||
// Read the real Afflictions.xml file
|
||||
content, err := os.ReadFile("../testfiles/Afflictions.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Afflictions.xml: %v", err)
|
||||
}
|
||||
|
||||
original := string(content)
|
||||
|
||||
// Test 1: Double all maxstrength values using helper functions
|
||||
command := utils.ModifyCommand{
|
||||
Name: "double_maxstrength",
|
||||
Lua: `
|
||||
-- Double all maxstrength attributes in Affliction elements
|
||||
local afflictions = findElements(root, "Affliction")
|
||||
for _, affliction in ipairs(afflictions) do
|
||||
modifyNumAttr(affliction, "maxstrength", function(val) return val * 2 end)
|
||||
end
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessXML(original, command, "Afflictions.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessXML failed: %v", err)
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("Expected modifications but got none")
|
||||
}
|
||||
|
||||
t.Logf("Generated %d surgical modifications", len(commands))
|
||||
|
||||
// Apply modifications
|
||||
result, count := utils.ExecuteModifications(commands, original)
|
||||
|
||||
t.Logf("Applied %d modifications", count)
|
||||
|
||||
// Verify specific changes
|
||||
if !strings.Contains(result, `maxstrength="20"`) {
|
||||
t.Errorf("Expected to find maxstrength=\"20\" (doubled from 10)")
|
||||
}
|
||||
if !strings.Contains(result, `maxstrength="480"`) {
|
||||
t.Errorf("Expected to find maxstrength=\"480\" (doubled from 240)")
|
||||
}
|
||||
if !strings.Contains(result, `maxstrength="12"`) {
|
||||
t.Errorf("Expected to find maxstrength=\"12\" (doubled from 6)")
|
||||
}
|
||||
|
||||
// Verify formatting preserved (XML declaration should be there)
|
||||
if !strings.Contains(result, `<?xml`) {
|
||||
t.Errorf("XML declaration not preserved")
|
||||
}
|
||||
|
||||
// Count lines to ensure structure preserved
|
||||
origLines := len(strings.Split(original, "\n"))
|
||||
resultLines := len(strings.Split(result, "\n"))
|
||||
if origLines != resultLines {
|
||||
t.Errorf("Line count changed: original %d, result %d", origLines, resultLines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealAfflictionsAttributes(t *testing.T) {
|
||||
// Read the real file
|
||||
content, err := os.ReadFile("../testfiles/Afflictions.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Afflictions.xml: %v", err)
|
||||
}
|
||||
|
||||
original := string(content)
|
||||
|
||||
// Test 2: Modify resistance values using helper functions
|
||||
command := utils.ModifyCommand{
|
||||
Name: "increase_resistance",
|
||||
Lua: `
|
||||
-- Increase all minresistance and maxresistance by 50%
|
||||
local effects = findElements(root, "Effect")
|
||||
for _, effect in ipairs(effects) do
|
||||
modifyNumAttr(effect, "minresistance", function(val) return val * 1.5 end)
|
||||
modifyNumAttr(effect, "maxresistance", function(val) return val * 1.5 end)
|
||||
end
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessXML(original, command, "Afflictions.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessXML failed: %v", err)
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("Expected modifications but got none")
|
||||
}
|
||||
|
||||
t.Logf("Generated %d surgical modifications", len(commands))
|
||||
|
||||
// Apply modifications
|
||||
_, count := utils.ExecuteModifications(commands, original)
|
||||
|
||||
t.Logf("Applied %d modifications", count)
|
||||
|
||||
// Verify we made resistance modifications
|
||||
if count < 10 {
|
||||
t.Errorf("Expected at least 10 resistance modifications, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealAfflictionsNestedModifications(t *testing.T) {
|
||||
// Read the real file
|
||||
content, err := os.ReadFile("../testfiles/Afflictions.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read Afflictions.xml: %v", err)
|
||||
}
|
||||
|
||||
original := string(content)
|
||||
|
||||
// Test 3: Modify nested Effect attributes using helper functions
|
||||
command := utils.ModifyCommand{
|
||||
Name: "modify_effects",
|
||||
Lua: `
|
||||
-- Double all amount values in ReduceAffliction elements
|
||||
local reduces = findElements(root, "ReduceAffliction")
|
||||
for _, reduce in ipairs(reduces) do
|
||||
modifyNumAttr(reduce, "amount", function(val) return val * 2 end)
|
||||
end
|
||||
modified = true
|
||||
`,
|
||||
}
|
||||
|
||||
commands, err := ProcessXML(original, command, "Afflictions.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessXML failed: %v", err)
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
t.Fatal("Expected modifications but got none")
|
||||
}
|
||||
|
||||
t.Logf("Generated %d surgical modifications for nested elements", len(commands))
|
||||
|
||||
// Apply modifications
|
||||
result, count := utils.ExecuteModifications(commands, original)
|
||||
|
||||
t.Logf("Applied %d modifications", count)
|
||||
|
||||
// Verify nested changes (0.001 * 2 = 0.002)
|
||||
if !strings.Contains(result, `amount="0.002"`) {
|
||||
t.Errorf("Expected to find amount=\"0.002\" (0.001 * 2)")
|
||||
}
|
||||
|
||||
// Verify we modified the nested elements
|
||||
if count < 8 {
|
||||
t.Errorf("Expected at least 8 amount modifications, got %d", count)
|
||||
}
|
||||
}
|
||||
621
processor/xml_test.go
Normal file
621
processor/xml_test.go
Normal file
@@ -0,0 +1,621 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cook/utils"
|
||||
)
|
||||
|
||||
func TestParseXMLWithPositions(t *testing.T) {
|
||||
xml := `<root><item name="test">Hello</item></root>`
|
||||
|
||||
elem, err := parseXMLWithPositions(xml)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse XML: %v", err)
|
||||
}
|
||||
|
||||
if elem.Tag != "root" {
|
||||
t.Errorf("Expected root tag 'root', got '%s'", elem.Tag)
|
||||
}
|
||||
|
||||
if len(elem.Children) != 1 {
|
||||
t.Fatalf("Expected 1 child, got %d", len(elem.Children))
|
||||
}
|
||||
|
||||
child := elem.Children[0]
|
||||
if child.Tag != "item" {
|
||||
t.Errorf("Expected child tag 'item', got '%s'", child.Tag)
|
||||
}
|
||||
|
||||
if child.Attributes["name"].Value != "test" {
|
||||
t.Errorf("Expected attribute 'name' to be 'test', got '%s'", child.Attributes["name"].Value)
|
||||
}
|
||||
|
||||
if child.Text != "Hello" {
|
||||
t.Errorf("Expected text 'Hello', got '%s'", child.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalTextChange(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="sword" weight="10">A sword</item>
|
||||
</root>`
|
||||
|
||||
expected := `<root>
|
||||
<item name="sword" weight="10">A modified sword</item>
|
||||
</root>`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
modElem.Children[0].Text = "A modified sword"
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 change, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].Type != "text" {
|
||||
t.Errorf("Expected change type 'text', got '%s'", changes[0].Type)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Text change failed.\nExpected:\n%s\n\nGot:\n%s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalAttributeChange(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="sword" weight="10" />
|
||||
</root>`
|
||||
|
||||
expected := `<root>
|
||||
<item name="sword" weight="20" />
|
||||
</root>`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
attr := modElem.Children[0].Attributes["weight"]
|
||||
attr.Value = "20"
|
||||
modElem.Children[0].Attributes["weight"] = attr
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 change, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].Type != "attribute" {
|
||||
t.Errorf("Expected change type 'attribute', got '%s'", changes[0].Type)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Attribute change failed.\nExpected:\n%s\n\nGot:\n%s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalMultipleAttributeChanges(t *testing.T) {
|
||||
original := `<item name="sword" weight="10" damage="5" />`
|
||||
|
||||
expected := `<item name="greatsword" weight="20" damage="15" />`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
|
||||
nameAttr := modElem.Attributes["name"]
|
||||
nameAttr.Value = "greatsword"
|
||||
modElem.Attributes["name"] = nameAttr
|
||||
|
||||
weightAttr := modElem.Attributes["weight"]
|
||||
weightAttr.Value = "20"
|
||||
modElem.Attributes["weight"] = weightAttr
|
||||
|
||||
damageAttr := modElem.Attributes["damage"]
|
||||
damageAttr.Value = "15"
|
||||
modElem.Attributes["damage"] = damageAttr
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("Expected 3 changes, got %d", len(changes))
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Multiple attribute changes failed.\nExpected:\n%s\n\nGot:\n%s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalAddAttribute(t *testing.T) {
|
||||
original := `<item name="sword" />`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version with new attribute
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
modElem.Attributes["weight"] = XMLAttribute{
|
||||
Value: "10",
|
||||
}
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 change, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].Type != "add_attribute" {
|
||||
t.Errorf("Expected change type 'add_attribute', got '%s'", changes[0].Type)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Should contain the new attribute
|
||||
if !strings.Contains(result, `weight="10"`) {
|
||||
t.Errorf("Add attribute failed. Result doesn't contain weight=\"10\":\n%s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalRemoveAttribute(t *testing.T) {
|
||||
original := `<item name="sword" weight="10" damage="5" />`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version without weight attribute
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
delete(modElem.Attributes, "weight")
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 change, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].Type != "remove_attribute" {
|
||||
t.Errorf("Expected change type 'remove_attribute', got '%s'", changes[0].Type)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Should not contain weight attribute
|
||||
if strings.Contains(result, "weight=") {
|
||||
t.Errorf("Remove attribute failed. Result still contains 'weight=':\n%s", result)
|
||||
}
|
||||
|
||||
// Should still contain other attributes
|
||||
if !strings.Contains(result, `name="sword"`) {
|
||||
t.Errorf("Remove attribute incorrectly removed other attributes:\n%s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalAddElement(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="sword" />
|
||||
</root>`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version with new child
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
newChild := &XMLElement{
|
||||
Tag: "item",
|
||||
Attributes: map[string]XMLAttribute{
|
||||
"name": {Value: "shield"},
|
||||
},
|
||||
Children: []*XMLElement{},
|
||||
}
|
||||
modElem.Children = append(modElem.Children, newChild)
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 change, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].Type != "add_element" {
|
||||
t.Errorf("Expected change type 'add_element', got '%s'", changes[0].Type)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Should contain the new element
|
||||
if !strings.Contains(result, `<item name="shield"`) {
|
||||
t.Errorf("Add element failed. Result doesn't contain new item:\n%s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurgicalRemoveElement(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="sword" />
|
||||
<item name="shield" />
|
||||
</root>`
|
||||
|
||||
expected := `<root>
|
||||
<item name="sword" />
|
||||
|
||||
</root>`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version without second child
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
modElem.Children = modElem.Children[:1]
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 change, got %d", len(changes))
|
||||
}
|
||||
|
||||
if changes[0].Type != "remove_element" {
|
||||
t.Errorf("Expected change type 'remove_element', got '%s'", changes[0].Type)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Should not contain shield
|
||||
if strings.Contains(result, "shield") {
|
||||
t.Errorf("Remove element failed. Result still contains 'shield':\n%s", result)
|
||||
}
|
||||
|
||||
// Should still contain sword
|
||||
if !strings.Contains(result, "sword") {
|
||||
t.Errorf("Remove element incorrectly removed other elements:\n%s", result)
|
||||
}
|
||||
|
||||
// Normalize whitespace for comparison
|
||||
resultNorm := strings.TrimSpace(result)
|
||||
expectedNorm := strings.TrimSpace(expected)
|
||||
|
||||
if resultNorm != expectedNorm {
|
||||
t.Errorf("Remove element result mismatch.\nExpected:\n%s\n\nGot:\n%s", expectedNorm, resultNorm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplexNestedChanges(t *testing.T) {
|
||||
original := `<root>
|
||||
<inventory>
|
||||
<item name="sword" weight="10">
|
||||
<stats damage="5" speed="3" />
|
||||
</item>
|
||||
<item name="shield" weight="8">
|
||||
<stats defense="7" />
|
||||
</item>
|
||||
</inventory>
|
||||
</root>`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Create modified version with multiple changes
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
|
||||
// Change first item's weight
|
||||
inventory := modElem.Children[0]
|
||||
item1 := inventory.Children[0]
|
||||
weightAttr := item1.Attributes["weight"]
|
||||
weightAttr.Value = "20"
|
||||
item1.Attributes["weight"] = weightAttr
|
||||
|
||||
// Change nested stats damage
|
||||
stats := item1.Children[0]
|
||||
damageAttr := stats.Attributes["damage"]
|
||||
damageAttr.Value = "10"
|
||||
stats.Attributes["damage"] = damageAttr
|
||||
|
||||
// Change second item's name
|
||||
item2 := inventory.Children[1]
|
||||
nameAttr := item2.Attributes["name"]
|
||||
nameAttr.Value = "buckler"
|
||||
item2.Attributes["name"] = nameAttr
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
// Should have 3 changes: weight, damage, name
|
||||
if len(changes) != 3 {
|
||||
t.Fatalf("Expected 3 changes, got %d: %+v", len(changes), changes)
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify all changes were applied
|
||||
if !strings.Contains(result, `weight="20"`) {
|
||||
t.Errorf("Failed to update weight to 20:\n%s", result)
|
||||
}
|
||||
if !strings.Contains(result, `damage="10"`) {
|
||||
t.Errorf("Failed to update damage to 10:\n%s", result)
|
||||
}
|
||||
if !strings.Contains(result, `name="buckler"`) {
|
||||
t.Errorf("Failed to update name to buckler:\n%s", result)
|
||||
}
|
||||
|
||||
// Verify unchanged elements remain
|
||||
if !strings.Contains(result, `speed="3"`) {
|
||||
t.Errorf("Incorrectly modified speed attribute:\n%s", result)
|
||||
}
|
||||
if !strings.Contains(result, `defense="7"`) {
|
||||
t.Errorf("Incorrectly modified defense attribute:\n%s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormattingPreservation(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="sword" weight="10">
|
||||
<description>A sharp blade</description>
|
||||
<stats damage="5" speed="3" />
|
||||
</item>
|
||||
</root>`
|
||||
|
||||
expected := `<root>
|
||||
<item name="sword" weight="20">
|
||||
<description>A sharp blade</description>
|
||||
<stats damage="5" speed="3" />
|
||||
</item>
|
||||
</root>`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Modify only weight
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
item := modElem.Children[0]
|
||||
weightAttr := item.Attributes["weight"]
|
||||
weightAttr.Value = "20"
|
||||
item.Attributes["weight"] = weightAttr
|
||||
|
||||
// Find changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
|
||||
// Apply changes
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Formatting preservation failed.\nExpected:\n%s\n\nGot:\n%s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected float64
|
||||
isNum bool
|
||||
}{
|
||||
{"42", 42.0, true},
|
||||
{"3.14", 3.14, true},
|
||||
{"0", 0.0, true},
|
||||
{"-5", -5.0, true},
|
||||
{"abc", 0.0, false},
|
||||
{"", 0.0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
val, ok := parseNumeric(tt.input)
|
||||
if ok != tt.isNum {
|
||||
t.Errorf("parseNumeric(%q) isNum = %v, expected %v", tt.input, ok, tt.isNum)
|
||||
}
|
||||
if ok && val != tt.expected {
|
||||
t.Errorf("parseNumeric(%q) = %v, expected %v", tt.input, val, tt.expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Test formatting
|
||||
formatTests := []struct {
|
||||
input float64
|
||||
expected string
|
||||
}{
|
||||
{42.0, "42"},
|
||||
{3.14, "3.14"},
|
||||
{0.0, "0"},
|
||||
{-5.0, "-5"},
|
||||
{100.5, "100.5"},
|
||||
}
|
||||
|
||||
for _, tt := range formatTests {
|
||||
result := formatNumeric(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatNumeric(%v) = %q, expected %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepCopyXMLElement(t *testing.T) {
|
||||
original := &XMLElement{
|
||||
Tag: "item",
|
||||
Text: "content",
|
||||
Attributes: map[string]XMLAttribute{
|
||||
"name": {Value: "sword"},
|
||||
},
|
||||
Children: []*XMLElement{
|
||||
{Tag: "child", Text: "text"},
|
||||
},
|
||||
}
|
||||
|
||||
copied := deepCopyXMLElement(original)
|
||||
|
||||
// Verify copy is equal
|
||||
if copied.Tag != original.Tag {
|
||||
t.Errorf("Tag not copied correctly")
|
||||
}
|
||||
if copied.Text != original.Text {
|
||||
t.Errorf("Text not copied correctly")
|
||||
}
|
||||
|
||||
// Modify copy
|
||||
copied.Tag = "modified"
|
||||
copied.Attributes["name"] = XMLAttribute{Value: "shield"}
|
||||
copied.Children[0].Text = "modified text"
|
||||
|
||||
// Verify original unchanged
|
||||
if original.Tag != "item" {
|
||||
t.Errorf("Original was modified")
|
||||
}
|
||||
if original.Attributes["name"].Value != "sword" {
|
||||
t.Errorf("Original attributes were modified")
|
||||
}
|
||||
if original.Children[0].Text != "text" {
|
||||
t.Errorf("Original children were modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeXMLElement(t *testing.T) {
|
||||
elem := &XMLElement{
|
||||
Tag: "item",
|
||||
Attributes: map[string]XMLAttribute{
|
||||
"name": {Value: "sword"},
|
||||
"weight": {Value: "10"},
|
||||
},
|
||||
Children: []*XMLElement{
|
||||
{
|
||||
Tag: "stats",
|
||||
Attributes: map[string]XMLAttribute{
|
||||
"damage": {Value: "5"},
|
||||
},
|
||||
Children: []*XMLElement{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := serializeXMLElement(elem, "")
|
||||
|
||||
// Check it contains expected parts
|
||||
if !strings.Contains(result, "<item") {
|
||||
t.Errorf("Missing opening tag")
|
||||
}
|
||||
if !strings.Contains(result, "</item>") {
|
||||
t.Errorf("Missing closing tag")
|
||||
}
|
||||
if !strings.Contains(result, `name="sword"`) {
|
||||
t.Errorf("Missing name attribute")
|
||||
}
|
||||
if !strings.Contains(result, `weight="10"`) {
|
||||
t.Errorf("Missing weight attribute")
|
||||
}
|
||||
if !strings.Contains(result, "<stats") {
|
||||
t.Errorf("Missing child element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyElements(t *testing.T) {
|
||||
original := `<root>
|
||||
<item name="sword" />
|
||||
<item name="shield"></item>
|
||||
</root>`
|
||||
|
||||
// Parse
|
||||
elem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse XML: %v", err)
|
||||
}
|
||||
|
||||
if len(elem.Children) != 2 {
|
||||
t.Errorf("Expected 2 children, got %d", len(elem.Children))
|
||||
}
|
||||
|
||||
// Both should be parsed correctly
|
||||
if elem.Children[0].Tag != "item" {
|
||||
t.Errorf("First child tag incorrect")
|
||||
}
|
||||
if elem.Children[1].Tag != "item" {
|
||||
t.Errorf("Second child tag incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributeOrderPreservation(t *testing.T) {
|
||||
original := `<item name="sword" weight="10" damage="5" speed="3" />`
|
||||
|
||||
// Parse original
|
||||
origElem, err := parseXMLWithPositions(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse original XML: %v", err)
|
||||
}
|
||||
|
||||
// Modify just weight
|
||||
modElem := deepCopyXMLElement(origElem)
|
||||
weightAttr := modElem.Attributes["weight"]
|
||||
weightAttr.Value = "20"
|
||||
modElem.Attributes["weight"] = weightAttr
|
||||
|
||||
// Find and apply changes
|
||||
changes := findXMLChanges(origElem, modElem, "")
|
||||
commands := applyXMLChanges(changes)
|
||||
result, _ := utils.ExecuteModifications(commands, original)
|
||||
|
||||
// Verify attribute order is preserved (weight comes before damage and speed)
|
||||
weightIdx := strings.Index(result, "weight=")
|
||||
damageIdx := strings.Index(result, "damage=")
|
||||
speedIdx := strings.Index(result, "speed=")
|
||||
|
||||
if weightIdx > damageIdx || damageIdx > speedIdx {
|
||||
t.Errorf("Attribute order not preserved:\n%s", result)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package regression
|
||||
|
||||
import (
|
||||
"modify/processor"
|
||||
"modify/utils"
|
||||
"cook/processor"
|
||||
"cook/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -15,7 +15,7 @@ func ApiAdaptor(content string, regex string, lua string) (string, int, int, err
|
||||
LogLevel: "TRACE",
|
||||
}
|
||||
|
||||
commands, err := processor.ProcessRegex(content, command)
|
||||
commands, err := processor.ProcessRegex(content, command, "test")
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
|
||||
11
test_surgical.yml
Normal file
11
test_surgical.yml
Normal 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
|
||||
74
testfiles/Afflictions.xml
Normal file
74
testfiles/Afflictions.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Afflictions>
|
||||
<Affliction name="" identifier="Cozy_Fire" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="10" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="10" strengthchange="-1.0">
|
||||
<ReduceAffliction type="damage" amount="0.001" />
|
||||
<ReduceAffliction type="bleeding" amount="0.001" />
|
||||
<ReduceAffliction type="burn" amount="0.001" />
|
||||
<ReduceAffliction type="bloodloss" amount="0.001" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Cozy_Fire.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="The_Bast_Defense" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="10" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="10" strengthchange="-1.0" resistancefor="damage" minresistance="0.05" maxresistance="0.05"></Effect>
|
||||
<icon texture="%ModDir%/Placable/The_Bast_Defense.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Clairvoyance" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="240" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="240" strengthchange="-1.0" resistancefor="stun" minresistance="0.15" maxresistance="0.15"></Effect>
|
||||
<icon texture="%ModDir%/Placable/Clairvoyance.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Heart_Lamp" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="10" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="10" strengthchange="-1.0">
|
||||
<ReduceAffliction type="damage" amount="0.001" />
|
||||
<ReduceAffliction type="bleeding" amount="0.001" />
|
||||
<ReduceAffliction type="burn" amount="0.001" />
|
||||
<ReduceAffliction type="bloodloss" amount="0.001" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Heart_Lamp.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Star_in_a_Bottle_buff" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="10" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="10" strengthchange="-1.0" resistancefor="stun" minresistance="0.1" maxresistance="0.1"></Effect>
|
||||
<icon texture="%ModDir%/Placable/Star_in_a_Bottle_buff.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="HappyF" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="10" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="10" strengthchange="-1.0" resistancefor="stun" minresistance="0.05" maxresistance="0.05" minspeedmultiplier="1.1" maxspeedmultiplier="1.1"></Effect>
|
||||
<icon texture="%ModDir%/Placable/Happy.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="SharpenedF" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="240" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="240" strengthchange="-1.0">
|
||||
<StatValue stattype="MeleeAttackMultiplier" value="0.25" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Sharpened.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Sugar_RushF" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="240" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="240" strengthchange="-1.0" minspeedmultiplier="1.2" maxspeedmultiplier="1.2">
|
||||
<StatValue stattype="MeleeAttackSpeed" value="0.05" />
|
||||
<StatValue stattype="RangedAttackSpeed" value="0.05" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Sugar_Rush.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Crimson_Effigy_buff" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="240" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="240" strengthchange="-1.0" resistancefor="damage" minresistance="-0.1" maxresistance="-0.1">
|
||||
<StatValue stattype="MeleeAttackSpeed" value="0.15" />
|
||||
<StatValue stattype="RangedAttackSpeed" value="0.15" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Crimson_Effigy_buff.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Corruption_Effigy_buff" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="240" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="240" strengthchange="-1.0" minvitalitydecrease="0.2" multiplybymaxvitality="true" maxvitalitydecrease="0.2" resistancefor="damage" minresistance="0.1" maxresistance="0.1">
|
||||
<StatValue stattype="AttackMultiplier" value="0.2" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Corruption_Effigy_buff.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Effigy_of_Decay_buff" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="240" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="240" strengthchange="-1.0" resistancefor="oxygenlow" minresistance="1" maxresistance="1">
|
||||
<StatusEffect target="Character" SpeedMultiplier="1.1" OxygenAvailable="1000.0" setvalue="true" />
|
||||
<AbilityFlag flagtype="ImmuneToPressure" />
|
||||
</Effect>
|
||||
<icon texture="%ModDir%/Placable/Effigy_of_Decay_buff.png" sourcerect="0,0,64,64" origin="0,0" />
|
||||
</Affliction>
|
||||
<Affliction name="" identifier="Chlorophyte_Extractinator" type="strengthbuff" limbspecific="false" isbuff="true" maxstrength="6" hideiconafterdelay="true">
|
||||
<Effect minstrength="0" maxstrength="6" strengthchange="-1.0"></Effect>
|
||||
<icon texture="%ModDir%/Extractinator/Chlorophyte_Extractinator.png" sourcerect="0,0,144,152" origin="0,0" />
|
||||
</Affliction>
|
||||
</Afflictions>
|
||||
8
testfiles/format_test_cook.yml
Normal file
8
testfiles/format_test_cook.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
- name: "JSONFormattingTest"
|
||||
json: true
|
||||
lua: |
|
||||
data.version = "2.0.0"
|
||||
data.enabled = true
|
||||
data.settings.timeout = 60
|
||||
return true
|
||||
files: ["testfiles/test3.json"]
|
||||
15
testfiles/json_global_test_cook.yml
Normal file
15
testfiles/json_global_test_cook.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Test with global JSON flag (no json: true in commands)
|
||||
- name: "JSONArrayMultiply"
|
||||
lua: |
|
||||
for i, item in ipairs(data.items) do
|
||||
data.items[i].value = item.value * 2
|
||||
end
|
||||
return true
|
||||
files: ["testfiles/test2.json"]
|
||||
|
||||
- name: "JSONObjectUpdate"
|
||||
lua: |
|
||||
data.version = "3.0.0"
|
||||
data.enabled = false
|
||||
return true
|
||||
files: ["testfiles/test2.json"]
|
||||
32
testfiles/json_test_cook.yml
Normal file
32
testfiles/json_test_cook.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Global modifiers
|
||||
- modifiers:
|
||||
multiply: 2.0
|
||||
new_version: "2.0.0"
|
||||
|
||||
# JSON mode examples
|
||||
- name: "JSONArrayMultiply"
|
||||
json: true
|
||||
lua: |
|
||||
for i, item in ipairs(data.items) do
|
||||
data.items[i].value = item.value * $multiply
|
||||
end
|
||||
return true
|
||||
files: ["testfiles/test.json"]
|
||||
|
||||
- name: "JSONObjectUpdate"
|
||||
json: true
|
||||
lua: |
|
||||
data.version = $new_version
|
||||
data.enabled = true
|
||||
return true
|
||||
files: ["testfiles/test.json"]
|
||||
|
||||
- name: "JSONNestedModify"
|
||||
json: true
|
||||
lua: |
|
||||
if data.settings and data.settings.performance then
|
||||
data.settings.performance.multiplier = data.settings.performance.multiplier * 1.5
|
||||
data.settings.performance.enabled = true
|
||||
end
|
||||
return true
|
||||
files: ["testfiles/test.json"]
|
||||
30
testfiles/test.json
Normal file
30
testfiles/test.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "test-config",
|
||||
"version": "1.0.0",
|
||||
"enabled": false,
|
||||
"settings": {
|
||||
"timeout": 30,
|
||||
"retries": 3,
|
||||
"performance": {
|
||||
"multiplier": 1.0,
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "item1",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "item2",
|
||||
"value": 20
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "item3",
|
||||
"value": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
30
testfiles/test2.json
Normal file
30
testfiles/test2.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "item1",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "item2",
|
||||
"value": 160
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "item3",
|
||||
"value": 240
|
||||
}
|
||||
],
|
||||
"name": "test-config",
|
||||
"settings": {
|
||||
"performance": {
|
||||
"enabled": true,
|
||||
"multiplier": 1.5
|
||||
},
|
||||
"retries": 3,
|
||||
"timeout": 30
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
25
testfiles/test3.json
Normal file
25
testfiles/test3.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "item1",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "item2",
|
||||
"value": 20
|
||||
}
|
||||
],
|
||||
"name": "test-config",
|
||||
"settings": {
|
||||
"performance": {
|
||||
"enabled": false,
|
||||
"multiplier": 1
|
||||
},
|
||||
"retries": 3,
|
||||
"timeout": 60
|
||||
},
|
||||
"version": "2.0.0"
|
||||
}
|
||||
25
testfiles/test3_backup.json
Normal file
25
testfiles/test3_backup.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "item1",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "item2",
|
||||
"value": 20
|
||||
}
|
||||
],
|
||||
"name": "test-config",
|
||||
"settings": {
|
||||
"performance": {
|
||||
"enabled": false,
|
||||
"multiplier": 1
|
||||
},
|
||||
"retries": 3,
|
||||
"timeout": 60
|
||||
},
|
||||
"version": "2.0.0"
|
||||
}
|
||||
25
testfiles/test4.json
Normal file
25
testfiles/test4.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "test-config",
|
||||
"version": "1.0.0",
|
||||
"enabled": false,
|
||||
"settings": {
|
||||
"timeout": 30,
|
||||
"retries": 3,
|
||||
"performance": {
|
||||
"multiplier": 1.0,
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "item1",
|
||||
"value": 10
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "item2",
|
||||
"value": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
502
toml_test.go
Normal file
502
toml_test.go
Normal file
@@ -0,0 +1,502 @@
|
||||
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 variables
|
||||
tomlContent := `[variables]
|
||||
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, variables, 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")
|
||||
assert.Len(t, variables, 3, "Should load 3 variables")
|
||||
|
||||
// Verify variables
|
||||
assert.Equal(t, int64(3), variables["multiplier"], "Multiplier should be 3")
|
||||
assert.Equal(t, "TEST_", variables["prefix"], "Prefix should be TEST_")
|
||||
assert.Equal(t, true, variables["enabled"], "Enabled should be true")
|
||||
|
||||
// Verify regular command
|
||||
assert.Equal(t, "UseGlobalModifiers", commands[0].Name, "Regular command name should match")
|
||||
assert.Equal(t, "value = !num", commands[0].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 := `[variables]
|
||||
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, variables, 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")
|
||||
assert.Len(t, variables, 1, "Should load 1 variable")
|
||||
|
||||
// Verify the multiline regex command
|
||||
multilineCmd := commands[0]
|
||||
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 := `[variables]
|
||||
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, variables, err := utils.LoadCommands([]string{"test.toml"})
|
||||
assert.NoError(t, err, "Should load TOML commands without error")
|
||||
assert.Len(t, commands, 2, "Should load 2 commands")
|
||||
assert.Len(t, variables, 2, "Should load 2 variables")
|
||||
|
||||
// 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 returns no commands (not an error)
|
||||
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.NoError(t, err, "Empty TOML should not return error")
|
||||
assert.Empty(t, commands, "Should return empty 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 := `variables:
|
||||
multiplier: 2.5
|
||||
prefix: "CONV_"
|
||||
|
||||
commands:
|
||||
- name: "ConversionTest"
|
||||
regex: "value = !num"
|
||||
lua: "v1 * 3"
|
||||
files: ["test.txt"]
|
||||
loglevel: DEBUG
|
||||
- name: "AnotherTest"
|
||||
regex: "enabled = (true|false)"
|
||||
lua: "= false"
|
||||
files: ["*.conf"]
|
||||
`
|
||||
|
||||
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, `[variables]`, "TOML should contain variables section")
|
||||
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, variables, err := utils.LoadCommandsFromTomlFiles("test.toml")
|
||||
assert.NoError(t, err, "Should load converted TOML without error")
|
||||
assert.Len(t, commands, 2, "Should load 2 commands from converted TOML")
|
||||
assert.Len(t, variables, 2, "Should have 2 variables")
|
||||
|
||||
// Variables are now loaded separately, not as part of commands
|
||||
|
||||
// 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")
|
||||
}
|
||||
162
utils/db.go
Normal file
162
utils/db.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// dbLogger is a scoped logger for the utils/db package.
|
||||
var dbLogger = logger.Default.WithPrefix("utils/db")
|
||||
|
||||
type DB interface {
|
||||
DB() *gorm.DB
|
||||
Raw(sql string, args ...any) *gorm.DB
|
||||
SaveFile(filePath string, fileData []byte) error
|
||||
GetFile(filePath string) ([]byte, error)
|
||||
GetAllFiles() ([]FileSnapshot, error)
|
||||
RemoveAllFiles() error
|
||||
}
|
||||
|
||||
type FileSnapshot struct {
|
||||
Date time.Time `gorm:"primaryKey"`
|
||||
FilePath string `gorm:"primaryKey"`
|
||||
FileData []byte `gorm:"type:blob"`
|
||||
}
|
||||
|
||||
type DBWrapper struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var globalDB *DBWrapper
|
||||
|
||||
func GetDB() (DB, error) {
|
||||
getDBLogger := dbLogger.WithPrefix("GetDB")
|
||||
getDBLogger.Debug("Attempting to get database connection")
|
||||
var err error
|
||||
|
||||
dbFile := filepath.Join("data.sqlite")
|
||||
getDBLogger.Debug("Opening database file: %q", dbFile)
|
||||
getDBLogger.Trace("Database configuration: PrepareStmt=true, GORM logger=Silent")
|
||||
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{
|
||||
// SkipDefaultTransaction: true,
|
||||
PrepareStmt: true,
|
||||
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
getDBLogger.Error("Failed to open database file %q: %v", dbFile, err)
|
||||
return nil, err
|
||||
}
|
||||
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")
|
||||
|
||||
globalDB = &DBWrapper{db: db}
|
||||
getDBLogger.Debug("Database wrapper initialized and cached globally")
|
||||
return globalDB, nil
|
||||
}
|
||||
|
||||
// Just a wrapper
|
||||
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...)
|
||||
}
|
||||
|
||||
func (db *DBWrapper) DB() *gorm.DB {
|
||||
dbLogger.WithPrefix("DB").Debug("Returning GORM DB instance")
|
||||
return db.db
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func (db *DBWrapper) SaveFile(filePath string, fileData []byte) error {
|
||||
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)
|
||||
if err != nil {
|
||||
saveFileLogger.Error("Error checking if file exists: %v", err)
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
saveFileLogger.Debug("File already exists in database, skipping save to avoid overwriting original snapshot")
|
||||
return nil
|
||||
}
|
||||
saveFileLogger.Debug("Creating new file snapshot in database")
|
||||
err = db.db.Create(&FileSnapshot{
|
||||
Date: time.Now(),
|
||||
FilePath: filePath,
|
||||
FileData: fileData,
|
||||
}).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) {
|
||||
getFileLogger := dbLogger.WithPrefix("GetFile").WithField("filePath", filePath)
|
||||
getFileLogger.Debug("Getting file from database")
|
||||
var fileSnapshot FileSnapshot
|
||||
err := db.db.Model(&FileSnapshot{}).Where("file_path = ?", filePath).First(&fileSnapshot).Error
|
||||
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
|
||||
}
|
||||
getFileLogger.Debug("File found in database")
|
||||
getFileLogger.Trace("Retrieved file data length: %d", len(fileSnapshot.FileData))
|
||||
return fileSnapshot.FileData, nil
|
||||
}
|
||||
|
||||
func (db *DBWrapper) GetAllFiles() ([]FileSnapshot, error) {
|
||||
getAllFilesLogger := dbLogger.WithPrefix("GetAllFiles")
|
||||
getAllFilesLogger.Debug("Getting all files from database")
|
||||
var fileSnapshots []FileSnapshot
|
||||
err := db.db.Model(&FileSnapshot{}).Find(&fileSnapshots).Error
|
||||
if err != nil {
|
||||
getAllFilesLogger.Error("Failed to get all files from database: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
getAllFilesLogger.Debug("Found %d files in database", len(fileSnapshots))
|
||||
getAllFilesLogger.Trace("File snapshots retrieved: %v", fileSnapshots)
|
||||
return fileSnapshots, nil
|
||||
}
|
||||
|
||||
func (db *DBWrapper) RemoveAllFiles() error {
|
||||
removeAllFilesLogger := dbLogger.WithPrefix("RemoveAllFiles")
|
||||
removeAllFilesLogger.Debug("Removing all files from database")
|
||||
err := db.db.Exec("DELETE FROM file_snapshots").Error
|
||||
if err != nil {
|
||||
removeAllFilesLogger.Error("Failed to remove all files from database: %v", err)
|
||||
} else {
|
||||
removeAllFilesLogger.Debug("All files removed from database")
|
||||
}
|
||||
return err
|
||||
}
|
||||
106
utils/file.go
Normal file
106
utils/file.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
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")
|
||||
|
||||
// LimitString truncates a string to maxLen and adds "..." if truncated
|
||||
func LimitString(s string, maxLen int) string {
|
||||
limitStringLogger := fileLogger.WithPrefix("LimitString").WithField("originalLength", len(s)).WithField("maxLength", maxLen)
|
||||
limitStringLogger.Debug("Limiting string length")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
if len(s) <= maxLen {
|
||||
limitStringLogger.Trace("String length (%d) is within max length (%d), no truncation", len(s), maxLen)
|
||||
return s
|
||||
}
|
||||
limited := s[:maxLen-3] + "..."
|
||||
limitStringLogger.Trace("String truncated from %d to %d characters: %q", len(s), len(limited), limited)
|
||||
return limited
|
||||
}
|
||||
|
||||
func ResetWhereNecessary(associations map[string]FileCommandAssociation, db DB) error {
|
||||
resetWhereNecessaryLogger := fileLogger.WithPrefix("ResetWhereNecessary")
|
||||
resetWhereNecessaryLogger.Debug("Starting reset where necessary operation")
|
||||
resetWhereNecessaryLogger.Trace("File-command associations input: %v", associations)
|
||||
dirtyFiles := make(map[string]struct{})
|
||||
for _, association := range associations {
|
||||
resetWhereNecessaryLogger.Debug("Processing association for file: %q", association.File)
|
||||
for _, command := range association.Commands {
|
||||
resetWhereNecessaryLogger.Debug("Checking command %q for reset requirement", command.Name)
|
||||
resetWhereNecessaryLogger.Trace("Command details: %v", command)
|
||||
if command.Reset {
|
||||
resetWhereNecessaryLogger.Debug("Command %q requires reset for file %q, marking as dirty", command.Name, association.File)
|
||||
dirtyFiles[association.File] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, command := range association.IsolateCommands {
|
||||
resetWhereNecessaryLogger.Debug("Checking isolate command %q for reset requirement", command.Name)
|
||||
resetWhereNecessaryLogger.Trace("Isolate command details: %v", command)
|
||||
if command.Reset {
|
||||
resetWhereNecessaryLogger.Debug("Isolate command %q requires reset for file %q, marking as dirty", command.Name, association.File)
|
||||
dirtyFiles[association.File] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
resetWhereNecessaryLogger.Debug("Identified %d files that need to be reset", len(dirtyFiles))
|
||||
resetWhereNecessaryLogger.Trace("Dirty files identified: %v", dirtyFiles)
|
||||
|
||||
for file := range dirtyFiles {
|
||||
resetWhereNecessaryLogger.Debug("Resetting file %q", file)
|
||||
fileData, err := db.GetFile(file)
|
||||
if err != nil {
|
||||
resetWhereNecessaryLogger.Warning("Failed to get original content for file %q from database: %v", file, err)
|
||||
// Seed the snapshot from current disk content if missing, then use it as fallback
|
||||
currentData, readErr := os.ReadFile(file)
|
||||
if readErr != nil {
|
||||
resetWhereNecessaryLogger.Warning("Additionally failed to read current file content for %q: %v", file, readErr)
|
||||
continue
|
||||
}
|
||||
// Best-effort attempt to save baseline; ignore errors to avoid blocking reset
|
||||
if saveErr := db.SaveFile(file, currentData); saveErr != nil {
|
||||
resetWhereNecessaryLogger.Warning("Failed to seed baseline snapshot for %q: %v", file, saveErr)
|
||||
}
|
||||
fileData = currentData
|
||||
}
|
||||
resetWhereNecessaryLogger.Trace("Retrieved original file data length for %q: %d", file, len(fileData))
|
||||
resetWhereNecessaryLogger.Debug("Writing original content back to file %q", file)
|
||||
err = os.WriteFile(file, fileData, 0644)
|
||||
if err != nil {
|
||||
resetWhereNecessaryLogger.Warning("Failed to write original content back to file %q: %v", file, err)
|
||||
continue
|
||||
}
|
||||
resetWhereNecessaryLogger.Debug("Successfully reset file %q", file)
|
||||
}
|
||||
resetWhereNecessaryLogger.Debug("Finished reset where necessary operation")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResetAllFiles(db DB) error {
|
||||
resetAllFilesLogger := fileLogger.WithPrefix("ResetAllFiles")
|
||||
resetAllFilesLogger.Debug("Starting reset all files operation")
|
||||
fileSnapshots, err := db.GetAllFiles()
|
||||
if err != nil {
|
||||
resetAllFilesLogger.Error("Failed to get all file snapshots from database: %v", err)
|
||||
return err
|
||||
}
|
||||
resetAllFilesLogger.Debug("Found %d files in database to reset", len(fileSnapshots))
|
||||
resetAllFilesLogger.Trace("File snapshots retrieved: %v", fileSnapshots)
|
||||
|
||||
for _, fileSnapshot := range fileSnapshots {
|
||||
resetAllFilesLogger.Debug("Resetting file %q", fileSnapshot.FilePath)
|
||||
err = os.WriteFile(fileSnapshot.FilePath, fileSnapshot.FileData, 0644)
|
||||
if err != nil {
|
||||
resetAllFilesLogger.Warning("Failed to write file %q to disk: %v", fileSnapshot.FilePath, err)
|
||||
continue
|
||||
}
|
||||
resetAllFilesLogger.Debug("File %q written to disk successfully", fileSnapshot.FilePath)
|
||||
}
|
||||
resetAllFilesLogger.Debug("Finished reset all files operation")
|
||||
return nil
|
||||
}
|
||||
209
utils/file_test.go
Normal file
209
utils/file_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLimitString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Short string",
|
||||
input: "hello",
|
||||
maxLen: 10,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "Exact length",
|
||||
input: "hello",
|
||||
maxLen: 5,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "Too long",
|
||||
input: "hello world",
|
||||
maxLen: 8,
|
||||
expected: "hello...",
|
||||
},
|
||||
{
|
||||
name: "With newlines",
|
||||
input: "hello\nworld",
|
||||
maxLen: 20,
|
||||
expected: "hello\\nworld",
|
||||
},
|
||||
{
|
||||
name: "With newlines truncated",
|
||||
input: "hello\nworld\nfoo\nbar",
|
||||
maxLen: 15,
|
||||
expected: "hello\\nworld...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := LimitString(tt.input, tt.maxLen)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetWhereNecessary(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "reset-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test files
|
||||
file1 := filepath.Join(tmpDir, "file1.txt")
|
||||
file2 := filepath.Join(tmpDir, "file2.txt")
|
||||
file3 := filepath.Join(tmpDir, "file3.txt")
|
||||
|
||||
err = os.WriteFile(file1, []byte("original1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file2, []byte("original2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file3, []byte("original3"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Modify files
|
||||
err = os.WriteFile(file1, []byte("modified1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file2, []byte("modified2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create mock DB
|
||||
db, err := GetDB()
|
||||
assert.NoError(t, err)
|
||||
err = db.SaveFile(file1, []byte("original1"))
|
||||
assert.NoError(t, err)
|
||||
err = db.SaveFile(file2, []byte("original2"))
|
||||
assert.NoError(t, err)
|
||||
// file3 not in DB
|
||||
|
||||
// Create associations with reset commands
|
||||
associations := map[string]FileCommandAssociation{
|
||||
file1: {
|
||||
File: file1,
|
||||
Commands: []ModifyCommand{
|
||||
{Name: "cmd1", Reset: true},
|
||||
},
|
||||
},
|
||||
file2: {
|
||||
File: file2,
|
||||
IsolateCommands: []ModifyCommand{
|
||||
{Name: "cmd2", Reset: true},
|
||||
},
|
||||
},
|
||||
file3: {
|
||||
File: file3,
|
||||
Commands: []ModifyCommand{
|
||||
{Name: "cmd3", Reset: false}, // No reset
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Run reset
|
||||
err = ResetWhereNecessary(associations, db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify file1 was reset
|
||||
data, _ := os.ReadFile(file1)
|
||||
assert.Equal(t, "original1", string(data))
|
||||
|
||||
// Verify file2 was reset
|
||||
data, _ = os.ReadFile(file2)
|
||||
assert.Equal(t, "original2", string(data))
|
||||
|
||||
// Verify file3 was NOT reset
|
||||
data, _ = os.ReadFile(file3)
|
||||
assert.Equal(t, "original3", string(data))
|
||||
}
|
||||
|
||||
func TestResetWhereNecessaryMissingFromDB(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "reset-missing-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test file that's been modified
|
||||
file1 := filepath.Join(tmpDir, "file1.txt")
|
||||
err = os.WriteFile(file1, []byte("modified_content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create DB but DON'T save file to it
|
||||
db, err := GetDB()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create associations with reset command
|
||||
associations := map[string]FileCommandAssociation{
|
||||
file1: {
|
||||
File: file1,
|
||||
Commands: []ModifyCommand{
|
||||
{Name: "cmd1", Reset: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Run reset - should use current disk content as fallback
|
||||
err = ResetWhereNecessary(associations, db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify file was "reset" to current content (saved to DB for next time)
|
||||
data, _ := os.ReadFile(file1)
|
||||
assert.Equal(t, "modified_content", string(data))
|
||||
|
||||
// Verify it was saved to DB
|
||||
savedData, err := db.GetFile(file1)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "modified_content", string(savedData))
|
||||
}
|
||||
|
||||
func TestResetAllFiles(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "reset-all-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test files
|
||||
file1 := filepath.Join(tmpDir, "file1.txt")
|
||||
file2 := filepath.Join(tmpDir, "file2.txt")
|
||||
|
||||
err = os.WriteFile(file1, []byte("original1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file2, []byte("original2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create mock DB and save originals
|
||||
db, err := GetDB()
|
||||
assert.NoError(t, err)
|
||||
err = db.SaveFile(file1, []byte("original1"))
|
||||
assert.NoError(t, err)
|
||||
err = db.SaveFile(file2, []byte("original2"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Modify files
|
||||
err = os.WriteFile(file1, []byte("modified1"), 0644)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(file2, []byte("modified2"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify they're modified
|
||||
data, _ := os.ReadFile(file1)
|
||||
assert.Equal(t, "modified1", string(data))
|
||||
|
||||
// Reset all
|
||||
err = ResetAllFiles(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify both were reset
|
||||
data, _ = os.ReadFile(file1)
|
||||
assert.Equal(t, "original1", string(data))
|
||||
|
||||
data, _ = os.ReadFile(file2)
|
||||
assert.Equal(t, "original2", string(data))
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"flag"
|
||||
)
|
||||
|
||||
var (
|
||||
// Deprecated
|
||||
GitFlag = flag.Bool("git", false, "Use git to manage files")
|
||||
// Deprecated
|
||||
ResetFlag = flag.Bool("reset", false, "Reset files to their original state")
|
||||
LogLevel = flag.String("loglevel", "INFO", "Set log level: ERROR, WARNING, INFO, DEBUG, TRACE")
|
||||
Cookfile = flag.String("cook", "**/cook.yml", "Path to cook config files, can be globbed")
|
||||
ParallelFiles = flag.Int("P", 100, "Number of files to process in parallel")
|
||||
)
|
||||
97
utils/git.go
97
utils/git.go
@@ -1,97 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"modify/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
Repo *git.Repository
|
||||
Worktree *git.Worktree
|
||||
)
|
||||
|
||||
func SetupGit() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
logger.Debug("Current working directory obtained: %s", cwd)
|
||||
|
||||
logger.Debug("Attempting to open git repository at %s", cwd)
|
||||
Repo, err = git.PlainOpen(cwd)
|
||||
if err != nil {
|
||||
logger.Debug("No existing git repository found at %s, attempting to initialize a new git repository.", cwd)
|
||||
Repo, err = git.PlainInit(cwd, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize a new git repository at %s: %w", cwd, err)
|
||||
}
|
||||
logger.Info("Successfully initialized a new git repository at %s", cwd)
|
||||
} else {
|
||||
logger.Info("Successfully opened existing git repository at %s", cwd)
|
||||
}
|
||||
|
||||
logger.Debug("Attempting to obtain worktree for repository at %s", cwd)
|
||||
Worktree, err = Repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to obtain worktree for repository at %s: %w", cwd, err)
|
||||
}
|
||||
logger.Debug("Successfully obtained worktree for repository at %s", cwd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CleanupGitFiles(files []string) error {
|
||||
for _, file := range files {
|
||||
logger.Debug("Checking git status for file: %s", file)
|
||||
status, err := Worktree.Status()
|
||||
if err != nil {
|
||||
logger.Error("Error getting worktree status: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "Error getting worktree status: %v\n", err)
|
||||
return fmt.Errorf("error getting worktree status: %w", err)
|
||||
}
|
||||
if status.IsUntracked(file) {
|
||||
logger.Info("Detected untracked file: %s. Adding to git index.", file)
|
||||
_, err = Worktree.Add(file)
|
||||
if err != nil {
|
||||
logger.Error("Error adding file to git: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "Error adding file to git: %v\n", err)
|
||||
return fmt.Errorf("error adding file to git: %w", err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(file)
|
||||
logger.Info("File %s added successfully. Committing with message: 'Track %s'", filename, filename)
|
||||
_, err = Worktree.Commit("Track "+filename, &git.CommitOptions{
|
||||
Author: &object.Signature{
|
||||
Name: "Big Chef",
|
||||
Email: "bigchef@bigchef.com",
|
||||
When: time.Now(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Error committing file: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "Error committing file: %v\n", err)
|
||||
return fmt.Errorf("error committing file: %w", err)
|
||||
}
|
||||
logger.Info("Successfully committed file: %s", filename)
|
||||
} else {
|
||||
logger.Info("File %s is already tracked. Restoring it to the working tree.", file)
|
||||
err := Worktree.Restore(&git.RestoreOptions{
|
||||
Files: []string{file},
|
||||
Staged: true,
|
||||
Worktree: true,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Error restoring file: %v", err)
|
||||
fmt.Fprintf(os.Stderr, "Error restoring file: %v\n", err)
|
||||
return fmt.Errorf("error restoring file: %w", err)
|
||||
}
|
||||
logger.Info("File %s restored successfully", file)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,207 +2,590 @@ package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"modify/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// modifyCommandLogger is a scoped logger for the utils/modifycommand package.
|
||||
var modifyCommandLogger = logger.Default.WithPrefix("utils/modifycommand")
|
||||
|
||||
type ModifyCommand struct {
|
||||
Name string `yaml:"name"`
|
||||
Regex string `yaml:"regex"`
|
||||
Lua string `yaml:"lua"`
|
||||
Files []string `yaml:"files"`
|
||||
Git bool `yaml:"git"`
|
||||
Reset bool `yaml:"reset"`
|
||||
LogLevel string `yaml:"loglevel"`
|
||||
Name string `yaml:"name,omitempty" toml:"name,omitempty"`
|
||||
Regex string `yaml:"regex,omitempty" toml:"regex,omitempty"`
|
||||
Regexes []string `yaml:"regexes,omitempty" toml:"regexes,omitempty"`
|
||||
Lua string `yaml:"lua,omitempty" toml:"lua,omitempty"`
|
||||
Files []string `yaml:"files,omitempty" toml:"files,omitempty"`
|
||||
Reset bool `yaml:"reset,omitempty" toml:"reset,omitempty"`
|
||||
LogLevel string `yaml:"loglevel,omitempty" toml:"loglevel,omitempty"`
|
||||
Isolate bool `yaml:"isolate,omitempty" toml:"isolate,omitempty"`
|
||||
NoDedup bool `yaml:"nodedup,omitempty" toml:"nodedup,omitempty"`
|
||||
Disabled bool `yaml:"disable,omitempty" toml:"disable,omitempty"`
|
||||
JSON bool `yaml:"json,omitempty" toml:"json,omitempty"`
|
||||
SourceDir string `yaml:"-" toml:"-"` // Directory of the config file that loaded this command
|
||||
}
|
||||
|
||||
type CookFile []ModifyCommand
|
||||
|
||||
func (c *ModifyCommand) Validate() error {
|
||||
if c.Regex == "" {
|
||||
return fmt.Errorf("pattern is required")
|
||||
validateLogger := modifyCommandLogger.WithPrefix("Validate").WithField("commandName", c.Name)
|
||||
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 == "" {
|
||||
validateLogger.Error("Validation failed: Lua expression is required")
|
||||
return fmt.Errorf("lua expression is required")
|
||||
}
|
||||
if len(c.Files) == 0 {
|
||||
validateLogger.Error("Validation failed: At least one file is required")
|
||||
return fmt.Errorf("at least one file is required")
|
||||
}
|
||||
if c.LogLevel == "" {
|
||||
validateLogger.Debug("LogLevel not specified, defaulting to INFO")
|
||||
c.LogLevel = "INFO"
|
||||
}
|
||||
validateLogger.Debug("Command validated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func AssociateFilesWithCommands(files []string, commands []ModifyCommand) (map[string][]ModifyCommand, error) {
|
||||
// Ehh.. Not much better... Guess this wasn't the big deal
|
||||
var matchesMemoTable map[string]bool = make(map[string]bool)
|
||||
var globMemoTable map[string][]string = make(map[string][]string)
|
||||
|
||||
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)
|
||||
if matches, ok := matchesMemoTable[key]; ok {
|
||||
matchesLogger.Debug("Found match in memo table: %t", matches)
|
||||
return matches, nil
|
||||
}
|
||||
matches, err := doublestar.Match(glob, path)
|
||||
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)
|
||||
}
|
||||
matchesMemoTable[key] = matches
|
||||
matchesLogger.Debug("Match result: %t, storing in memo table", matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func SplitPattern(pattern string) (string, string) {
|
||||
splitPatternLogger := modifyCommandLogger.WithPrefix("SplitPattern").WithField("pattern", pattern)
|
||||
splitPatternLogger.Debug("Splitting pattern")
|
||||
splitPatternLogger.Trace("Original pattern: %q", pattern)
|
||||
|
||||
// Split the pattern first to separate static and wildcard parts
|
||||
static, remainingPattern := doublestar.SplitPattern(pattern)
|
||||
splitPatternLogger.Trace("After split: static=%q, pattern=%q", static, remainingPattern)
|
||||
|
||||
// Resolve the static part to handle ~ expansion and make it absolute
|
||||
// ResolvePath already normalizes to forward slashes
|
||||
static = ResolvePath(static)
|
||||
splitPatternLogger.Trace("Resolved static part: %q", static)
|
||||
|
||||
splitPatternLogger.Trace("Final static path: %q, Remaining pattern: %q", static, remainingPattern)
|
||||
return static, remainingPattern
|
||||
}
|
||||
|
||||
type FileCommandAssociation struct {
|
||||
File string
|
||||
IsolateCommands []ModifyCommand
|
||||
Commands []ModifyCommand
|
||||
}
|
||||
|
||||
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
|
||||
fileCommands := make(map[string][]ModifyCommand)
|
||||
fileCommands := make(map[string]FileCommandAssociation)
|
||||
|
||||
for _, file := range files {
|
||||
// Use centralized path resolution internally but keep original file as key
|
||||
resolvedFile := ResolvePath(file)
|
||||
associateFilesLogger.Debug("Processing file: %q (resolved: %q)", file, resolvedFile)
|
||||
fileCommands[file] = FileCommandAssociation{
|
||||
File: resolvedFile,
|
||||
IsolateCommands: []ModifyCommand{},
|
||||
Commands: []ModifyCommand{},
|
||||
}
|
||||
for _, command := range commands {
|
||||
associateFilesLogger.Debug("Checking command %q for file %q", command.Name, file)
|
||||
for _, glob := range command.Files {
|
||||
// TODO: Maybe memoize this function call
|
||||
matches, err := doublestar.Match(glob, file)
|
||||
// SplitPattern now handles tilde expansion and path resolution
|
||||
static, pattern := SplitPattern(glob)
|
||||
associateFilesLogger.Trace("Glob parts for %q → static=%q pattern=%q", glob, static, pattern)
|
||||
|
||||
// Use resolved file for matching (already normalized to forward slashes by ResolvePath)
|
||||
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)
|
||||
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
|
||||
}
|
||||
if matches {
|
||||
logger.Debug("Found match for file %q and command %q", file, command.Regex)
|
||||
fileCommands[file] = append(fileCommands[file], command)
|
||||
associateFilesLogger.Debug("File %q matches glob %q. Associating with command %q", file, glob, command.Name)
|
||||
association := fileCommands[file]
|
||||
|
||||
if command.Isolate {
|
||||
associateFilesLogger.Debug("Command %q is an isolate command, adding to isolate list", command.Name)
|
||||
association.IsolateCommands = append(association.IsolateCommands, command)
|
||||
} else {
|
||||
associateFilesLogger.Debug("Command %q is a regular command, adding to regular list", command.Name)
|
||||
association.Commands = append(association.Commands, command)
|
||||
}
|
||||
fileCommands[file] = association
|
||||
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]), file)
|
||||
if len(fileCommands[file]) == 0 {
|
||||
logger.Info("No commands found for file %q", file)
|
||||
}
|
||||
currentFileCommands := fileCommands[file]
|
||||
associateFilesLogger.Debug("Finished processing file %q. Found %d regular commands and %d isolate commands", file, len(currentFileCommands.Commands), len(currentFileCommands.IsolateCommands))
|
||||
associateFilesLogger.Trace("Commands for file %q: %v", file, currentFileCommands.Commands)
|
||||
associateFilesLogger.Trace("Isolate commands for file %q: %v", file, currentFileCommands.IsolateCommands)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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{})
|
||||
for _, command := range commands {
|
||||
aggregateGlobsLogger.Debug("Processing command %q for glob patterns", command.Name)
|
||||
for _, glob := range command.Files {
|
||||
globs[glob] = struct{}{}
|
||||
// Split the glob into static and pattern parts, then resolve ONLY the static part
|
||||
static, pattern := SplitPattern(glob)
|
||||
// Reconstruct the glob with resolved static part
|
||||
resolvedGlob := static
|
||||
if pattern != "" {
|
||||
resolvedGlob += "/" + pattern
|
||||
}
|
||||
aggregateGlobsLogger.Trace("Adding glob: %q (resolved to %q) [static=%s, pattern=%s]", glob, resolvedGlob, static, pattern)
|
||||
globs[resolvedGlob] = struct{}{}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
filesMap := make(map[string]bool)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
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)
|
||||
}
|
||||
expandGlobsLogger.Debug("Current working directory: %q", cwd)
|
||||
|
||||
logger.Debug("Expanding patterns from directory: %s", cwd)
|
||||
for pattern := range patterns {
|
||||
logger.Trace("Processing pattern: %s", pattern)
|
||||
matches, _ := doublestar.Glob(os.DirFS(cwd), pattern)
|
||||
logger.Debug("Found %d matches for pattern %s", len(matches), pattern)
|
||||
for _, m := range matches {
|
||||
info, err := os.Stat(m)
|
||||
expandGlobsLogger.Debug("Processing glob pattern: %q", pattern)
|
||||
static, pattern := SplitPattern(pattern)
|
||||
key := static + "|" + pattern
|
||||
matches, ok := globMemoTable[key]
|
||||
if !ok {
|
||||
var err error
|
||||
matches, err = doublestar.Glob(os.DirFS(static), pattern)
|
||||
if err != nil {
|
||||
logger.Warning("Error getting file info for %s: %v", m, err)
|
||||
expandGlobsLogger.Warning("Error expanding glob %q in %q: %v", pattern, static, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() && !filesMap[m] {
|
||||
logger.Trace("Adding file to process list: %s", m)
|
||||
filesMap[m], files = true, append(files, m)
|
||||
globMemoTable[key] = matches
|
||||
}
|
||||
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 {
|
||||
// Resolve the full path
|
||||
fullPath := ResolvePath(filepath.Join(static, m))
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
expandGlobsLogger.Warning("Error getting file info for %q: %v", fullPath, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() && !filesMap[fullPath] {
|
||||
expandGlobsLogger.Trace("Adding unique file to list: %q", fullPath)
|
||||
filesMap[fullPath], files = true, append(files, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func LoadCommands(args []string) ([]ModifyCommand, error) {
|
||||
func LoadCommands(args []string) ([]ModifyCommand, map[string]interface{}, error) {
|
||||
loadCommandsLogger := modifyCommandLogger.WithPrefix("LoadCommands")
|
||||
loadCommandsLogger.Debug("Loading commands from arguments (cook files or direct patterns)")
|
||||
loadCommandsLogger.Trace("Input arguments: %v", args)
|
||||
commands := []ModifyCommand{}
|
||||
variables := make(map[string]interface{})
|
||||
|
||||
logger.Info("Loading commands from cook files: %s", *Cookfile)
|
||||
newcommands, err := LoadCommandsFromCookFiles(*Cookfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load commands from cook files: %w", err)
|
||||
}
|
||||
logger.Info("Successfully loaded %d commands from cook files", len(newcommands))
|
||||
commands = append(commands, newcommands...)
|
||||
logger.Info("Now total commands: %d", len(commands))
|
||||
for _, arg := range args {
|
||||
loadCommandsLogger.Debug("Processing argument for commands: %q", arg)
|
||||
var newCommands []ModifyCommand
|
||||
var newVariables map[string]interface{}
|
||||
var err error
|
||||
|
||||
logger.Info("Loading commands from arguments: %v", args)
|
||||
newcommands, err = LoadCommandFromArgs(args)
|
||||
if err != nil {
|
||||
if len(commands) == 0 {
|
||||
return nil, fmt.Errorf("failed to load commands from args: %w", err)
|
||||
// Check file extension to determine format
|
||||
if strings.HasSuffix(arg, ".toml") {
|
||||
loadCommandsLogger.Debug("Loading TOML commands from %q", arg)
|
||||
newCommands, newVariables, err = LoadCommandsFromTomlFiles(arg)
|
||||
if err != nil {
|
||||
loadCommandsLogger.Error("Failed to load TOML commands from argument %q: %v", arg, err)
|
||||
return nil, 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, newVariables, err = LoadCommandsFromCookFiles(arg)
|
||||
if err != nil {
|
||||
loadCommandsLogger.Error("Failed to load YAML commands from argument %q: %v", arg, err)
|
||||
return nil, nil, fmt.Errorf("failed to load commands from cook files: %w", err)
|
||||
}
|
||||
}
|
||||
for k, v := range newVariables {
|
||||
variables[k] = v
|
||||
}
|
||||
logger.Warning("Failed to load commands from args: %v", err)
|
||||
}
|
||||
logger.Info("Successfully loaded %d commands from args", len(newcommands))
|
||||
commands = append(commands, newcommands...)
|
||||
logger.Info("Now total commands: %d", len(commands))
|
||||
|
||||
return commands, nil
|
||||
loadCommandsLogger.Debug("Successfully loaded %d commands from %q", len(newCommands), arg)
|
||||
for _, cmd := range newCommands {
|
||||
if cmd.Disabled {
|
||||
loadCommandsLogger.Debug("Skipping disabled command: %q", cmd.Name)
|
||||
continue
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
loadCommandsLogger.Trace("Added command %q. Current total commands: %d", cmd.Name, len(commands))
|
||||
}
|
||||
}
|
||||
|
||||
loadCommandsLogger.Info("Finished loading commands. Total %d commands and %d variables loaded", len(commands), len(variables))
|
||||
return commands, variables, nil
|
||||
}
|
||||
|
||||
func LoadCommandFromArgs(args []string) ([]ModifyCommand, error) {
|
||||
// Cannot reset without git, right?
|
||||
if *ResetFlag {
|
||||
*GitFlag = true
|
||||
}
|
||||
|
||||
if len(args) < 3 {
|
||||
return nil, fmt.Errorf("at least %d arguments are required", 3)
|
||||
}
|
||||
|
||||
command := ModifyCommand{
|
||||
Regex: args[0],
|
||||
Lua: args[1],
|
||||
Files: args[2:],
|
||||
Git: *GitFlag,
|
||||
Reset: *ResetFlag,
|
||||
LogLevel: *LogLevel,
|
||||
}
|
||||
|
||||
if err := command.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid command: %w", err)
|
||||
}
|
||||
|
||||
return []ModifyCommand{command}, nil
|
||||
}
|
||||
|
||||
func LoadCommandsFromCookFiles(s string) ([]ModifyCommand, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
func LoadCommandsFromCookFiles(pattern string) ([]ModifyCommand, map[string]interface{}, 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)
|
||||
commands := []ModifyCommand{}
|
||||
cookFiles, err := doublestar.Glob(os.DirFS(cwd), *Cookfile)
|
||||
variables := make(map[string]interface{})
|
||||
cookFiles, err := doublestar.Glob(os.DirFS(static), pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to glob cook files: %w", err)
|
||||
loadCookFilesLogger.Error("Failed to glob cook files for pattern %q: %v", pattern, err)
|
||||
return nil, 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 {
|
||||
// Use centralized path resolution
|
||||
cookFile = ResolvePath(filepath.Join(static, cookFile))
|
||||
loadCookFilesLogger.Debug("Loading commands from individual cook file: %q", cookFile)
|
||||
|
||||
cookFileData, err := os.ReadFile(cookFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read cook file: %w", err)
|
||||
loadCookFilesLogger.Error("Failed to read cook file %q: %v", cookFile, err)
|
||||
return nil, 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, newVariables, err := LoadCommandsFromCookFile(cookFileData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load commands from cook file: %w", err)
|
||||
loadCookFilesLogger.Error("Failed to load commands from cook file data for %q: %v", cookFile, err)
|
||||
return nil, nil, fmt.Errorf("failed to load commands from cook file: %w", err)
|
||||
}
|
||||
commands = append(commands, newcommands...)
|
||||
// Set source directory for each command
|
||||
sourceDir := filepath.Dir(cookFile)
|
||||
for i := range newCommands {
|
||||
newCommands[i].SourceDir = sourceDir
|
||||
}
|
||||
commands = append(commands, newCommands...)
|
||||
for k, v := range newVariables {
|
||||
variables[k] = v
|
||||
}
|
||||
loadCookFilesLogger.Debug("Added %d commands and %d variables from cook file %q. Total commands now: %d", len(newCommands), len(newVariables), cookFile, len(commands))
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
loadCookFilesLogger.Debug("Finished loading commands from cook files. Total %d commands and %d variables", len(commands), len(variables))
|
||||
return commands, variables, nil
|
||||
}
|
||||
|
||||
func LoadCommandsFromCookFile(cookFileData []byte) ([]ModifyCommand, error) {
|
||||
commands := []ModifyCommand{}
|
||||
err := yaml.Unmarshal(cookFileData, &commands)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal cook file: %w", err)
|
||||
func LoadCommandsFromCookFile(cookFileData []byte) ([]ModifyCommand, map[string]interface{}, error) {
|
||||
loadCommandLogger := modifyCommandLogger.WithPrefix("LoadCommandsFromCookFile")
|
||||
loadCommandLogger.Debug("Unmarshaling commands from cook file data")
|
||||
loadCommandLogger.Trace("Cook file data length: %d", len(cookFileData))
|
||||
|
||||
var cookFile struct {
|
||||
Variables map[string]interface{} `yaml:"variables,omitempty"`
|
||||
Commands []ModifyCommand `yaml:"commands"`
|
||||
}
|
||||
return commands, nil
|
||||
|
||||
err := yaml.Unmarshal(cookFileData, &cookFile)
|
||||
if err != nil {
|
||||
loadCommandLogger.Error("Failed to unmarshal cook file data: %v", err)
|
||||
return nil, nil, fmt.Errorf("failed to unmarshal cook file: %w", err)
|
||||
}
|
||||
loadCommandLogger.Debug("Successfully unmarshaled %d commands and %d variables", len(cookFile.Commands), len(cookFile.Variables))
|
||||
loadCommandLogger.Trace("Unmarshaled commands: %v", cookFile.Commands)
|
||||
loadCommandLogger.Trace("Unmarshaled variables: %v", cookFile.Variables)
|
||||
return cookFile.Commands, cookFile.Variables, nil
|
||||
}
|
||||
|
||||
// CountGlobsBeforeDedup counts the total number of glob patterns across all commands before deduplication
|
||||
func CountGlobsBeforeDedup(commands []ModifyCommand) int {
|
||||
countGlobsLogger := modifyCommandLogger.WithPrefix("CountGlobsBeforeDedup")
|
||||
countGlobsLogger.Debug("Counting glob patterns before deduplication")
|
||||
count := 0
|
||||
for _, cmd := range commands {
|
||||
countGlobsLogger.Trace("Processing command %q, adding %d globs", cmd.Name, len(cmd.Files))
|
||||
count += len(cmd.Files)
|
||||
}
|
||||
countGlobsLogger.Debug("Total glob patterns before deduplication: %d", count)
|
||||
return count
|
||||
}
|
||||
|
||||
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{}
|
||||
filters := strings.Split(filter, ",")
|
||||
filterCommandsLogger.Trace("Split filters: %v", filters)
|
||||
for _, cmd := range commands {
|
||||
filterCommandsLogger.Debug("Checking command %q against filters", cmd.Name)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func LoadCommandsFromTomlFiles(pattern string) ([]ModifyCommand, map[string]interface{}, 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{}
|
||||
variables := make(map[string]interface{})
|
||||
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, 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, nil, fmt.Errorf("failed to read TOML file: %w", err)
|
||||
}
|
||||
loadTomlFilesLogger.Trace("Read %d bytes from TOML file %q", len(tomlFileData), tomlFile)
|
||||
newCommands, newVariables, err := LoadCommandsFromTomlFile(tomlFileData)
|
||||
if err != nil {
|
||||
loadTomlFilesLogger.Error("Failed to load commands from TOML file data for %q: %v", tomlFile, err)
|
||||
return nil, nil, fmt.Errorf("failed to load commands from TOML file: %w", err)
|
||||
}
|
||||
// Set source directory for each command
|
||||
sourceDir := filepath.Dir(tomlFile)
|
||||
for i := range newCommands {
|
||||
newCommands[i].SourceDir = sourceDir
|
||||
}
|
||||
commands = append(commands, newCommands...)
|
||||
for k, v := range newVariables {
|
||||
variables[k] = v
|
||||
}
|
||||
loadTomlFilesLogger.Debug("Added %d commands and %d variables from TOML file %q. Total commands now: %d", len(newCommands), len(newVariables), tomlFile, len(commands))
|
||||
}
|
||||
|
||||
loadTomlFilesLogger.Debug("Finished loading commands from TOML files. Total %d commands and %d variables", len(commands), len(variables))
|
||||
return commands, variables, nil
|
||||
}
|
||||
|
||||
func LoadCommandsFromTomlFile(tomlFileData []byte) ([]ModifyCommand, map[string]interface{}, 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 and top-level variables
|
||||
var tomlData struct {
|
||||
Variables map[string]interface{} `toml:"variables,omitempty"`
|
||||
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, nil, fmt.Errorf("failed to unmarshal TOML file: %w", err)
|
||||
}
|
||||
|
||||
var commands []ModifyCommand
|
||||
variables := make(map[string]interface{})
|
||||
|
||||
// Extract top-level variables
|
||||
if len(tomlData.Variables) > 0 {
|
||||
loadTomlCommandLogger.Debug("Found %d top-level variables", len(tomlData.Variables))
|
||||
for k, v := range tomlData.Variables {
|
||||
variables[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Use commands from wrapped structure
|
||||
commands = tomlData.Commands
|
||||
loadTomlCommandLogger.Debug("Successfully unmarshaled %d commands and %d variables", len(commands), len(variables))
|
||||
loadTomlCommandLogger.Trace("Unmarshaled commands: %v", commands)
|
||||
loadTomlCommandLogger.Trace("Unmarshaled variables: %v", variables)
|
||||
return commands, variables, 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")
|
||||
|
||||
// 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))
|
||||
|
||||
if len(yamlFiles) == 0 {
|
||||
convertLogger.Info("No YAML files found for pattern: %s", yamlPattern)
|
||||
return nil
|
||||
}
|
||||
|
||||
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, fileVariables, 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, fileVariables)
|
||||
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, variables map[string]interface{}) ([]byte, error) {
|
||||
convertLogger := modifyCommandLogger.WithPrefix("convertCommandsToTOML")
|
||||
convertLogger.Debug("Converting %d commands to TOML format", len(commands))
|
||||
|
||||
// Create TOML structure
|
||||
tomlData := struct {
|
||||
Variables map[string]interface{} `toml:"variables,omitempty"`
|
||||
Commands []ModifyCommand `toml:"commands"`
|
||||
}{
|
||||
Variables: variables,
|
||||
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 and %d variables to TOML (%d bytes)", len(commands), len(variables), len(tomlBytes))
|
||||
return tomlBytes, nil
|
||||
}
|
||||
|
||||
313
utils/modifycommand_coverage_test.go
Normal file
313
utils/modifycommand_coverage_test.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAggregateGlobsWithDuplicates(t *testing.T) {
|
||||
commands := []ModifyCommand{
|
||||
{Files: []string{"*.txt", "*.md"}},
|
||||
{Files: []string{"*.txt", "*.go"}}, // *.txt is duplicate
|
||||
{Files: []string{"test/**/*.xml"}},
|
||||
}
|
||||
|
||||
globs := AggregateGlobs(commands)
|
||||
|
||||
// Should deduplicate
|
||||
assert.Equal(t, 4, len(globs))
|
||||
// AggregateGlobs resolves paths, which uses forward slashes internally
|
||||
assert.Contains(t, globs, ResolvePath("*.txt"))
|
||||
assert.Contains(t, globs, ResolvePath("*.md"))
|
||||
assert.Contains(t, globs, ResolvePath("*.go"))
|
||||
assert.Contains(t, globs, ResolvePath("test/**/*.xml"))
|
||||
}
|
||||
|
||||
func TestExpandGlobsWithActualFiles(t *testing.T) {
|
||||
// Create temp dir with test files
|
||||
tmpDir, err := os.MkdirTemp("", "glob-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test files
|
||||
testFile1 := filepath.Join(tmpDir, "test1.txt")
|
||||
testFile2 := filepath.Join(tmpDir, "test2.txt")
|
||||
testFile3 := filepath.Join(tmpDir, "test.md")
|
||||
|
||||
os.WriteFile(testFile1, []byte("test"), 0644)
|
||||
os.WriteFile(testFile2, []byte("test"), 0644)
|
||||
os.WriteFile(testFile3, []byte("test"), 0644)
|
||||
|
||||
// Change to temp directory so glob pattern can find files
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// Test expanding globs using ResolvePath to normalize the pattern
|
||||
globs := map[string]struct{}{
|
||||
ResolvePath("*.txt"): {},
|
||||
}
|
||||
|
||||
files, err := ExpandGlobs(globs)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(files))
|
||||
}
|
||||
|
||||
func TestSplitPatternWithTilde(t *testing.T) {
|
||||
pattern := "~/test/*.txt"
|
||||
static, pat := SplitPattern(pattern)
|
||||
|
||||
// Should expand ~
|
||||
assert.NotEqual(t, "~", static)
|
||||
assert.Contains(t, pat, "*.txt")
|
||||
}
|
||||
|
||||
func TestLoadCommandsWithDisabled(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "disabled-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
yamlContent := `
|
||||
variables:
|
||||
test: "value"
|
||||
|
||||
commands:
|
||||
- name: "enabled_cmd"
|
||||
regex: "test"
|
||||
lua: "v1 * 2"
|
||||
files: ["*.txt"]
|
||||
- name: "disabled_cmd"
|
||||
regex: "test2"
|
||||
lua: "v1 * 3"
|
||||
files: ["*.txt"]
|
||||
disable: true
|
||||
`
|
||||
|
||||
yamlFile := filepath.Join(tmpDir, "test.yml")
|
||||
err = os.WriteFile(yamlFile, []byte(yamlContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Change to temp directory so LoadCommands can find the file with a simple pattern
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
commands, variables, err := LoadCommands([]string{"test.yml"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should only load enabled command
|
||||
assert.Equal(t, 1, len(commands))
|
||||
assert.Equal(t, "enabled_cmd", commands[0].Name)
|
||||
|
||||
// Should still load variables
|
||||
assert.Equal(t, 1, len(variables))
|
||||
}
|
||||
|
||||
func TestFilterCommandsByName(t *testing.T) {
|
||||
commands := []ModifyCommand{
|
||||
{Name: "test_multiply"},
|
||||
{Name: "test_divide"},
|
||||
{Name: "other_command"},
|
||||
{Name: "test_add"},
|
||||
}
|
||||
|
||||
// Filter by "test"
|
||||
filtered := FilterCommands(commands, "test")
|
||||
assert.Equal(t, 3, len(filtered))
|
||||
|
||||
// Filter by multiple
|
||||
filtered = FilterCommands(commands, "multiply,divide")
|
||||
assert.Equal(t, 2, len(filtered))
|
||||
}
|
||||
|
||||
func TestCountGlobsBeforeDedup(t *testing.T) {
|
||||
commands := []ModifyCommand{
|
||||
{Files: []string{"*.txt", "*.md", "*.go"}},
|
||||
{Files: []string{"*.xml"}},
|
||||
{Files: []string{"test/**/*.txt", "data/**/*.json"}},
|
||||
}
|
||||
|
||||
count := CountGlobsBeforeDedup(commands)
|
||||
assert.Equal(t, 6, count)
|
||||
}
|
||||
|
||||
func TestMatchesWithMemoization(t *testing.T) {
|
||||
path := "test/file.txt"
|
||||
glob := "**/*.txt"
|
||||
|
||||
// First call
|
||||
matches1, err1 := Matches(path, glob)
|
||||
assert.NoError(t, err1)
|
||||
assert.True(t, matches1)
|
||||
|
||||
// Second call should use memo
|
||||
matches2, err2 := Matches(path, glob)
|
||||
assert.NoError(t, err2)
|
||||
assert.Equal(t, matches1, matches2)
|
||||
}
|
||||
|
||||
func TestValidateCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd ModifyCommand
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid command",
|
||||
cmd: ModifyCommand{
|
||||
Regex: "test",
|
||||
Lua: "v1 * 2",
|
||||
Files: []string{"*.txt"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid JSON mode without regex",
|
||||
cmd: ModifyCommand{
|
||||
JSON: true,
|
||||
Lua: "data.value = data.value * 2; modified = true",
|
||||
Files: []string{"*.json"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Missing regex in non-JSON mode",
|
||||
cmd: ModifyCommand{
|
||||
Lua: "v1 * 2",
|
||||
Files: []string{"*.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Missing Lua",
|
||||
cmd: ModifyCommand{
|
||||
Regex: "test",
|
||||
Files: []string{"*.txt"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Missing files",
|
||||
cmd: ModifyCommand{
|
||||
Regex: "test",
|
||||
Lua: "v1 * 2",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cmd.Validate()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCommandsFromTomlWithVariables(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "toml-vars-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tomlContent := `[variables]
|
||||
multiplier = 3
|
||||
prefix = "PREFIX_"
|
||||
|
||||
[[commands]]
|
||||
name = "test_cmd"
|
||||
regex = "value = !num"
|
||||
lua = "v1 * multiplier"
|
||||
files = ["*.txt"]
|
||||
`
|
||||
|
||||
tomlFile := filepath.Join(tmpDir, "test.toml")
|
||||
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Change to temp directory so glob pattern can find the file
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
commands, variables, err := LoadCommandsFromTomlFiles("test.toml")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(commands))
|
||||
assert.Equal(t, 2, len(variables))
|
||||
assert.Equal(t, int64(3), variables["multiplier"])
|
||||
assert.Equal(t, "PREFIX_", variables["prefix"])
|
||||
}
|
||||
|
||||
func TestConvertYAMLToTOMLSkipExisting(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "convert-skip-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create YAML file
|
||||
yamlContent := `
|
||||
commands:
|
||||
- name: "test"
|
||||
regex: "value"
|
||||
lua: "v1 * 2"
|
||||
files: ["*.txt"]
|
||||
`
|
||||
yamlFile := filepath.Join(tmpDir, "test.yml")
|
||||
err = os.WriteFile(yamlFile, []byte(yamlContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create TOML file (should skip conversion)
|
||||
tomlFile := filepath.Join(tmpDir, "test.toml")
|
||||
err = os.WriteFile(tomlFile, []byte("# existing"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Change to temp dir
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// Should skip existing TOML
|
||||
err = ConvertYAMLToTOML("test.yml")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// TOML content should be unchanged
|
||||
content, _ := os.ReadFile(tomlFile)
|
||||
assert.Equal(t, "# existing", string(content))
|
||||
}
|
||||
|
||||
func TestLoadCommandsWithTomlExtension(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "toml-ext-test-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
tomlContent := `
|
||||
[variables]
|
||||
test_var = "value"
|
||||
|
||||
[[commands]]
|
||||
name = "TestCmd"
|
||||
regex = "test"
|
||||
lua = "return true"
|
||||
files = ["*.txt"]
|
||||
`
|
||||
tomlFile := filepath.Join(tmpDir, "test.toml")
|
||||
err = os.WriteFile(tomlFile, []byte(tomlContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// This should trigger the .toml suffix check in LoadCommands
|
||||
commands, variables, err := LoadCommands([]string{"test.toml"})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commands, 1)
|
||||
assert.Equal(t, "TestCmd", commands[0].Name)
|
||||
assert.Len(t, variables, 1)
|
||||
assert.Equal(t, "value", variables["test_var"])
|
||||
}
|
||||
@@ -158,21 +158,24 @@ func TestAssociateFilesWithCommands(t *testing.T) {
|
||||
|
||||
// The associations expected depends on the implementation
|
||||
// Let's check the actual associations and verify they make sense
|
||||
for file, cmds := range associations {
|
||||
t.Logf("File %s is associated with %d commands", file, len(cmds))
|
||||
for i, cmd := range cmds {
|
||||
for file, assoc := range associations {
|
||||
t.Logf("File %s is associated with %d commands and %d isolate commands", file, len(assoc.Commands), len(assoc.IsolateCommands))
|
||||
for i, cmd := range assoc.Commands {
|
||||
t.Logf(" Command %d: Pattern=%s, Files=%v", i, cmd.Regex, cmd.Files)
|
||||
}
|
||||
for i, cmd := range assoc.IsolateCommands {
|
||||
t.Logf(" Isolate Command %d: Pattern=%s, Files=%v", i, cmd.Regex, cmd.Files)
|
||||
}
|
||||
|
||||
// Specific validation based on our file types
|
||||
switch file {
|
||||
case "file1.xml":
|
||||
if len(cmds) < 1 {
|
||||
t.Errorf("Expected at least 1 command for file1.xml, got %d", len(cmds))
|
||||
if len(assoc.Commands) < 1 {
|
||||
t.Errorf("Expected at least 1 command for file1.xml, got %d", len(assoc.Commands))
|
||||
}
|
||||
// Verify at least one command with *.xml pattern
|
||||
hasXmlGlob := false
|
||||
for _, cmd := range cmds {
|
||||
for _, cmd := range assoc.Commands {
|
||||
for _, glob := range cmd.Files {
|
||||
if glob == "*.xml" {
|
||||
hasXmlGlob = true
|
||||
@@ -187,12 +190,12 @@ func TestAssociateFilesWithCommands(t *testing.T) {
|
||||
t.Errorf("Expected command with *.xml glob for file1.xml")
|
||||
}
|
||||
case "file2.txt":
|
||||
if len(cmds) < 1 {
|
||||
t.Errorf("Expected at least 1 command for file2.txt, got %d", len(cmds))
|
||||
if len(assoc.Commands) < 1 {
|
||||
t.Errorf("Expected at least 1 command for file2.txt, got %d", len(assoc.Commands))
|
||||
}
|
||||
// Verify at least one command with *.txt pattern
|
||||
hasTxtGlob := false
|
||||
for _, cmd := range cmds {
|
||||
for _, cmd := range assoc.Commands {
|
||||
for _, glob := range cmd.Files {
|
||||
if glob == "*.txt" {
|
||||
hasTxtGlob = true
|
||||
@@ -207,12 +210,12 @@ func TestAssociateFilesWithCommands(t *testing.T) {
|
||||
t.Errorf("Expected command with *.txt glob for file2.txt")
|
||||
}
|
||||
case "subdir/file3.xml":
|
||||
if len(cmds) < 1 {
|
||||
t.Errorf("Expected at least 1 command for subdir/file3.xml, got %d", len(cmds))
|
||||
if len(assoc.Commands) < 1 {
|
||||
t.Errorf("Expected at least 1 command for subdir/file3.xml, got %d", len(assoc.Commands))
|
||||
}
|
||||
// Should match both *.xml and subdir/* patterns
|
||||
matches := 0
|
||||
for _, cmd := range cmds {
|
||||
for _, cmd := range assoc.Commands {
|
||||
for _, glob := range cmd.Files {
|
||||
if glob == "*.xml" || glob == "subdir/*" {
|
||||
matches++
|
||||
@@ -248,11 +251,19 @@ func TestAggregateGlobs(t *testing.T) {
|
||||
|
||||
globs := AggregateGlobs(commands)
|
||||
|
||||
// Now we properly resolve only the static part of globs
|
||||
// *.xml has no static part (current dir), so it becomes resolved_dir/*.xml
|
||||
// *.txt has no static part (current dir), so it becomes resolved_dir/*.txt
|
||||
// *.json has no static part (current dir), so it becomes resolved_dir/*.json
|
||||
// subdir/*.xml has static "subdir", so it becomes resolved_dir/subdir/*.xml
|
||||
cwd, _ := os.Getwd()
|
||||
resolvedCwd := ResolvePath(cwd)
|
||||
|
||||
expected := map[string]struct{}{
|
||||
"*.xml": {},
|
||||
"*.txt": {},
|
||||
"*.json": {},
|
||||
"subdir/*.xml": {},
|
||||
resolvedCwd + "/*.xml": {},
|
||||
resolvedCwd + "/*.txt": {},
|
||||
resolvedCwd + "/*.json": {},
|
||||
resolvedCwd + "/subdir/*.xml": {},
|
||||
}
|
||||
|
||||
if len(globs) != len(expected) {
|
||||
@@ -266,142 +277,21 @@ func TestAggregateGlobs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCommandFromArgs(t *testing.T) {
|
||||
// Save original flags
|
||||
origGitFlag := *GitFlag
|
||||
origResetFlag := *ResetFlag
|
||||
origLogLevel := *LogLevel
|
||||
|
||||
// Restore original flags after test
|
||||
defer func() {
|
||||
*GitFlag = origGitFlag
|
||||
*ResetFlag = origResetFlag
|
||||
*LogLevel = origLogLevel
|
||||
}()
|
||||
|
||||
// Test cases
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
gitFlag bool
|
||||
resetFlag bool
|
||||
logLevel string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid command",
|
||||
args: []string{"pattern", "expr", "file1", "file2"},
|
||||
gitFlag: false,
|
||||
resetFlag: false,
|
||||
logLevel: "INFO",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Not enough args",
|
||||
args: []string{"pattern", "expr"},
|
||||
gitFlag: false,
|
||||
resetFlag: false,
|
||||
logLevel: "INFO",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "With git flag",
|
||||
args: []string{"pattern", "expr", "file1"},
|
||||
gitFlag: true,
|
||||
resetFlag: false,
|
||||
logLevel: "INFO",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "With reset flag (forces git flag)",
|
||||
args: []string{"pattern", "expr", "file1"},
|
||||
gitFlag: false,
|
||||
resetFlag: true,
|
||||
logLevel: "INFO",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "With custom log level",
|
||||
args: []string{"pattern", "expr", "file1"},
|
||||
gitFlag: false,
|
||||
resetFlag: false,
|
||||
logLevel: "DEBUG",
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set flags for this test case
|
||||
*GitFlag = tc.gitFlag
|
||||
*ResetFlag = tc.resetFlag
|
||||
*LogLevel = tc.logLevel
|
||||
|
||||
commands, err := LoadCommandFromArgs(tc.args)
|
||||
|
||||
if tc.shouldError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(commands) != 1 {
|
||||
t.Errorf("Expected 1 command, got %d", len(commands))
|
||||
return
|
||||
}
|
||||
|
||||
cmd := commands[0]
|
||||
|
||||
// Check command properties
|
||||
if cmd.Regex != tc.args[0] {
|
||||
t.Errorf("Expected pattern %q, got %q", tc.args[0], cmd.Regex)
|
||||
}
|
||||
|
||||
if cmd.Lua != tc.args[1] {
|
||||
t.Errorf("Expected LuaExpr %q, got %q", tc.args[1], cmd.Lua)
|
||||
}
|
||||
|
||||
if len(cmd.Files) != len(tc.args)-2 {
|
||||
t.Errorf("Expected %d files, got %d", len(tc.args)-2, len(cmd.Files))
|
||||
}
|
||||
|
||||
// When reset is true, git should be true regardless of what was set
|
||||
expectedGit := tc.gitFlag || tc.resetFlag
|
||||
if cmd.Git != expectedGit {
|
||||
t.Errorf("Expected Git flag %v, got %v", expectedGit, cmd.Git)
|
||||
}
|
||||
|
||||
if cmd.Reset != tc.resetFlag {
|
||||
t.Errorf("Expected Reset flag %v, got %v", tc.resetFlag, cmd.Reset)
|
||||
}
|
||||
|
||||
if cmd.LogLevel != tc.logLevel {
|
||||
t.Errorf("Expected LogLevel %q, got %q", tc.logLevel, cmd.LogLevel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully unmarshal valid YAML data into ModifyCommand slice
|
||||
func TestLoadCommandsFromCookFileSuccess(t *testing.T) {
|
||||
// Arrange
|
||||
yamlData := []byte(`
|
||||
- name: command1
|
||||
pattern: "*.txt"
|
||||
lua: replace
|
||||
- name: command2
|
||||
pattern: "*.go"
|
||||
lua: delete
|
||||
commands:
|
||||
- name: command1
|
||||
regex: "*.txt"
|
||||
lua: replace
|
||||
- name: command2
|
||||
regex: "*.go"
|
||||
lua: delete
|
||||
`)
|
||||
|
||||
// Act
|
||||
commands, err := LoadCommandsFromCookFile(yamlData)
|
||||
commands, _, err := LoadCommandsFromCookFile(yamlData)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
@@ -419,17 +309,18 @@ func TestLoadCommandsFromCookFileWithComments(t *testing.T) {
|
||||
// Arrange
|
||||
yamlData := []byte(`
|
||||
# This is a comment
|
||||
- name: command1
|
||||
pattern: "*.txt"
|
||||
lua: replace
|
||||
# Another comment
|
||||
- name: command2
|
||||
pattern: "*.go"
|
||||
lua: delete
|
||||
commands:
|
||||
- name: command1
|
||||
regex: "*.txt"
|
||||
lua: replace
|
||||
# Another comment
|
||||
- name: command2
|
||||
regex: "*.go"
|
||||
lua: delete
|
||||
`)
|
||||
|
||||
// Act
|
||||
commands, err := LoadCommandsFromCookFile(yamlData)
|
||||
commands, _, err := LoadCommandsFromCookFile(yamlData)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
@@ -445,10 +336,10 @@ func TestLoadCommandsFromCookFileWithComments(t *testing.T) {
|
||||
// Handle different YAML formatting styles (flow vs block)
|
||||
func TestLoadCommandsFromCookFileWithFlowStyle(t *testing.T) {
|
||||
// Arrange
|
||||
yamlData := []byte(`[ { name: command1, pattern: "*.txt", lua: replace }, { name: command2, pattern: "*.go", lua: delete } ]`)
|
||||
yamlData := []byte(`commands: [ { name: command1, regex: "*.txt", lua: replace }, { name: command2, regex: "*.go", lua: delete } ]`)
|
||||
|
||||
// Act
|
||||
commands, err := LoadCommandsFromCookFile(yamlData)
|
||||
commands, _, err := LoadCommandsFromCookFile(yamlData)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
@@ -468,8 +359,8 @@ func TestLoadCommandsFromCookFileNilOrEmptyData(t *testing.T) {
|
||||
emptyData := []byte{}
|
||||
|
||||
// Act
|
||||
commandsNil, errNil := LoadCommandsFromCookFile(nilData)
|
||||
commandsEmpty, errEmpty := LoadCommandsFromCookFile(emptyData)
|
||||
commandsNil, _, errNil := LoadCommandsFromCookFile(nilData)
|
||||
commandsEmpty, _, errEmpty := LoadCommandsFromCookFile(emptyData)
|
||||
|
||||
// Assert
|
||||
assert.Nil(t, errNil)
|
||||
@@ -484,7 +375,7 @@ func TestLoadCommandsFromCookFileEmptyData(t *testing.T) {
|
||||
yamlData := []byte(``)
|
||||
|
||||
// Act
|
||||
commands, err := LoadCommandsFromCookFile(yamlData)
|
||||
commands, _, err := LoadCommandsFromCookFile(yamlData)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
@@ -495,19 +386,20 @@ func TestLoadCommandsFromCookFileEmptyData(t *testing.T) {
|
||||
func TestLoadCommandsFromCookFileWithMultipleEntries(t *testing.T) {
|
||||
// Arrange
|
||||
yamlData := []byte(`
|
||||
- name: command1
|
||||
pattern: "*.txt"
|
||||
lua: replace
|
||||
- name: command2
|
||||
pattern: "*.go"
|
||||
lua: delete
|
||||
- name: command3
|
||||
pattern: "*.md"
|
||||
lua: append
|
||||
commands:
|
||||
- name: command1
|
||||
regex: "*.txt"
|
||||
lua: replace
|
||||
- name: command2
|
||||
regex: "*.go"
|
||||
lua: delete
|
||||
- name: command3
|
||||
regex: "*.md"
|
||||
lua: append
|
||||
`)
|
||||
|
||||
// Act
|
||||
commands, err := LoadCommandsFromCookFile(yamlData)
|
||||
commands, _, err := LoadCommandsFromCookFile(yamlData)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
@@ -526,26 +418,27 @@ func TestLoadCommandsFromCookFileWithMultipleEntries(t *testing.T) {
|
||||
func TestLoadCommandsFromCookFileLegitExample(t *testing.T) {
|
||||
// Arrange
|
||||
yamlData := []byte(`
|
||||
- name: crewlayabout
|
||||
pattern: '<Talent identifier="crewlayabout">!anyvalue="(?<repairspeedpenalty>!num)"!anyvalue="(?<skillpenalty>!num)"!anyvalue="(?<repairspeedbonus>!num)"!anyvalue="(?<skillbonus>!num)"!anydistance="(?<distance>!num)"!anySkillBonus!anyvalue="(?<skillpenaltyv>!num)"!anyvalue="(?<skillpenaltyv1>!num)"!anyvalue="(?<skillpenaltyv2>!num)"!anyvalue="(?<skillpenaltyv3>!num)"!anyvalue="(?<skillpenaltyv4>!num)"!anyvalue="(?<repairspeedpenaltyv>!num)'
|
||||
lua: |
|
||||
repairspeedpenalty=round(repairspeedpenalty/2, 2)
|
||||
skillpenalty=round(skillpenalty/2, 0)
|
||||
repairspeedbonus=round(repairspeedbonus*2, 2)
|
||||
skillbonus=round(skillbonus*2, 0)
|
||||
distance=round(distance*2, 0)
|
||||
skillpenaltyv=skillpenalty
|
||||
skillpenaltyv1=skillpenalty
|
||||
skillpenaltyv2=skillpenalty
|
||||
skillpenaltyv3=skillpenalty
|
||||
skillpenaltyv4=skillpenalty
|
||||
repairspeedpenaltyv=round(-repairspeedpenalty/100, 2)
|
||||
files:
|
||||
- '**/TalentsAssistant.xml'
|
||||
commands:
|
||||
- name: crewlayabout
|
||||
pattern: '<Talent identifier="crewlayabout">!anyvalue="(?<repairspeedpenalty>!num)"!anyvalue="(?<skillpenalty>!num)"!anyvalue="(?<repairspeedbonus>!num)"!anyvalue="(?<skillbonus>!num)"!anydistance="(?<distance>!num)"!anySkillBonus!anyvalue="(?<skillpenaltyv>!num)"!anyvalue="(?<skillpenaltyv1>!num)"!anyvalue="(?<skillpenaltyv2>!num)"!anyvalue="(?<skillpenaltyv3>!num)"!anyvalue="(?<skillpenaltyv4>!num)"!anyvalue="(?<repairspeedpenaltyv>!num)'
|
||||
lua: |
|
||||
repairspeedpenalty=round(repairspeedpenalty/2, 2)
|
||||
skillpenalty=round(skillpenalty/2, 0)
|
||||
repairspeedbonus=round(repairspeedbonus*2, 2)
|
||||
skillbonus=round(skillbonus*2, 0)
|
||||
distance=round(distance*2, 0)
|
||||
skillpenaltyv=skillpenalty
|
||||
skillpenaltyv1=skillpenalty
|
||||
skillpenaltyv2=skillpenalty
|
||||
skillpenaltyv3=skillpenalty
|
||||
skillpenaltyv4=skillpenalty
|
||||
repairspeedpenaltyv=round(-repairspeedpenalty/100, 2)
|
||||
files:
|
||||
- '**/TalentsAssistant.xml'
|
||||
`)
|
||||
|
||||
// Act
|
||||
commands, err := LoadCommandsFromCookFile(yamlData)
|
||||
commands, _, err := LoadCommandsFromCookFile(yamlData)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
@@ -553,155 +446,6 @@ func TestLoadCommandsFromCookFileLegitExample(t *testing.T) {
|
||||
assert.Equal(t, "crewlayabout", commands[0].Name)
|
||||
}
|
||||
|
||||
// Valid command with minimum 3 arguments returns a ModifyCommand slice with correct values
|
||||
func TestLoadCommandFromArgsWithValidArguments(t *testing.T) {
|
||||
// Setup
|
||||
oldGitFlag := GitFlag
|
||||
oldResetFlag := ResetFlag
|
||||
oldLogLevel := LogLevel
|
||||
|
||||
gitValue := true
|
||||
resetValue := false
|
||||
logLevelValue := "info"
|
||||
|
||||
GitFlag = &gitValue
|
||||
ResetFlag = &resetValue
|
||||
LogLevel = &logLevelValue
|
||||
|
||||
defer func() {
|
||||
GitFlag = oldGitFlag
|
||||
ResetFlag = oldResetFlag
|
||||
LogLevel = oldLogLevel
|
||||
}()
|
||||
|
||||
args := []string{"*.go", "return x", "file1.go", "file2.go"}
|
||||
|
||||
// Execute
|
||||
commands, err := LoadCommandFromArgs(args)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commands, 1)
|
||||
assert.Equal(t, "*.go", commands[0].Regex)
|
||||
assert.Equal(t, "return x", commands[0].Lua)
|
||||
assert.Equal(t, []string{"file1.go", "file2.go"}, commands[0].Files)
|
||||
assert.Equal(t, true, commands[0].Git)
|
||||
assert.Equal(t, false, commands[0].Reset)
|
||||
assert.Equal(t, "info", commands[0].LogLevel)
|
||||
}
|
||||
|
||||
// Less than 3 arguments returns an error with appropriate message
|
||||
func TestLoadCommandFromArgsWithInsufficientArguments(t *testing.T) {
|
||||
// Setup
|
||||
oldGitFlag := GitFlag
|
||||
oldResetFlag := ResetFlag
|
||||
oldLogLevel := LogLevel
|
||||
|
||||
gitValue := false
|
||||
resetValue := false
|
||||
logLevelValue := "info"
|
||||
|
||||
GitFlag = &gitValue
|
||||
ResetFlag = &resetValue
|
||||
LogLevel = &logLevelValue
|
||||
|
||||
defer func() {
|
||||
GitFlag = oldGitFlag
|
||||
ResetFlag = oldResetFlag
|
||||
LogLevel = oldLogLevel
|
||||
}()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"empty args", []string{}},
|
||||
{"one arg", []string{"*.go"}},
|
||||
{"two args", []string{"*.go", "return x"}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Execute
|
||||
commands, err := LoadCommandFromArgs(tc.args)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, commands)
|
||||
assert.Contains(t, err.Error(), "at least 3 arguments are required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern, Lua, and Files fields are correctly populated from args
|
||||
func TestLoadCommandFromArgsPopulatesFieldsCorrectly(t *testing.T) {
|
||||
// Setup
|
||||
oldGitFlag := GitFlag
|
||||
oldResetFlag := ResetFlag
|
||||
oldLogLevel := LogLevel
|
||||
|
||||
gitValue := false
|
||||
resetValue := false
|
||||
logLevelValue := "debug"
|
||||
|
||||
GitFlag = &gitValue
|
||||
ResetFlag = &resetValue
|
||||
LogLevel = &logLevelValue
|
||||
|
||||
defer func() {
|
||||
GitFlag = oldGitFlag
|
||||
ResetFlag = oldResetFlag
|
||||
LogLevel = oldLogLevel
|
||||
}()
|
||||
|
||||
args := []string{"*.txt", "print('Hello')", "file1.txt", "file2.txt"}
|
||||
|
||||
// Execute
|
||||
commands, err := LoadCommandFromArgs(args)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commands, 1)
|
||||
assert.Equal(t, "*.txt", commands[0].Regex)
|
||||
assert.Equal(t, "print('Hello')", commands[0].Lua)
|
||||
assert.Equal(t, []string{"file1.txt", "file2.txt"}, commands[0].Files)
|
||||
assert.Equal(t, false, commands[0].Git)
|
||||
assert.Equal(t, false, commands[0].Reset)
|
||||
assert.Equal(t, "debug", commands[0].LogLevel)
|
||||
}
|
||||
|
||||
// Git flag is set to true when ResetFlag is true
|
||||
func TestLoadCommandFromArgsSetsGitFlagWhenResetFlagIsTrue(t *testing.T) {
|
||||
// Setup
|
||||
oldGitFlag := GitFlag
|
||||
oldResetFlag := ResetFlag
|
||||
oldLogLevel := LogLevel
|
||||
|
||||
gitValue := false
|
||||
resetValue := true
|
||||
logLevelValue := "info"
|
||||
|
||||
GitFlag = &gitValue
|
||||
ResetFlag = &resetValue
|
||||
LogLevel = &logLevelValue
|
||||
|
||||
defer func() {
|
||||
GitFlag = oldGitFlag
|
||||
ResetFlag = oldResetFlag
|
||||
LogLevel = oldLogLevel
|
||||
}()
|
||||
|
||||
args := []string{"*.go", "return x", "file1.go", "file2.go"}
|
||||
|
||||
// Execute
|
||||
commands, err := LoadCommandFromArgs(args)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, commands, 1)
|
||||
assert.Equal(t, true, commands[0].Git)
|
||||
}
|
||||
|
||||
// TODO: Figure out how to mock shit
|
||||
// Can't be asked doing that right now...
|
||||
// Successfully loads commands from multiple YAML files in the current directory
|
||||
@@ -803,7 +547,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
// Execute function
|
||||
commands, err := LoadCommandsFromCookFiles("")
|
||||
commands, _, err := LoadCommandsFromCookFiles("")
|
||||
|
||||
// Assertions
|
||||
if err != nil {
|
||||
@@ -869,7 +613,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
//
|
||||
// // Execute function
|
||||
// commands, err := LoadCommandsFromCookFiles("")
|
||||
// commands, _, err := LoadCommandsFromCookFiles("")
|
||||
//
|
||||
// // Assertions
|
||||
// if err != nil {
|
||||
@@ -953,7 +697,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
//
|
||||
// // Execute function
|
||||
// commands, err := LoadCommandsFromCookFiles("")
|
||||
// commands, _, err := LoadCommandsFromCookFiles("")
|
||||
//
|
||||
// // Assertions
|
||||
// if err != nil {
|
||||
@@ -965,6 +709,58 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
// }
|
||||
|
||||
func TestExpandGlobsMemoization(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "expand-globs-memo-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
err = os.WriteFile(filepath.Join(tmpDir, "test1.go"), []byte("test"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(tmpDir, "test2.go"), []byte("test"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
os.Chdir(tmpDir)
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
resolvedCwd := ResolvePath(cwd)
|
||||
pattern1 := resolvedCwd + "/*.go"
|
||||
patterns := map[string]struct{}{pattern1: {}}
|
||||
|
||||
globMemoTable = make(map[string][]string)
|
||||
|
||||
files1, err := ExpandGlobs(patterns)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandGlobs failed: %v", err)
|
||||
}
|
||||
if len(files1) != 2 {
|
||||
t.Fatalf("Expected 2 files, got %d", len(files1))
|
||||
}
|
||||
|
||||
if len(globMemoTable) != 1 {
|
||||
t.Fatalf("Expected 1 entry in memo table, got %d", len(globMemoTable))
|
||||
}
|
||||
|
||||
files2, err := ExpandGlobs(patterns)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpandGlobs failed: %v", err)
|
||||
}
|
||||
if len(files2) != 2 {
|
||||
t.Fatalf("Expected 2 files, got %d", len(files2))
|
||||
}
|
||||
|
||||
if len(globMemoTable) != 1 {
|
||||
t.Fatalf("Expected memo table to still have 1 entry, got %d", len(globMemoTable))
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCommandsFromCookFile returns an error for a malformed YAML file
|
||||
// func TestLoadCommandsFromCookFilesMalformedYAML(t *testing.T) {
|
||||
// // Setup test directory with mock YAML files
|
||||
@@ -1060,7 +856,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
//
|
||||
// // Execute function
|
||||
// commands, err := LoadCommandsFromCookFiles("")
|
||||
// commands, _, err := LoadCommandsFromCookFiles("")
|
||||
//
|
||||
// // Assertions
|
||||
// if err == nil {
|
||||
@@ -1127,7 +923,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
//
|
||||
// // Execute function
|
||||
// commands, err := LoadCommandsFromCookFiles("")
|
||||
// commands, _, err := LoadCommandsFromCookFiles("")
|
||||
//
|
||||
// // Assertions
|
||||
// if err != nil {
|
||||
@@ -1197,7 +993,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
//
|
||||
// // Execute function
|
||||
// commands, err := LoadCommandsFromCookFiles("")
|
||||
// commands, _, err := LoadCommandsFromCookFiles("")
|
||||
//
|
||||
// // Assertions
|
||||
// if err != nil {
|
||||
@@ -1255,7 +1051,7 @@ func TestLoadCommandsFromCookFilesNoYamlFiles(t *testing.T) {
|
||||
// }
|
||||
//
|
||||
// // Execute function
|
||||
// commands, err := LoadCommandsFromCookFiles("")
|
||||
// commands, _, err := LoadCommandsFromCookFiles("")
|
||||
//
|
||||
// // Assertions
|
||||
// if err != nil {
|
||||
|
||||
93
utils/modifycommand_yaml_convert_test.go
Normal file
93
utils/modifycommand_yaml_convert_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestConvertYAMLToTOMLReadError tests error handling when YAML file can't be read
|
||||
func TestConvertYAMLToTOMLReadError(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "convert-read-error-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create YAML file with no read permissions (on Unix) or delete it after creation
|
||||
yamlFile := filepath.Join(tmpDir, "test.yml")
|
||||
err = os.WriteFile(yamlFile, []byte("commands:\n - name: test\n"), 0000)
|
||||
assert.NoError(t, err)
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// This should fail to read but not crash
|
||||
err = ConvertYAMLToTOML("test.yml")
|
||||
// Function continues on error, doesn't return error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Fix permissions for cleanup
|
||||
os.Chmod(yamlFile, 0644)
|
||||
}
|
||||
|
||||
// TestConvertYAMLToTOMLParseError tests error handling when YAML is invalid
|
||||
func TestConvertYAMLToTOMLParseError(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "convert-parse-error-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create invalid YAML
|
||||
yamlFile := filepath.Join(tmpDir, "invalid.yml")
|
||||
err = os.WriteFile(yamlFile, []byte("commands:\n - [this is not valid yaml}}"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// This should fail to parse but not crash
|
||||
err = ConvertYAMLToTOML("invalid.yml")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// TOML file should not exist
|
||||
_, statErr := os.Stat(filepath.Join(tmpDir, "invalid.toml"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
|
||||
// TestConvertYAMLToTOMLWriteError tests error handling when TOML file can't be written
|
||||
func TestConvertYAMLToTOMLWriteError(t *testing.T) {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Skip("Skipping write permission test in CI")
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "convert-write-error-*")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create valid YAML
|
||||
yamlFile := filepath.Join(tmpDir, "test.yml")
|
||||
err = os.WriteFile(yamlFile, []byte("commands:\n - name: test\n regex: test\n lua: v1\n files: [test.txt]\n"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create output directory with no write permissions
|
||||
outputDir := filepath.Join(tmpDir, "readonly")
|
||||
err = os.Mkdir(outputDir, 0555)
|
||||
assert.NoError(t, err)
|
||||
defer os.Chmod(outputDir, 0755) // Fix for cleanup
|
||||
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(tmpDir)
|
||||
|
||||
// Move YAML into readonly dir
|
||||
newYamlFile := filepath.Join(outputDir, "test.yml")
|
||||
os.Rename(yamlFile, newYamlFile)
|
||||
|
||||
os.Chdir(outputDir)
|
||||
|
||||
// This should fail to write but not crash
|
||||
err = ConvertYAMLToTOML("test.yml")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
79
utils/path.go
Normal file
79
utils/path.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 path to an absolute path, handling ~ expansion and cleaning
|
||||
func ResolvePath(path string) string {
|
||||
resolvePathLogger := pathLogger.WithPrefix("ResolvePath").WithField("inputPath", path)
|
||||
resolvePathLogger.Trace("Resolving path: %q", path)
|
||||
|
||||
// Handle empty path
|
||||
if path == "" {
|
||||
resolvePathLogger.Trace("Empty path, returning empty string")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if path is absolute
|
||||
if filepath.IsAbs(path) {
|
||||
resolvePathLogger.Trace("Path is already absolute: %q", path)
|
||||
cleaned := filepath.ToSlash(filepath.Clean(path))
|
||||
resolvePathLogger.Trace("Cleaned absolute path: %q", cleaned)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Handle ~ expansion
|
||||
if strings.HasPrefix(path, "~") {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") {
|
||||
path = filepath.Join(homeDir, path[2:])
|
||||
} else if path == "~" {
|
||||
path = homeDir
|
||||
} else {
|
||||
// ~something (like ~~), treat first ~ as home expansion, rest as literal
|
||||
path = homeDir + path[1:]
|
||||
}
|
||||
resolvePathLogger.Trace("Expanded ~ to home directory: %q", path)
|
||||
}
|
||||
|
||||
// Make absolute if not already
|
||||
if !filepath.IsAbs(path) {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
resolvePathLogger.Error("Failed to get absolute path: %v", err)
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
resolvePathLogger.Trace("Made path absolute: %q -> %q", path, absPath)
|
||||
path = absPath
|
||||
}
|
||||
|
||||
// Clean the path and normalize to forward slashes for consistency
|
||||
cleaned := filepath.ToSlash(filepath.Clean(path))
|
||||
resolvePathLogger.Trace("Final cleaned path: %q", cleaned)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// GetRelativePath returns the relative path from base to target
|
||||
func GetRelativePath(base, target string) (string, error) {
|
||||
getRelativePathLogger := pathLogger.WithPrefix("GetRelativePath")
|
||||
getRelativePathLogger.Debug("Getting relative path from %q to %q", base, target)
|
||||
|
||||
relPath, err := filepath.Rel(base, target)
|
||||
if err != nil {
|
||||
getRelativePathLogger.Error("Failed to get relative path: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use forward slashes for consistency
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
getRelativePathLogger.Debug("Relative path: %q", relPath)
|
||||
return relPath, nil
|
||||
}
|
||||
386
utils/path_test.go
Normal file
386
utils/path_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"modify/logger"
|
||||
"sort"
|
||||
|
||||
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 {
|
||||
From int
|
||||
To int
|
||||
@@ -13,45 +17,63 @@ type ReplaceCommand struct {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
sort.Slice(modifications, func(i, j int) bool {
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
logger.Error("Failed to execute replacement: %v", err)
|
||||
executeModificationsLogger.Error("Failed to execute replacement for modification %+v: %v", modification, err)
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
if err != nil {
|
||||
executeLogger.Error("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)
|
||||
return fileDataStr[:m.From] + m.With + fileDataStr[m.To:], nil
|
||||
executeLogger.Trace("Applying replacement: fileDataStr[:%d] + %q + fileDataStr[%d:]", m.From, m.With, m.To)
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
validateLogger.Debug("Modification command validated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user