Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2d523dfe64 | |||
| 2629722f67 | |||
| 1f6c4e4976 | |||
| bfd08e754e | |||
| 750010b71a | |||
| 9064a53820 | |||
| 294c04a11a | |||
| ba7ac07001 | |||
| 5d10178bf9 | |||
| f91c2b4795 | |||
| 057db23d09 | |||
| bf72734b90 | |||
| cc30c2bdcb | |||
| f453079c72 | |||
| e634fe28bd | |||
| 4e4b7bbd19 | |||
| 89eed3f847 | |||
| f008efd5e1 | |||
| f6def1e5a5 | |||
| 867b188718 | |||
| aac29a4074 | |||
| 8a40f463f7 | |||
| 8d4db1da91 | |||
| d41e2afe17 | |||
| 76457d22cf | |||
| 912950d463 | |||
| 25326ea11b | |||
| df212b7fcc | |||
| f4a963760a | |||
| d236811cb9 | |||
| da93770334 | |||
| d9f54a8354 | |||
| dc8da8ab63 | |||
| 24262a7dca | |||
| d77b13c363 | |||
| a9c60a3698 | |||
| 66bcf21d79 | |||
| e847e5c3ce | |||
| 9a70c9696e | |||
| 9cea103042 | |||
| 81d8259dfc | |||
| 5c5fbac63f | |||
| 3e818e61c7 | |||
| 001470ffe4 | |||
| d88a76c4e2 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
*.exe
|
||||
.qodo
|
||||
*.sqlite
|
||||
.cursor/rules
|
||||
|
||||
105
.vscode/launch.json
vendored
105
.vscode/launch.json
vendored
@@ -5,16 +5,111 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"name": "Launch Package (Barotrauma)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Barotrauma",
|
||||
"args": [
|
||||
"-loglevel",
|
||||
"trace",
|
||||
"-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",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Barotrauma cookfile)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Barotrauma",
|
||||
"args": [
|
||||
"-loglevel",
|
||||
"trace",
|
||||
"-cook",
|
||||
"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": [
|
||||
"-mode=json",
|
||||
"$..name",
|
||||
"v='pero'",
|
||||
"test.json"
|
||||
"tester.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Avorion)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Avorion/Avorion",
|
||||
"args": [
|
||||
"*.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (Minecraft)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-Minecraft",
|
||||
"args": [
|
||||
"cook_tacz.yml",
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch Package (ICARUS)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "C:/Users/Administrator/Seafile/Games-ICARUS/Icarus/Saved/IME3/Mods",
|
||||
"args": [
|
||||
"-loglevel",
|
||||
"trace",
|
||||
"cook_processorrecipes.yml",
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
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,651 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Talents>
|
||||
<Talent identifier="powerarmor">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="5,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.powerarmor">
|
||||
<Replace tag="[bonusmovement]" value="25" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.exosuit" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupInterval interval="0.9">
|
||||
<Conditions>
|
||||
<AbilityConditionHasItem tags="deepdivinglarge" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyStat stattype="MovementSpeed" value="0.25" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AddedRecipe itemidentifier="exosuit"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="foolhardy">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="4,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.lowhealthstatboost">
|
||||
<Replace tag="[health]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.additionalstattype">
|
||||
<Replace tag="[amount]" value="20" color="gui.green"/>
|
||||
<Replace tag="[stattype]" value="stattypenames.physicalresistance" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupInterval interval="0.9">
|
||||
<Conditions>
|
||||
<AbilityConditionAboveVitality invert="true" vitalitypercentage="0.5"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityApplyStatusEffects>
|
||||
<StatusEffects>
|
||||
<StatusEffect type="OnAbility" target="Character" disabledeltatime="true" multiplyafflictionsbymaxvitality="true">
|
||||
<Affliction identifier="foolhardy" amount="1.0"/>
|
||||
</StatusEffect>
|
||||
</StatusEffects>
|
||||
</CharacterAbilityApplyStatusEffects>
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="berserker">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="3,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.lowhealthstatboost">
|
||||
<Replace tag="[health]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.additionalstattype">
|
||||
<Replace tag="[amount]" value="20" color="gui.green"/>
|
||||
<Replace tag="[stattype]" value="stattypenames.meleedamagebonus" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupInterval interval="0.9">
|
||||
<Conditions>
|
||||
<AbilityConditionAboveVitality invert="true" vitalitypercentage="0.5"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityApplyStatusEffects>
|
||||
<StatusEffects>
|
||||
<StatusEffect type="OnAbility" target="Character" disabledeltatime="true" multiplyafflictionsbymaxvitality="true">
|
||||
<Affliction identifier="berserker" amount="1.0"/>
|
||||
</StatusEffect>
|
||||
</StatusEffects>
|
||||
</CharacterAbilityApplyStatusEffects>
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="mudraptorwrestler">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="2,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.mudraptorwrestler">
|
||||
<Replace tag="[amount]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.additionalstattypeself">
|
||||
<Replace tag="[amount]" value="10" color="gui.green"/>
|
||||
<Replace tag="[stattype]" value="stattypenames.physicalresistance" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnAttack">
|
||||
<Conditions>
|
||||
<AbilityConditionAttackData weapontype="NoWeapon,Melee" />
|
||||
<AbilityConditionCharacter>
|
||||
<Conditional group="eq mudraptor" />
|
||||
</AbilityConditionCharacter>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyAttackData addeddamagemultiplier="0.5"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveResistance resistanceid="damage" multiplier="0.9"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="heavylifting">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="1,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.heavylifting">
|
||||
<Replace tag="[amount]" value="20" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupInterval interval="0.9">
|
||||
<Conditions>
|
||||
<AbilityConditionHoldingItem tags="alienartifact,crate"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyStat stattype="MovementSpeed" value="0.2"/>
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="iamthatguy">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="0,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.iamthatguy">
|
||||
<Replace tag="[amount]" value="20" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.skillbonus">
|
||||
<Replace tag="[amount]" value="20" color="gui.green"/>
|
||||
<Replace tag="[skillname]" value="stattypenames.weaponsskillbonus" color="gui.orange"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.heavywrench" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveStat stattype="WeaponsSkillBonus" value="20"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupEffect abilityeffecttype="OnAddDamageAffliction">
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyAffliction afflictionidentifiers="blunttrauma" addedmultiplier="0.2" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AddedRecipe itemidentifier="heavywrench"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="robotics">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="3,7" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.robotics"/>
|
||||
<Description tag="talentdescription.roboticsreminder">
|
||||
<Replace tag="[amount]" value="2" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.defensebotspawner,entityname.defensebotammobox" color="gui.orange"/>
|
||||
</Description>
|
||||
<AddedRecipe itemidentifier="defensebotspawner"/>
|
||||
<AddedRecipe itemidentifier="defensebotammobox"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="ironstorm">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="7,5" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.ironstorm">
|
||||
<Replace tag="[chance]" value="10" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.scrapcannon" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilitySetMetadataInt identifier="tiermodifieroverride" value="3"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AddedRecipe itemidentifier="scrapcannon"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="residualwaste">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="6,5" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.residualwaste">
|
||||
<Replace tag="[chance]" value="20" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnItemDeconstructedMaterial">
|
||||
<Conditions>
|
||||
<AbilityConditionServerRandom randomChance="0.2"/>
|
||||
<!-- don't allow duplicating genetic materials, and prevent infinite FPGA circuits -->
|
||||
<AbilityConditionItem tags="geneticmaterial,unidentifiedgeneticmaterial,circuitboxcomponent,lightcomponent" invert="true"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyValue multiplyvalue="2"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="massproduction">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="6,1" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.massproduction">
|
||||
<Replace tag="[chance]" value="40" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnItemFabricatedIngredients">
|
||||
<Conditions>
|
||||
<AbilityConditionServerRandom randomChance="0.4" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityRemoveRandomIngredient>
|
||||
<AbilityConditionItem category="Material"/>
|
||||
</CharacterAbilityRemoveRandomIngredient>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="toolmaintenance">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="5,5" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.toolmaintenance">
|
||||
<Replace tag="[amount]" value="1" color="gui.green"/>
|
||||
</Description>
|
||||
<!-- Give once when unlocking the talent -->
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGivePermanentStat statidentifier="tool~toolmaintenance" stattype="IncreaseFabricationQuality" value="1" targetallies="true" setvalue="true"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<!-- Give every 60 seconds for late comers -->
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Abilities>
|
||||
<CharacterAbilityGivePermanentStat statidentifier="tool~toolmaintenance" stattype="IncreaseFabricationQuality" value="1" targetallies="true" setvalue="true"/>
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="miner">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="2,3" sheetelementsize="428,428"/>
|
||||
<Description tag="talentdescription.miner">
|
||||
<Replace tag="[probability]" value="320" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.gainoredetachspeed">
|
||||
<Replace tag="[amount]" value="1600" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveStat stattype="RepairToolDeattachTimeMultiplier" value="1"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupEffect abilityeffecttype="OnItemDeconstructedMaterial">
|
||||
<Conditions>
|
||||
<AbilityConditionServerRandom randomchance="12.8"/>
|
||||
<AbilityConditionItem tags="ore"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyValue multiplyvalue="2"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="retrofit">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="3,5" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.retrofit" />
|
||||
<Description tag="talentdescription.doesnotstack" />
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilitySetMetadataInt identifier="tiermodifiers.increasewallhealth" value="1"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="ironman">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="6,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.ironhelmet,entityname.makeshiftarmor" color="gui.orange"/>
|
||||
</Description>
|
||||
<AddedRecipe itemidentifier="ironhelmet"/>
|
||||
<AddedRecipe itemidentifier="makeshiftarmor"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="oiledmachinery">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="4,5" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.oiledmachinery">
|
||||
<Replace tag="[amount]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.doesnotstack" />
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="fabricator" stattype="FabricationSpeed" value="1.5" />
|
||||
<CharacterAbilityGiveItemStatToTags tags="deconstructor" stattype="DeconstructorSpeed" value="1.5" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="fabricator" stattype="FabricationSpeed" value="1.5" />
|
||||
<CharacterAbilityGiveItemStatToTags tags="deconstructor" stattype="DeconstructorSpeed" value="1.5" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="pumpndump">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="1,7" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.pumpndump">
|
||||
<Replace tag="[amount]" value="10" color="gui.green"/>
|
||||
<Replace tag="[stattype]" value="stattypenames.maxflow" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnRepairComplete">
|
||||
<conditions>
|
||||
<AbilityConditionItem tags="pump"/>
|
||||
</conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStat stattype="PumpSpeed" value="1.1"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="ballastdenizen">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="7,6" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.ballastdenizen">
|
||||
<Replace tag="[amount]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveStat stattype="HoldBreathMultiplier" value="0.5"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="engineengineer">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="2,5" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.engineengineer">
|
||||
<Replace tag="[amount]" value="2.5" color="gui.green"/>
|
||||
<Replace tag="[max]" value="5" color="gui.green"/>
|
||||
<Replace tag="[stattype]" value="stattypenames.maxspeed" color="gui.orange"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.doesnotstack" />
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="1" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.025" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="2" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.05" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="3" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.075" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="4" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.1" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="5" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.125" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="6" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.15" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel levelequals="7" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.175" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
<AbilityGroupInterval interval="60">
|
||||
<Conditions>
|
||||
<AbilityConditionHasLevel minlevel="8" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveItemStatToTags tags="engine" stattype="EngineMaxSpeed" stackable="false" value="1.2" />
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="multifunctional">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="6,1" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.multifunctional">
|
||||
<Replace tag="[powerincrease]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnAttack">
|
||||
<Conditions>
|
||||
<AbilityConditionAttackData tags="wrenchitem"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyAttackData addeddamagemultiplier="0.5"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupEffect abilityeffecttype="OnAttack">
|
||||
<Conditions>
|
||||
<AbilityConditionAttackData tags="crowbaritem"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyAttackData addeddamagemultiplier="0.5"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="salvagecrew">
|
||||
<Icon texture="Content/UI/TalentsIcons3.png" sheetindex="0,7" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.bonusxponmission">
|
||||
<Replace tag="[xpbonus]" value="30" color="gui.green"/>
|
||||
<Replace tag="[missiontype]" value="missiontype.salvage" color="gui.orange"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.salvagecrew">
|
||||
<Replace tag="[swimbonus]" value="50" color="gui.green"/>
|
||||
<Replace tag="[resistanceamount]" value="10" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnGainMissionExperience">
|
||||
<Conditions>
|
||||
<AbilityConditionMission missiontype="Salvage"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyValue multiplyvalue="1.3"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupInterval interval="0.9">
|
||||
<Conditions>
|
||||
<AbilityConditionInSubmarine submarinetype="Wreck" />
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityApplyStatusEffects>
|
||||
<StatusEffects>
|
||||
<StatusEffect type="OnAbility" target="This" disabledeltatime="true">
|
||||
<Affliction identifier="salvagecrew" amount="1.0"/>
|
||||
</StatusEffect>
|
||||
</StatusEffects>
|
||||
</CharacterAbilityApplyStatusEffects>
|
||||
</Abilities>
|
||||
</AbilityGroupInterval>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="machinemaniac" trackedstat="machinemaniac_counter" trackedmax="100">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="3,2" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.machinemaniac">
|
||||
<Replace tag="[bonus]" value="80" color="gui.green"/>
|
||||
<Replace tag="[amount]" value="3" color="gui.orange"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.machinemaniac.30">
|
||||
<Replace tag="[requirement]" value="12" color="gui.green"/>
|
||||
<Replace tag="[amount]" value="10" color="gui.green"/>
|
||||
<Replace tag="[skill]" value="stattypenames.mechanicalskillbonus" color="gui.orange"/>
|
||||
<Replace tag="[xpamount]" value="500" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.machinemaniac.50">
|
||||
<Replace tag="[requirement]" value="20" color="gui.green"/>
|
||||
<Replace tag="[level]" value="1" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.machinemaniac.100">
|
||||
<Replace tag="[requirement]" value="40" color="gui.green"/>
|
||||
<Replace tag="[amount]" value="50" color="gui.green"/>
|
||||
</Description>
|
||||
|
||||
<!-- Give the player stats that tracks if the rewards should be given -->
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGivePermanentStat statidentifier="machinemaniac_30" value="1" maxvalue="1" setvalue="true" />
|
||||
<CharacterAbilityGivePermanentStat statidentifier="machinemaniac_50" value="1" maxvalue="1" setvalue="true" />
|
||||
<CharacterAbilityGivePermanentStat statidentifier="machinemaniac_100" value="1" maxvalue="1" setvalue="true" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
|
||||
<AbilityGroupEffect abilityeffecttype="OnRepairComplete">
|
||||
<Conditions>
|
||||
<AbilityConditionItem tags="fabricator,door,engine,oxygengenerator,pump,turretammosource,deconstructor,medicalfabricator,ductblock"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGivePermanentStat statidentifier="machinemaniac_counter" value="1" removeondeath="false" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
|
||||
<AbilityGroupEffect abilityeffecttype="OnRepairComplete">
|
||||
<Conditions>
|
||||
<AbilityConditionHasPermanentStat statidentifier="machinemaniac_30" min="1"/>
|
||||
<AbilityConditionHasPermanentStat statidentifier="machinemaniac_counter" min="12"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveExperience amount="2000"/>
|
||||
<CharacterAbilityGivePermanentStat stattype="MechanicalSkillBonus" statidentifier="machinemaniac" value="10" setvalue="true" removeondeath="false" />
|
||||
<CharacterAbilityResetPermanentStat statidentifier="machinemaniac_30" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
|
||||
<AbilityGroupEffect abilityeffecttype="OnRepairComplete">
|
||||
<Conditions>
|
||||
<AbilityConditionHasPermanentStat statidentifier="machinemaniac_50" min="1"/>
|
||||
<AbilityConditionHasPermanentStat statidentifier="machinemaniac_counter" min="20"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityUpgradeSubmarine upgradeprefab="increasemaxpumpflow" upgradecategory="pumps" level="1" />
|
||||
<CharacterAbilityResetPermanentStat statidentifier="machinemaniac_50" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
|
||||
<AbilityGroupEffect abilityeffecttype="OnRepairComplete">
|
||||
<Conditions>
|
||||
<AbilityConditionHasPermanentStat statidentifier="machinemaniac_100" min="1"/>
|
||||
<AbilityConditionHasPermanentStat statidentifier="machinemaniac_counter" min="40"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityGivePermanentStat stattype="MechanicalRepairSpeed" statidentifier="machinemaniac" value="0.5" setvalue="true" removeondeath="false" />
|
||||
<CharacterAbilityResetPermanentStat statidentifier="machinemaniac_100" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="tinkerer">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="4,1" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.increasemaxrepairmechanical">
|
||||
<Replace tag="[percentage]" value="40" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveStat stattype="MaxRepairConditionMultiplierMechanical" value="0.4"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="modularrepairs">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="5,1" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.repairpack" color="gui.orange"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.freeupgrade">
|
||||
<Replace tag="[level]" value="1" color="gui.green"/>
|
||||
<Replace tag="[upgradename]" value="upgradename.decreaselowskillfixduration" color="gui.orange"/>
|
||||
</Description>
|
||||
<AddedRecipe itemidentifier="repairpack"/>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityUpgradeSubmarine upgradeprefab="decreaselowskillfixduration" upgradecategory="electricaldevices" level="1" />
|
||||
<CharacterAbilityUpgradeSubmarine upgradeprefab="decreaselowskillfixduration" upgradecategory="mechanicaldevices" level="1" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="hullfixer">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="0,2" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.fixfoamgrenade,entityname.handheldstatusmonitor" color="gui.orange"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.additionalstattype">
|
||||
<Replace tag="[amount]" value="25" color="gui.green"/>
|
||||
<Replace tag="[stattype]" value="stattypenames.repairtoolstructurerepairmultiplier" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveStat stattype="RepairToolStructureRepairMultiplier" value="0.25"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AddedRecipe itemidentifier="fixfoamgrenade"/>
|
||||
<AddedRecipe itemidentifier="handheldstatusmonitor"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="letitdrain">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="1,2" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.letitdrain"/>
|
||||
<Description tag="talentdescription.letitdrainreminder">
|
||||
<Replace tag="[itemcount]" value="2" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.portablepump" color="gui.orange"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGivePermanentStat statidentifier="portablepump" stattype="MaxAttachableCount" value="2" />
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AddedRecipe itemidentifier="portablepump"/>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="quickfixer">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="5,2" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.quickfixer">
|
||||
<Replace tag="[amount]" value="20" color="gui.green"/>
|
||||
<Replace tag="[duration]" value="10" color="gui.green"/>
|
||||
</Description>
|
||||
<Description tag="talentdescription.repairmechanicaldevicestwiceasfast"/>
|
||||
<AbilityGroupEffect abilityeffecttype="None">
|
||||
<Abilities>
|
||||
<CharacterAbilityGiveStat stattype="MechanicalRepairSpeed" value="1"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupEffect abilityeffecttype="OnRepairComplete">
|
||||
<Conditions>
|
||||
<AbilityConditionItem tags="fabricator,door,engine,oxygengenerator,pump,turretammosource,deconstructor,medicalfabricator,ductblock"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityApplyStatusEffects>
|
||||
<StatusEffects>
|
||||
<StatusEffect type="OnAbility" target="Character" disabledeltatime="true">
|
||||
<Affliction identifier="quickfixer" amount="10.0"/>
|
||||
</StatusEffect>
|
||||
</StatusEffects>
|
||||
</CharacterAbilityApplyStatusEffects>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="scrapsavant">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="6,3" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.doublescrapoutput" />
|
||||
<Description tag="talentdescription.findadditionalscrap">
|
||||
<Replace tag="[probability]" value="20" color="gui.green"/>
|
||||
</Description>
|
||||
<AbilityGroupEffect abilityeffecttype="OnItemDeconstructedMaterial">
|
||||
<Conditions>
|
||||
<AbilityConditionItem tags="scrap"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilityModifyValue multiplyvalue="2"/>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
<AbilityGroupEffect abilityeffecttype="OnOpenItemContainer">
|
||||
<Conditions>
|
||||
<AbilityConditionItemInSubmarine submarinetype="Wreck"/>
|
||||
<AbilityConditionItem tags="container"/>
|
||||
</Conditions>
|
||||
<Abilities>
|
||||
<CharacterAbilitySpawnItemsToContainer randomchance="0.2" oncepercontainer="true">
|
||||
<StatusEffects>
|
||||
<StatusEffect type="OnAbility" target="UseTarget" >
|
||||
<SpawnItem identifiers="scrap" spawnposition="ThisInventory" spawnifcantbecontained="false" />
|
||||
</StatusEffect>
|
||||
</StatusEffects>
|
||||
</CharacterAbilitySpawnItemsToContainer>
|
||||
</Abilities>
|
||||
</AbilityGroupEffect>
|
||||
</Talent>
|
||||
|
||||
<Talent identifier="safetyfirst">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="4,2" sheetelementsize="128,128"/>
|
||||
<Description tag="talentdescription.unlockrecipe">
|
||||
<Replace tag="[itemname]" value="entityname.safetyharness" color="gui.orange"/>
|
||||
</Description>
|
||||
<AddedRecipe itemidentifier="safetyharness"/>
|
||||
</Talent>
|
||||
|
||||
</Talents>
|
||||
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']
|
||||
10
glob_test.go
10
glob_test.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -76,9 +77,14 @@ func TestGlobExpansion(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
files, err := expandFilePatterns(tc.patterns)
|
||||
// Convert string patterns to map[string]struct{} for ExpandGLobs
|
||||
patternMap := make(map[string]struct{})
|
||||
for _, pattern := range tc.patterns {
|
||||
patternMap[pattern] = struct{}{}
|
||||
}
|
||||
files, err := utils.ExpandGlobs(patternMap)
|
||||
if err != nil {
|
||||
t.Fatalf("expandFilePatterns failed: %v", err)
|
||||
t.Fatalf("ExpandGLobs failed: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != tc.expected {
|
||||
|
||||
60
go.mod
60
go.mod
@@ -1,39 +1,41 @@
|
||||
module modify
|
||||
module cook
|
||||
|
||||
go 1.24.1
|
||||
go 1.23.2
|
||||
|
||||
require (
|
||||
github.com/PaesslerAG/jsonpath v0.1.1
|
||||
github.com/antchfx/xmlquery v1.4.4
|
||||
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/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/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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PaesslerAG/gval v1.0.0 // indirect
|
||||
github.com/antchfx/xpath v1.3.3 // indirect
|
||||
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/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/hexops/valast v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
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/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
|
||||
)
|
||||
|
||||
200
go.sum
200
go.sum
@@ -1,177 +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/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
|
||||
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
|
||||
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
|
||||
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/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
|
||||
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
|
||||
github.com/antchfx/xpath v1.3.3 h1:tmuPQa1Uye0Ym1Zn65vxPgfltWb/Lxu2jeqIGteJSRs=
|
||||
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
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-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
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/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/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/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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)
|
||||
}
|
||||
873
main.go
873
main.go
@@ -1,289 +1,672 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"cook/processor"
|
||||
"cook/utils"
|
||||
|
||||
"modify/processor"
|
||||
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
|
||||
TotalMatches int64
|
||||
TotalModifications int64
|
||||
ProcessedFiles int64
|
||||
FailedFiles int64
|
||||
ModificationsPerCommand sync.Map
|
||||
}
|
||||
|
||||
var stats GlobalStats
|
||||
var logger *log.Logger
|
||||
|
||||
var (
|
||||
jsonFlag = flag.Bool("json", false, "Process JSON files")
|
||||
xmlFlag = flag.Bool("xml", false, "Process XML files")
|
||||
gitFlag = flag.Bool("git", false, "Use git to manage files")
|
||||
resetFlag = flag.Bool("reset", false, "Reset files to their original state")
|
||||
repo *git.Repository
|
||||
worktree *git.Worktree
|
||||
stats GlobalStats = GlobalStats{
|
||||
ModificationsPerCommand: sync.Map{},
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
||||
logger = log.New(os.Stdout, "", log.Lmicroseconds|log.Lshortfile)
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd *cobra.Command
|
||||
|
||||
stats = GlobalStats{}
|
||||
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() {
|
||||
// TODO: Implement some sort of git integration
|
||||
// Maybe use go-git
|
||||
// Specify a -git flag
|
||||
// If we are operating with git then:
|
||||
// Inmitialize a repo if one doesn't exist (try to open right?)
|
||||
// For each file matched by glob first figure out if it's already tracked
|
||||
// If not tracked then track it and commit (either it alone or maybe multiple together somehow)
|
||||
// Then reset the file (to undo previous modifications)
|
||||
// THEN change the file
|
||||
// In addition add a -undo flag that will ONLY reset the files without changing them
|
||||
// Only for the ones matched by glob
|
||||
// ^ important because binary files would fuck us up
|
||||
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, " -json\n")
|
||||
fmt.Fprintf(os.Stderr, " Process JSON files\n")
|
||||
fmt.Fprintf(os.Stderr, " -xml\n")
|
||||
fmt.Fprintf(os.Stderr, " Process XML files\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, " -mode string\n")
|
||||
fmt.Fprintf(os.Stderr, " Processing mode: regex, xml, json (default \"regex\")\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, " XML mode:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -xml \"//value\" \"*1.5\" data.xml\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " JSON mode:\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -json \"$.items[*].value\" \"*1.5\" data.json\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "\nNote: v1, v2, etc. are used to refer to capture groups as numbers.\n")
|
||||
fmt.Fprintf(os.Stderr, " s1, s2, etc. are used to refer to capture groups as strings.\n")
|
||||
fmt.Fprintf(os.Stderr, " Helper functions: num(str) converts string to number, str(num) converts number to string\n")
|
||||
fmt.Fprintf(os.Stderr, " is_number(str) checks if a string is numeric\n")
|
||||
fmt.Fprintf(os.Stderr, " For XML and JSON, the captured values are exposed as 'v', which can be of any type we capture (string, number, table).\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")
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
mainLogger.Error("Command execution failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
args := flag.Args()
|
||||
if *resetFlag {
|
||||
*gitFlag = true
|
||||
}
|
||||
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")
|
||||
|
||||
if len(args) < 3 {
|
||||
log.Printf("At least %d arguments are required", 3)
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
// Get the appropriate pattern and expression based on mode
|
||||
var pattern, luaExpr string
|
||||
var filePatterns []string
|
||||
|
||||
pattern = args[0]
|
||||
luaExpr = args[1]
|
||||
filePatterns = args[2:]
|
||||
|
||||
// Prepare the Lua expression
|
||||
originalLuaExpr := luaExpr
|
||||
luaExpr = processor.BuildLuaScript(luaExpr)
|
||||
if originalLuaExpr != luaExpr {
|
||||
logger.Printf("Transformed Lua expression from %q to %q", originalLuaExpr, luaExpr)
|
||||
}
|
||||
|
||||
if *gitFlag {
|
||||
err := setupGit()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error setting up git: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Expand file patterns with glob support
|
||||
files, err := expandFilePatterns(filePatterns)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No files found matching the specified patterns\n")
|
||||
return
|
||||
}
|
||||
|
||||
if *gitFlag {
|
||||
err := cleanupGitFiles(files)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error cleaning up git files: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if *resetFlag {
|
||||
log.Printf("Files reset to their original state, nothing more to do")
|
||||
return
|
||||
}
|
||||
|
||||
// Create the processor based on mode
|
||||
var proc processor.Processor
|
||||
switch {
|
||||
case *xmlFlag:
|
||||
proc = &processor.XMLProcessor{}
|
||||
logger.Printf("Starting XML modifier with XPath %q, expression %q on %d files",
|
||||
pattern, luaExpr, len(files))
|
||||
case *jsonFlag:
|
||||
proc = &processor.JSONProcessor{}
|
||||
logger.Printf("Starting JSON modifier with JSONPath %q, expression %q on %d files",
|
||||
pattern, luaExpr, len(files))
|
||||
default:
|
||||
proc = &processor.RegexProcessor{}
|
||||
logger.Printf("Starting regex modifier with pattern %q, expression %q on %d files",
|
||||
pattern, luaExpr, len(files))
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// Process each file
|
||||
for _, file := range files {
|
||||
wg.Add(1)
|
||||
go func(file string) {
|
||||
defer wg.Done()
|
||||
logger.Printf("Processing file: %s", file)
|
||||
|
||||
// It's a bit fucked, maybe I could do better to call it from proc... But it'll do for now
|
||||
modCount, matchCount, err := processor.Process(proc, file, pattern, luaExpr)
|
||||
// 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 {
|
||||
fmt.Fprintf(os.Stderr, "Failed to process file %s: %v\n", file, err)
|
||||
stats.FailedFiles++
|
||||
} else {
|
||||
logger.Printf("Successfully processed file: %s", file)
|
||||
stats.ProcessedFiles++
|
||||
stats.TotalMatches += matchCount
|
||||
stats.TotalModifications += modCount
|
||||
mainLogger.Error("Failed to convert YAML files for pattern %s: %v", arg, err)
|
||||
continue
|
||||
}
|
||||
}(file)
|
||||
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
|
||||
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)
|
||||
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
|
||||
mainLogger.Info("Found %d unique file patterns", len(globs))
|
||||
mainLogger.Debug("Expanding glob patterns to files")
|
||||
files, err := utils.ExpandGlobs(globs)
|
||||
if err != nil {
|
||||
mainLogger.Error("Failed to expand file patterns: %v", err)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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")
|
||||
|
||||
// Then for each file run all commands associated with the file
|
||||
workers := make(chan struct{}, parallelFlag)
|
||||
wg := sync.WaitGroup{}
|
||||
mainLogger.Debug("Starting file processing with %d parallel workers", parallelFlag)
|
||||
|
||||
// Add performance tracking
|
||||
startTime := time.Now()
|
||||
|
||||
// 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 {
|
||||
mainLogger.Error("Failed to read file %q: %v", file, err)
|
||||
atomic.AddInt64(&stats.FailedFiles, 1)
|
||||
return
|
||||
}
|
||||
fileDataStr := string(fileData)
|
||||
mainLogger.Trace("File %q content: %s", file, utils.LimitString(fileDataStr, 500))
|
||||
|
||||
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 {
|
||||
mainLogger.Error("Failed to save file %q to database: %v", file, err)
|
||||
atomic.AddInt64(&stats.FailedFiles, 1)
|
||||
return
|
||||
}
|
||||
mainLogger.Debug("File %q saved to database", file)
|
||||
}
|
||||
|
||||
mainLogger.Debug("Writing file %q", file)
|
||||
err = os.WriteFile(file, []byte(fileDataStr), 0644)
|
||||
if err != nil {
|
||||
mainLogger.Error("Failed to write file %q: %v", file, err)
|
||||
atomic.AddInt64(&stats.FailedFiles, 1)
|
||||
return
|
||||
}
|
||||
mainLogger.Debug("File %q written", file)
|
||||
|
||||
// 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)
|
||||
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...?
|
||||
|
||||
// Print summary
|
||||
if stats.TotalModifications == 0 {
|
||||
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 {
|
||||
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 setupGit() error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %w", err)
|
||||
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
|
||||
}
|
||||
logger.Printf("Current working directory obtained: %s", cwd)
|
||||
|
||||
logger.Printf("Attempting to open git repository at %s", cwd)
|
||||
repo, err = git.PlainOpen(cwd)
|
||||
if err != nil {
|
||||
logger.Printf("No existing git repository found at %s, attempting to initialize a new git repository.", cwd)
|
||||
repo, err = git.PlainInit(cwd, false)
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
handleSpecialArgsLogger.Info("Resetting all files to their original state from database")
|
||||
err := utils.ResetAllFiles(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize a new git repository at %s: %w", cwd, err)
|
||||
handleSpecialArgsLogger.Error("Failed to reset all files: %v", err)
|
||||
return true, err
|
||||
}
|
||||
logger.Printf("Successfully initialized a new git repository at %s", cwd)
|
||||
} else {
|
||||
logger.Printf("Successfully opened existing git repository at %s", cwd)
|
||||
}
|
||||
|
||||
logger.Printf("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.Printf("Successfully obtained worktree for repository at %s", cwd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func expandFilePatterns(patterns []string) ([]string, error) {
|
||||
var files []string
|
||||
filesMap := make(map[string]bool)
|
||||
|
||||
for _, pattern := range patterns {
|
||||
matches, _ := doublestar.Glob(os.DirFS("."), pattern)
|
||||
for _, m := range matches {
|
||||
if info, err := os.Stat(m); err == nil && !info.IsDir() && !filesMap[m] {
|
||||
filesMap[m], files = true, append(files, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) > 0 {
|
||||
logger.Printf("Found %d files to process", len(files))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func cleanupGitFiles(files []string) error {
|
||||
for _, file := range files {
|
||||
logger.Printf("Checking file: %s", file)
|
||||
status, err := worktree.Status()
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "Error getting worktree status: %v\n", err)
|
||||
return fmt.Errorf("error getting worktree status: %w", err)
|
||||
handleSpecialArgsLogger.Error("Failed to remove all files from database: %v", err)
|
||||
return true, err
|
||||
}
|
||||
if status.IsUntracked(file) {
|
||||
logger.Printf("Detected untracked file: %s. Attempting to add it to the git index.", file)
|
||||
_, err = worktree.Add(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error adding file to git: %v\n", err)
|
||||
return fmt.Errorf("error adding file to git: %w", 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
|
||||
}
|
||||
|
||||
filename := filepath.Base(file)
|
||||
logger.Printf("File %s added successfully. Now committing it 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 {
|
||||
fmt.Fprintf(os.Stderr, "Error committing file: %v\n", err)
|
||||
return fmt.Errorf("error committing file: %w", err)
|
||||
}
|
||||
logger.Printf("Successfully committed file: %s with message: 'Track %s'", filename, filename)
|
||||
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 {
|
||||
logger.Printf("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 {
|
||||
fmt.Fprintf(os.Stderr, "Error restoring file: %v\n", err)
|
||||
return fmt.Errorf("error restoring file: %w", err)
|
||||
}
|
||||
logger.Printf("File %s restored successfully.", file)
|
||||
regexCommands = append(regexCommands, command)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,165 +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"
|
||||
"log"
|
||||
"modify/processor/jsonpath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
"github.com/tidwall/gjson"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// JSONProcessor implements the Processor interface for JSON documents
|
||||
type JSONProcessor struct{}
|
||||
// jsonLogger is a scoped logger for the processor/json package.
|
||||
var jsonLogger = logger.Default.WithPrefix("processor/json")
|
||||
|
||||
// ProcessContent implements the Processor interface for JSONProcessor
|
||||
func (p *JSONProcessor) ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error) {
|
||||
// Parse JSON document
|
||||
// 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 {
|
||||
return content, 0, 0, fmt.Errorf("error parsing JSON: %v", err)
|
||||
processJSONLogger.Error("Failed to parse JSON content: %v", err)
|
||||
return commands, fmt.Errorf("failed to parse JSON: %v", err)
|
||||
}
|
||||
processJSONLogger.Debug("Successfully parsed JSON content")
|
||||
|
||||
// Find nodes matching the JSONPath pattern
|
||||
nodes, err := jsonpath.Get(jsonData, pattern)
|
||||
// Create Lua state
|
||||
L, err := NewLuaState()
|
||||
if err != nil {
|
||||
return content, 0, 0, fmt.Errorf("error getting nodes: %v", err)
|
||||
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)
|
||||
}
|
||||
|
||||
matchCount := len(nodes)
|
||||
if matchCount == 0 {
|
||||
return content, 0, 0, nil
|
||||
// Set the JSON data as a global variable
|
||||
L.SetGlobal("data", luaTable)
|
||||
processJSONLogger.Debug("Set JSON data as Lua global 'data'")
|
||||
|
||||
// Build and execute Lua script for JSON mode
|
||||
luaExpr := BuildJSONLuaScript(command.Lua)
|
||||
processJSONLogger.Debug("Built Lua script from expression: %q", command.Lua)
|
||||
processJSONLogger.Trace("Full Lua script: %q", utils.LimitString(luaExpr, 200))
|
||||
|
||||
if err := L.DoString(luaExpr); err != nil {
|
||||
processJSONLogger.Error("Lua script execution failed: %v\nScript: %s", err, utils.LimitString(luaExpr, 200))
|
||||
return commands, fmt.Errorf("lua script execution failed: %v", err)
|
||||
}
|
||||
processJSONLogger.Debug("Lua script executed successfully")
|
||||
|
||||
// Check if modification flag is set
|
||||
modifiedVal := L.GetGlobal("modified")
|
||||
if modifiedVal.Type() != lua.LTBool || !lua.LVAsBool(modifiedVal) {
|
||||
processJSONLogger.Debug("Skipping - no modifications indicated by Lua script")
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
modCount := 0
|
||||
for _, node := range nodes {
|
||||
log.Printf("Processing node at path: %s with value: %v", node.Path, node.Value)
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Initialize Lua
|
||||
L, err := NewLuaState()
|
||||
if err != nil {
|
||||
return content, len(nodes), 0, fmt.Errorf("error creating Lua state: %v", err)
|
||||
// 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)
|
||||
}
|
||||
defer L.Close()
|
||||
log.Println("Lua state initialized successfully.")
|
||||
}
|
||||
|
||||
err = p.ToLua(L, node.Value)
|
||||
if err != nil {
|
||||
return content, len(nodes), 0, fmt.Errorf("error converting to Lua: %v", err)
|
||||
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)
|
||||
}
|
||||
log.Printf("Converted node value to Lua: %v", node.Value)
|
||||
|
||||
originalScript := luaExpr
|
||||
fullScript := BuildLuaScript(luaExpr)
|
||||
log.Printf("Original script: %q, Full script: %q", originalScript, fullScript)
|
||||
|
||||
// Execute Lua script
|
||||
log.Printf("Executing Lua script: %q", fullScript)
|
||||
if err := L.DoString(fullScript); err != nil {
|
||||
return content, len(nodes), 0, fmt.Errorf("error executing Lua %q: %v", fullScript, err)
|
||||
}
|
||||
log.Println("Lua script executed successfully.")
|
||||
|
||||
// Get modified value
|
||||
result, err := p.FromLua(L)
|
||||
if err != nil {
|
||||
return content, len(nodes), 0, fmt.Errorf("error getting result from Lua: %v", err)
|
||||
}
|
||||
log.Printf("Retrieved modified value from Lua: %v", result)
|
||||
|
||||
modified := false
|
||||
modified = L.GetGlobal("modified").String() == "true"
|
||||
if !modified {
|
||||
log.Printf("No changes made to node at path: %s", node.Path)
|
||||
if !parentResult.Exists() {
|
||||
jsonLogger.Debug("Parent path %s does not exist, skipping", parentPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply the modification to the JSON data
|
||||
err = p.updateJSONValue(jsonData, node.Path, result)
|
||||
// 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 content, len(nodes), 0, fmt.Errorf("error updating JSON: %v", err)
|
||||
return "null" // Fallback to null if marshaling fails
|
||||
}
|
||||
log.Printf("Updated JSON at path: %s with new value: %v", node.Path, result)
|
||||
modCount++
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// Convert the modified JSON back to a string with same formatting
|
||||
var jsonBytes []byte
|
||||
jsonBytes, err = json.MarshalIndent(jsonData, "", " ")
|
||||
if err != nil {
|
||||
return content, modCount, matchCount, fmt.Errorf("error marshalling JSON: %v", err)
|
||||
}
|
||||
return string(jsonBytes), modCount, matchCount, nil
|
||||
}
|
||||
|
||||
// updateJSONValue updates a value in the JSON structure based on its JSONPath
|
||||
func (p *JSONProcessor) updateJSONValue(jsonData interface{}, path string, newValue interface{}) error {
|
||||
// Special handling for root node
|
||||
if path == "$" {
|
||||
// For the root node, we'll copy the value to the jsonData reference
|
||||
// This is a special case since we can't directly replace the interface{} variable
|
||||
// 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)
|
||||
}
|
||||
|
||||
// We need to handle different types of root elements
|
||||
switch rootValue := newValue.(type) {
|
||||
case map[string]interface{}:
|
||||
// For objects, we need to copy over all keys
|
||||
rootMap, ok := jsonData.(map[string]interface{})
|
||||
if !ok {
|
||||
// If the original wasn't a map, completely replace it with the new map
|
||||
// This is handled by the jsonpath.Set function
|
||||
return jsonpath.Set(jsonData, path, newValue)
|
||||
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
|
||||
}
|
||||
|
||||
// Clear the original map
|
||||
for k := range rootMap {
|
||||
delete(rootMap, k)
|
||||
}
|
||||
} 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
|
||||
|
||||
// Copy all keys from the new map
|
||||
for k, v := range rootValue {
|
||||
rootMap[k] = v
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
case []interface{}:
|
||||
// For arrays, we need to handle similarly
|
||||
rootArray, ok := jsonData.([]interface{})
|
||||
if !ok {
|
||||
// If the original wasn't an array, use jsonpath.Set
|
||||
return jsonpath.Set(jsonData, path, newValue)
|
||||
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
|
||||
|
||||
// Clear and recreate the array
|
||||
*&rootArray = rootValue
|
||||
return nil
|
||||
return changes
|
||||
}
|
||||
|
||||
default:
|
||||
// For other types, use jsonpath.Set
|
||||
return jsonpath.Set(jsonData, path, newValue)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-root paths, use the regular Set method
|
||||
err := jsonpath.Set(jsonData, path, newValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update JSON value at path '%s': %w", path, err)
|
||||
}
|
||||
return nil
|
||||
return removedIndices
|
||||
}
|
||||
|
||||
// ToLua converts JSON values to Lua variables
|
||||
func (p *JSONProcessor) ToLua(L *lua.LState, data interface{}) error {
|
||||
table, err := ToLua(L, data)
|
||||
if err != nil {
|
||||
return err
|
||||
// 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
|
||||
}
|
||||
L.SetGlobal("v", table)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromLua retrieves values from Lua
|
||||
func (p *JSONProcessor) FromLua(L *lua.LState) (interface{}, error) {
|
||||
luaValue := L.GetGlobal("v")
|
||||
return FromLua(L, luaValue)
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,490 +0,0 @@
|
||||
package jsonpath
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// JSONStep represents a single step in a JSONPath query
|
||||
type JSONStep struct {
|
||||
Type StepType
|
||||
Key string // For Child/RecursiveDescent
|
||||
Index int // For Index (use -1 for wildcard "*")
|
||||
}
|
||||
|
||||
// JSONNode represents a value in the JSON data with its path
|
||||
type JSONNode struct {
|
||||
Value interface{} // The value found at the path
|
||||
Path string // The exact JSONPath where the value was found
|
||||
}
|
||||
|
||||
// StepType defines the types of steps in a JSONPath
|
||||
type StepType int
|
||||
|
||||
const (
|
||||
RootStep StepType = iota // $ - The root element
|
||||
ChildStep // .key - Direct child access
|
||||
RecursiveDescentStep // ..key - Recursive search for key
|
||||
WildcardStep // .* - All children of an object
|
||||
IndexStep // [n] - Array index access (or [*] for all elements)
|
||||
)
|
||||
|
||||
// TraversalMode determines how the traversal behaves
|
||||
type TraversalMode int
|
||||
|
||||
const (
|
||||
CollectMode TraversalMode = iota // Just collect matched nodes
|
||||
ModifyFirstMode // Modify first matching node
|
||||
ModifyAllMode // Modify all matching nodes
|
||||
)
|
||||
|
||||
// ParseJSONPath parses a JSONPath string into a sequence of steps
|
||||
func ParseJSONPath(path string) ([]JSONStep, error) {
|
||||
if len(path) == 0 || path[0] != '$' {
|
||||
return nil, fmt.Errorf("path must start with $; received: %q", path)
|
||||
}
|
||||
|
||||
steps := []JSONStep{}
|
||||
i := 0
|
||||
|
||||
for i < len(path) {
|
||||
switch path[i] {
|
||||
case '$':
|
||||
steps = append(steps, JSONStep{Type: RootStep})
|
||||
i++
|
||||
case '.':
|
||||
i++
|
||||
if i < len(path) && path[i] == '.' {
|
||||
// Recursive descent
|
||||
i++
|
||||
key, nextPos := readKey(path, i)
|
||||
steps = append(steps, JSONStep{Type: RecursiveDescentStep, Key: key})
|
||||
i = nextPos
|
||||
} else {
|
||||
// Child step or wildcard
|
||||
key, nextPos := readKey(path, i)
|
||||
if key == "*" {
|
||||
steps = append(steps, JSONStep{Type: WildcardStep})
|
||||
} else {
|
||||
steps = append(steps, JSONStep{Type: ChildStep, Key: key})
|
||||
}
|
||||
i = nextPos
|
||||
}
|
||||
case '[':
|
||||
// Index step
|
||||
i++
|
||||
indexStr, nextPos := readIndex(path, i)
|
||||
if indexStr == "*" {
|
||||
steps = append(steps, JSONStep{Type: IndexStep, Index: -1})
|
||||
} else {
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid index: %s; error: %w", indexStr, err)
|
||||
}
|
||||
steps = append(steps, JSONStep{Type: IndexStep, Index: index})
|
||||
}
|
||||
i = nextPos + 1 // Skip closing ]
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected character: %c at position %d; path: %q", path[i], i, path)
|
||||
}
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// readKey extracts a key name from the path
|
||||
func readKey(path string, start int) (string, int) {
|
||||
i := start
|
||||
for ; i < len(path); i++ {
|
||||
if path[i] == '.' || path[i] == '[' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return path[start:i], i
|
||||
}
|
||||
|
||||
// readIndex extracts an array index or wildcard from the path
|
||||
func readIndex(path string, start int) (string, int) {
|
||||
i := start
|
||||
for ; i < len(path); i++ {
|
||||
if path[i] == ']' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return path[start:i], i
|
||||
}
|
||||
|
||||
// Get retrieves values with their paths from data at the specified JSONPath
|
||||
// Each returned JSONNode contains both the value and its exact path in the data structure
|
||||
func Get(data interface{}, path string) ([]JSONNode, error) {
|
||||
steps, err := ParseJSONPath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSONPath %q: %w", path, err)
|
||||
}
|
||||
|
||||
results := []JSONNode{}
|
||||
err = traverseWithPaths(data, steps, &results, "$")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to traverse JSONPath %q: %w", path, err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Set updates the value at the specified JSONPath in the original data structure.
|
||||
// It only modifies the first matching node.
|
||||
func Set(data interface{}, path string, value interface{}) error {
|
||||
steps, err := ParseJSONPath(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse JSONPath %q: %w", path, err)
|
||||
}
|
||||
|
||||
success := false
|
||||
err = setWithPath(data, steps, &success, value, "$", ModifyFirstMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAll updates all matching values at the specified JSONPath.
|
||||
func SetAll(data interface{}, path string, value interface{}) error {
|
||||
steps, err := ParseJSONPath(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse JSONPath %q: %w", path, err)
|
||||
}
|
||||
|
||||
success := false
|
||||
err = setWithPath(data, steps, &success, value, "$", ModifyAllMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setWithPath modifies values while tracking paths
|
||||
func setWithPath(node interface{}, steps []JSONStep, success *bool, value interface{}, currentPath string, mode TraversalMode) error {
|
||||
if node == nil || *success && mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip root step
|
||||
actualSteps := steps
|
||||
if len(steps) > 0 && steps[0].Type == RootStep {
|
||||
actualSteps = steps[1:]
|
||||
}
|
||||
|
||||
// If we have no steps left, we're setting the root value
|
||||
if len(actualSteps) == 0 {
|
||||
// For the root node, we need to handle it differently depending on what's passed in
|
||||
// since we can't directly replace the interface{} variable
|
||||
|
||||
// We'll signal success and let the JSONProcessor handle updating the root
|
||||
*success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process the first step
|
||||
step := actualSteps[0]
|
||||
remainingSteps := actualSteps[1:]
|
||||
isLastStep := len(remainingSteps) == 0
|
||||
|
||||
switch step.Type {
|
||||
case ChildStep:
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("node at path %q is not a map; actual type: %T", currentPath, node)
|
||||
}
|
||||
|
||||
childPath := currentPath + "." + step.Key
|
||||
|
||||
if isLastStep {
|
||||
// We've reached the target, set the value
|
||||
m[step.Key] = value
|
||||
*success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create intermediate nodes if necessary
|
||||
child, exists := m[step.Key]
|
||||
if !exists {
|
||||
// Create missing intermediate node
|
||||
if len(remainingSteps) > 0 && remainingSteps[0].Type == IndexStep {
|
||||
child = []interface{}{}
|
||||
} else {
|
||||
child = map[string]interface{}{}
|
||||
}
|
||||
m[step.Key] = child
|
||||
}
|
||||
|
||||
err := setWithPath(child, remainingSteps, success, value, childPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
|
||||
case IndexStep:
|
||||
arr, ok := node.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("node at path %q is not an array; actual type: %T", currentPath, node)
|
||||
}
|
||||
|
||||
// Handle wildcard index
|
||||
if step.Index == -1 {
|
||||
for i, item := range arr {
|
||||
itemPath := fmt.Sprintf("%s[%d]", currentPath, i)
|
||||
if isLastStep {
|
||||
arr[i] = value
|
||||
*success = true
|
||||
if mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
err := setWithPath(item, remainingSteps, success, value, itemPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", itemPath, err)
|
||||
}
|
||||
if *success && mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle specific index
|
||||
if step.Index >= 0 && step.Index < len(arr) {
|
||||
item := arr[step.Index]
|
||||
itemPath := fmt.Sprintf("%s[%d]", currentPath, step.Index)
|
||||
if isLastStep {
|
||||
arr[step.Index] = value
|
||||
*success = true
|
||||
} else {
|
||||
err := setWithPath(item, remainingSteps, success, value, itemPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", itemPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case RecursiveDescentStep:
|
||||
// For recursive descent, first check direct match at this level
|
||||
if m, ok := node.(map[string]interface{}); ok && step.Key != "*" {
|
||||
if val, exists := m[step.Key]; exists {
|
||||
directPath := currentPath + "." + step.Key
|
||||
if isLastStep {
|
||||
m[step.Key] = value
|
||||
*success = true
|
||||
if mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
err := setWithPath(val, remainingSteps, success, value, directPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", directPath, err)
|
||||
}
|
||||
if *success && mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then continue recursion to all children
|
||||
switch n := node.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range n {
|
||||
childPath := currentPath + "." + k
|
||||
// Skip keys we've already processed directly
|
||||
if step.Key != "*" && k == step.Key {
|
||||
continue
|
||||
}
|
||||
err := setWithPath(v, steps, success, value, childPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
if *success && mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for i, v := range n {
|
||||
childPath := fmt.Sprintf("%s[%d]", currentPath, i)
|
||||
err := setWithPath(v, steps, success, value, childPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
if *success && mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case WildcardStep:
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("node at path %q is not a map; actual type: %T", currentPath, node)
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
childPath := currentPath + "." + k
|
||||
if isLastStep {
|
||||
m[k] = value
|
||||
*success = true
|
||||
if mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
err := setWithPath(v, remainingSteps, success, value, childPath, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set value at JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
if *success && mode == ModifyFirstMode {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// traverseWithPaths tracks both nodes and their paths during traversal
|
||||
func traverseWithPaths(node interface{}, steps []JSONStep, results *[]JSONNode, currentPath string) error {
|
||||
if len(steps) == 0 || node == nil {
|
||||
return fmt.Errorf("cannot traverse with empty steps or nil node; steps length: %d, node: %v", len(steps), node)
|
||||
}
|
||||
|
||||
// Skip root step
|
||||
actualSteps := steps
|
||||
if steps[0].Type == RootStep {
|
||||
if len(steps) == 1 {
|
||||
*results = append(*results, JSONNode{Value: node, Path: currentPath})
|
||||
return nil
|
||||
}
|
||||
actualSteps = steps[1:]
|
||||
}
|
||||
|
||||
// Process the first step
|
||||
step := actualSteps[0]
|
||||
remainingSteps := actualSteps[1:]
|
||||
isLastStep := len(remainingSteps) == 0
|
||||
|
||||
switch step.Type {
|
||||
case ChildStep:
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("node is not a map; actual type: %T", node)
|
||||
}
|
||||
|
||||
child, exists := m[step.Key]
|
||||
if !exists {
|
||||
return fmt.Errorf("key not found: %s in node at path: %s", step.Key, currentPath)
|
||||
}
|
||||
|
||||
childPath := currentPath + "." + step.Key
|
||||
if isLastStep {
|
||||
*results = append(*results, JSONNode{Value: child, Path: childPath})
|
||||
} else {
|
||||
err := traverseWithPaths(child, remainingSteps, results, childPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
case IndexStep:
|
||||
arr, ok := node.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("node is not an array; actual type: %T", node)
|
||||
}
|
||||
|
||||
// Handle wildcard index
|
||||
if step.Index == -1 {
|
||||
for i, item := range arr {
|
||||
itemPath := fmt.Sprintf("%s[%d]", currentPath, i)
|
||||
if isLastStep {
|
||||
*results = append(*results, JSONNode{Value: item, Path: itemPath})
|
||||
} else {
|
||||
err := traverseWithPaths(item, remainingSteps, results, itemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", itemPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle specific index
|
||||
if step.Index >= 0 && step.Index < len(arr) {
|
||||
item := arr[step.Index]
|
||||
itemPath := fmt.Sprintf("%s[%d]", currentPath, step.Index)
|
||||
if isLastStep {
|
||||
*results = append(*results, JSONNode{Value: item, Path: itemPath})
|
||||
} else {
|
||||
err := traverseWithPaths(item, remainingSteps, results, itemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", itemPath, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("index %d out of bounds for array at path: %s", step.Index, currentPath)
|
||||
}
|
||||
|
||||
case RecursiveDescentStep:
|
||||
// For recursive descent, first check direct match at this level
|
||||
if m, ok := node.(map[string]interface{}); ok && step.Key != "*" {
|
||||
if val, exists := m[step.Key]; exists {
|
||||
directPath := currentPath + "." + step.Key
|
||||
if isLastStep {
|
||||
*results = append(*results, JSONNode{Value: val, Path: directPath})
|
||||
} else {
|
||||
err := traverseWithPaths(val, remainingSteps, results, directPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", directPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For wildcard, collect this node
|
||||
if step.Key == "*" && isLastStep {
|
||||
*results = append(*results, JSONNode{Value: node, Path: currentPath})
|
||||
}
|
||||
|
||||
// Then continue recursion to all children
|
||||
switch n := node.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range n {
|
||||
childPath := currentPath + "." + k
|
||||
err := traverseWithPaths(v, steps, results, childPath) // Use the same steps
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for i, v := range n {
|
||||
childPath := fmt.Sprintf("%s[%d]", currentPath, i)
|
||||
err := traverseWithPaths(v, steps, results, childPath) // Use the same steps
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case WildcardStep:
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("node is not a map; actual type: %T", node)
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
childPath := currentPath + "." + k
|
||||
if isLastStep {
|
||||
*results = append(*results, JSONNode{Value: v, Path: childPath})
|
||||
} else {
|
||||
err := traverseWithPaths(v, remainingSteps, results, childPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse JSONPath %q: %w", childPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
package jsonpath
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetWithPathsBasic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
path string
|
||||
expected []JSONNode
|
||||
error bool
|
||||
}{
|
||||
{
|
||||
name: "simple property",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
},
|
||||
path: "$.name",
|
||||
expected: []JSONNode{
|
||||
{Value: "John", Path: "$.name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested property",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
},
|
||||
},
|
||||
path: "$.user.name",
|
||||
expected: []JSONNode{
|
||||
{Value: "John", Path: "$.user.name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "array access",
|
||||
data: map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"name": "John", "age": 30},
|
||||
map[string]interface{}{"name": "Jane", "age": 25},
|
||||
},
|
||||
},
|
||||
path: "$.users[1].name",
|
||||
expected: []JSONNode{
|
||||
{Value: "Jane", Path: "$.users[1].name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard",
|
||||
data: map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"name": "John", "age": 30},
|
||||
map[string]interface{}{"name": "Jane", "age": 25},
|
||||
},
|
||||
},
|
||||
path: "$.users[*].name",
|
||||
expected: []JSONNode{
|
||||
{Value: "John", Path: "$.users[0].name"},
|
||||
{Value: "Jane", Path: "$.users[1].name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recursive descent",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"profile": map[string]interface{}{
|
||||
"email": "john@example.com",
|
||||
},
|
||||
},
|
||||
"admin": map[string]interface{}{
|
||||
"email": "admin@example.com",
|
||||
},
|
||||
},
|
||||
path: "$..email",
|
||||
expected: []JSONNode{
|
||||
{Value: "john@example.com", Path: "$.user.profile.email"},
|
||||
{Value: "admin@example.com", Path: "$.admin.email"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nonexistent path",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
},
|
||||
path: "$.user.email",
|
||||
expected: []JSONNode{},
|
||||
error: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Get(tt.data, tt.path)
|
||||
if err != nil {
|
||||
if !tt.error {
|
||||
t.Errorf("GetWithPaths() returned error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For nonexistent path, we expect empty slice
|
||||
if tt.name == "nonexistent path" {
|
||||
if len(result) > 0 {
|
||||
t.Errorf("GetWithPaths() returned %v, expected empty result", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if lengths match
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("GetWithPaths() returned %d items, expected %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
// For wildcard results, we need to check containment rather than exact order
|
||||
if tt.name == "wildcard" || tt.name == "recursive descent" {
|
||||
// For each expected item, check if it exists in the results by both value and path
|
||||
for _, expected := range tt.expected {
|
||||
found := false
|
||||
for _, r := range result {
|
||||
if reflect.DeepEqual(r.Value, expected.Value) && r.Path == expected.Path {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetWithPaths() missing expected value: %v with path: %s", expected.Value, expected.Path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Otherwise check exact equality of both values and paths
|
||||
for i, expected := range tt.expected {
|
||||
if !reflect.DeepEqual(result[i].Value, expected.Value) {
|
||||
t.Errorf("GetWithPaths() value at [%d] = %v, expected %v", i, result[i].Value, expected.Value)
|
||||
}
|
||||
if result[i].Path != expected.Path {
|
||||
t.Errorf("GetWithPaths() path at [%d] = %s, expected %s", i, result[i].Path, expected.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
t.Run("simple property", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
}
|
||||
err := Set(data, "$.name", "Jane")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if data["name"] != "Jane" {
|
||||
t.Errorf("Set() failed: expected name to be 'Jane', got %v", data["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested property", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
},
|
||||
}
|
||||
err := Set(data, "$.user.name", "Jane")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := data["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User is not a map")
|
||||
}
|
||||
if user["name"] != "Jane" {
|
||||
t.Errorf("Set() failed: expected user.name to be 'Jane', got %v", user["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array element", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"name": "John", "age": 30},
|
||||
map[string]interface{}{"name": "Jane", "age": 25},
|
||||
},
|
||||
}
|
||||
err := Set(data, "$.users[0].name", "Bob")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
users, ok := data["users"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Users is not a slice")
|
||||
}
|
||||
user0, ok := users[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User is not a map")
|
||||
}
|
||||
if user0["name"] != "Bob" {
|
||||
t.Errorf("Set() failed: expected users[0].name to be 'Bob', got %v", user0["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("complex value", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"profile": map[string]interface{}{
|
||||
"email": "john@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newProfile := map[string]interface{}{
|
||||
"email": "john.doe@example.com",
|
||||
"phone": "123-456-7890",
|
||||
}
|
||||
|
||||
err := Set(data, "$.user.profile", newProfile)
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
userMap, ok := data["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User is not a map")
|
||||
}
|
||||
|
||||
profile, ok := userMap["profile"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Profile is not a map")
|
||||
}
|
||||
|
||||
if profile["email"] != "john.doe@example.com" || profile["phone"] != "123-456-7890" {
|
||||
t.Errorf("Set() failed: expected profile to be updated with new values")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create new property", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
}
|
||||
|
||||
err := Set(data, "$.user.email", "john@example.com")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
userMap, ok := data["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User is not a map")
|
||||
}
|
||||
|
||||
if email, exists := userMap["email"]; !exists || email != "john@example.com" {
|
||||
t.Errorf("Set() failed: expected user.email to be 'john@example.com', got %v", userMap["email"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create nested properties", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
}
|
||||
|
||||
err := Set(data, "$.user.contact.email", "john@example.com")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
userMap, ok := data["user"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User is not a map")
|
||||
}
|
||||
|
||||
contact, ok := userMap["contact"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Contact is not a map")
|
||||
}
|
||||
|
||||
if email, exists := contact["email"]; !exists || email != "john@example.com" {
|
||||
t.Errorf("Set() failed: expected user.contact.email to be 'john@example.com', got %v", contact["email"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array and element", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
},
|
||||
}
|
||||
|
||||
// This should create an empty addresses array, but won't be able to set index 0
|
||||
// since the array is empty
|
||||
err := Set(data, "$.user.addresses[0].street", "123 Main St")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple targets (should only update first)", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"active": true},
|
||||
map[string]interface{}{"active": true},
|
||||
},
|
||||
}
|
||||
|
||||
err := Set(data, "$.users[*].active", false)
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
users, ok := data["users"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Users is not a slice")
|
||||
}
|
||||
|
||||
user0, ok := users[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User0 is not a map")
|
||||
}
|
||||
|
||||
user1, ok := users[1].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User1 is not a map")
|
||||
}
|
||||
|
||||
// Only the first one should be changed
|
||||
if active, exists := user0["active"]; !exists || active != false {
|
||||
t.Errorf("Set() failed: expected users[0].active to be false, got %v", user0["active"])
|
||||
}
|
||||
|
||||
// The second one should remain unchanged
|
||||
if active, exists := user1["active"]; !exists || active != true {
|
||||
t.Errorf("Set() incorrectly modified users[1].active: expected true, got %v", user1["active"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("setting on root should not fail (anymore)", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "John",
|
||||
}
|
||||
|
||||
err := Set(data, "$", "Jane")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Data should be unchanged
|
||||
if data["name"] != "John" {
|
||||
t.Errorf("Data was modified when setting on root")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetAll(t *testing.T) {
|
||||
t.Run("simple property", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
}
|
||||
err := SetAll(data, "$.name", "Jane")
|
||||
if err != nil {
|
||||
t.Errorf("SetAll() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if data["name"] != "Jane" {
|
||||
t.Errorf("SetAll() failed: expected name to be 'Jane', got %v", data["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all array elements", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"active": true},
|
||||
map[string]interface{}{"active": true},
|
||||
},
|
||||
}
|
||||
|
||||
err := SetAll(data, "$.users[*].active", false)
|
||||
if err != nil {
|
||||
t.Errorf("SetAll() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
users, ok := data["users"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Users is not a slice")
|
||||
}
|
||||
|
||||
// Both elements should be updated
|
||||
for i, user := range users {
|
||||
userMap, ok := user.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("User%d is not a map", i)
|
||||
}
|
||||
|
||||
if active, exists := userMap["active"]; !exists || active != false {
|
||||
t.Errorf("SetAll() failed: expected users[%d].active to be false, got %v", i, userMap["active"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recursive descent", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"profile": map[string]interface{}{
|
||||
"active": true,
|
||||
},
|
||||
},
|
||||
"admin": map[string]interface{}{
|
||||
"profile": map[string]interface{}{
|
||||
"active": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := SetAll(data, "$..active", false)
|
||||
if err != nil {
|
||||
t.Errorf("SetAll() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check user profile
|
||||
userProfile, ok := data["user"].(map[string]interface{})["profile"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Failed to access user.profile")
|
||||
}
|
||||
if active, exists := userProfile["active"]; !exists || active != false {
|
||||
t.Errorf("SetAll() didn't update user.profile.active, got: %v", active)
|
||||
}
|
||||
|
||||
// Check admin profile
|
||||
adminProfile, ok := data["admin"].(map[string]interface{})["profile"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Failed to access admin.profile")
|
||||
}
|
||||
if active, exists := adminProfile["active"]; !exists || active != false {
|
||||
t.Errorf("SetAll() didn't update admin.profile.active, got: %v", active)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetWithPathsExtended(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
path string
|
||||
expected []JSONNode
|
||||
}{
|
||||
{
|
||||
name: "simple property",
|
||||
data: map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
},
|
||||
path: "$.name",
|
||||
expected: []JSONNode{
|
||||
{Value: "John", Path: "$.name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested property",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
},
|
||||
},
|
||||
path: "$.user.name",
|
||||
expected: []JSONNode{
|
||||
{Value: "John", Path: "$.user.name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "array access",
|
||||
data: map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"name": "John", "age": 30},
|
||||
map[string]interface{}{"name": "Jane", "age": 25},
|
||||
},
|
||||
},
|
||||
path: "$.users[1].name",
|
||||
expected: []JSONNode{
|
||||
{Value: "Jane", Path: "$.users[1].name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard",
|
||||
data: map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"name": "John", "age": 30},
|
||||
map[string]interface{}{"name": "Jane", "age": 25},
|
||||
},
|
||||
},
|
||||
path: "$.users[*].name",
|
||||
expected: []JSONNode{
|
||||
{Value: "John", Path: "$.users[0].name"},
|
||||
{Value: "Jane", Path: "$.users[1].name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recursive descent",
|
||||
data: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "John",
|
||||
"profile": map[string]interface{}{
|
||||
"email": "john@example.com",
|
||||
},
|
||||
},
|
||||
"admin": map[string]interface{}{
|
||||
"email": "admin@example.com",
|
||||
},
|
||||
},
|
||||
path: "$..email",
|
||||
expected: []JSONNode{
|
||||
{Value: "john@example.com", Path: "$.user.profile.email"},
|
||||
{Value: "admin@example.com", Path: "$.admin.email"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Get(tt.data, tt.path)
|
||||
if err != nil {
|
||||
t.Errorf("GetWithPaths() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if lengths match
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("GetWithPaths() returned %d items, expected %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
// For each expected item, find its match in the results and verify both value and path
|
||||
for _, expected := range tt.expected {
|
||||
found := false
|
||||
for _, r := range result {
|
||||
// Check if value matches
|
||||
if reflect.DeepEqual(r.Value, expected.Value) {
|
||||
found = true
|
||||
// Check if path matches
|
||||
if r.Path != expected.Path {
|
||||
t.Errorf("Path mismatch for value %v: got %s, expected %s", r.Value, r.Path, expected.Path)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected node with value %v and path %s not found in results", expected.Value, expected.Path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
package jsonpath
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testData = map[string]interface{}{
|
||||
"store": map[string]interface{}{
|
||||
"book": []interface{}{
|
||||
map[string]interface{}{
|
||||
"title": "The Fellowship of the Ring",
|
||||
"price": 22.99,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title": "The Two Towers",
|
||||
"price": 23.45,
|
||||
},
|
||||
},
|
||||
"bicycle": map[string]interface{}{
|
||||
"color": "red",
|
||||
"price": 199.95,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
steps []JSONStep
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
path: "$.store.bicycle.color",
|
||||
steps: []JSONStep{
|
||||
{Type: RootStep},
|
||||
{Type: ChildStep, Key: "store"},
|
||||
{Type: ChildStep, Key: "bicycle"},
|
||||
{Type: ChildStep, Key: "color"},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "$..price",
|
||||
steps: []JSONStep{
|
||||
{Type: RootStep},
|
||||
{Type: RecursiveDescentStep, Key: "price"},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "$.store.book[*].title",
|
||||
steps: []JSONStep{
|
||||
{Type: RootStep},
|
||||
{Type: ChildStep, Key: "store"},
|
||||
{Type: ChildStep, Key: "book"},
|
||||
{Type: IndexStep, Index: -1}, // Wildcard
|
||||
{Type: ChildStep, Key: "title"},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "$.store.book[0]",
|
||||
steps: []JSONStep{
|
||||
{Type: RootStep},
|
||||
{Type: ChildStep, Key: "store"},
|
||||
{Type: ChildStep, Key: "book"},
|
||||
{Type: IndexStep, Index: 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "invalid.path",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
path: "$.store.book[abc]",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
steps, err := ParseJSONPath(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ParseJSONPath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && !reflect.DeepEqual(steps, tt.steps) {
|
||||
t.Errorf("ParseJSONPath() steps = %+v, want %+v", steps, tt.steps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected []JSONNode
|
||||
error bool
|
||||
}{
|
||||
{
|
||||
name: "simple_property_access",
|
||||
path: "$.store.bicycle.color",
|
||||
expected: []JSONNode{
|
||||
{Value: "red", Path: "$.store.bicycle.color"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "array_index_access",
|
||||
path: "$.store.book[0].title",
|
||||
expected: []JSONNode{
|
||||
{Value: "The Fellowship of the Ring", Path: "$.store.book[0].title"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard_array_access",
|
||||
path: "$.store.book[*].title",
|
||||
expected: []JSONNode{
|
||||
{Value: "The Fellowship of the Ring", Path: "$.store.book[0].title"},
|
||||
{Value: "The Two Towers", Path: "$.store.book[1].title"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recursive_price_search",
|
||||
path: "$..price",
|
||||
expected: []JSONNode{
|
||||
{Value: 22.99, Path: "$.store.book[0].price"},
|
||||
{Value: 23.45, Path: "$.store.book[1].price"},
|
||||
{Value: 199.95, Path: "$.store.bicycle.price"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard_recursive",
|
||||
path: "$..*",
|
||||
expected: []JSONNode{
|
||||
// These will be compared by value only, paths will be validated separately
|
||||
{Value: testData["store"].(map[string]interface{})["book"]},
|
||||
{Value: testData["store"].(map[string]interface{})["bicycle"]},
|
||||
{Value: testData["store"].(map[string]interface{})["book"].([]interface{})[0]},
|
||||
{Value: testData["store"].(map[string]interface{})["book"].([]interface{})[1]},
|
||||
{Value: "The Fellowship of the Ring"},
|
||||
{Value: 22.99},
|
||||
{Value: "The Two Towers"},
|
||||
{Value: 23.45},
|
||||
{Value: "red"},
|
||||
{Value: 199.95},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_index",
|
||||
path: "$.store.book[5]",
|
||||
expected: []JSONNode{},
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent_property",
|
||||
path: "$.store.nonexistent",
|
||||
expected: []JSONNode{},
|
||||
error: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Use GetWithPaths directly
|
||||
result, err := Get(testData, tt.path)
|
||||
if err != nil {
|
||||
if !tt.error {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for wildcard recursive test
|
||||
if tt.name == "wildcard_recursive" {
|
||||
// Skip length check for wildcard recursive since it might vary
|
||||
// Just verify that each expected item is in the results
|
||||
|
||||
// Validate values match and paths are filled in
|
||||
for _, e := range tt.expected {
|
||||
found := false
|
||||
for _, r := range result {
|
||||
if reflect.DeepEqual(r.Value, e.Value) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected value %v not found in results", e.Value)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("Expected %d items, got %d", len(tt.expected), len(result))
|
||||
}
|
||||
|
||||
// Validate both values and paths
|
||||
for i, e := range tt.expected {
|
||||
if i < len(result) {
|
||||
if !reflect.DeepEqual(result[i].Value, e.Value) {
|
||||
t.Errorf("Value at [%d]: got %v, expected %v", i, result[i].Value, e.Value)
|
||||
}
|
||||
if result[i].Path != e.Path {
|
||||
t.Errorf("Path at [%d]: got %s, expected %s", i, result[i].Path, e.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
t.Run("empty_data", func(t *testing.T) {
|
||||
result, err := Get(nil, "$.a.b")
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for empty data")
|
||||
return
|
||||
}
|
||||
if len(result) > 0 {
|
||||
t.Errorf("Expected empty result, got %v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty_path", func(t *testing.T) {
|
||||
_, err := ParseJSONPath("")
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty path")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric_keys", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"42": "answer",
|
||||
}
|
||||
result, err := Get(data, "$.42")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) == 0 || result[0].Value != "answer" {
|
||||
t.Errorf("Expected 'answer', got %v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetWithPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected []JSONNode
|
||||
}{
|
||||
{
|
||||
name: "simple_property_access",
|
||||
path: "$.store.bicycle.color",
|
||||
expected: []JSONNode{
|
||||
{Value: "red", Path: "$.store.bicycle.color"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "array_index_access",
|
||||
path: "$.store.book[0].title",
|
||||
expected: []JSONNode{
|
||||
{Value: "The Fellowship of the Ring", Path: "$.store.book[0].title"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard_array_access",
|
||||
path: "$.store.book[*].title",
|
||||
expected: []JSONNode{
|
||||
{Value: "The Fellowship of the Ring", Path: "$.store.book[0].title"},
|
||||
{Value: "The Two Towers", Path: "$.store.book[1].title"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recursive_price_search",
|
||||
path: "$..price",
|
||||
expected: []JSONNode{
|
||||
{Value: 22.99, Path: "$.store.book[0].price"},
|
||||
{Value: 23.45, Path: "$.store.book[1].price"},
|
||||
{Value: 199.95, Path: "$.store.bicycle.price"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Get(testData, tt.path)
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if lengths match
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("GetWithPaths() returned %d items, expected %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
// For each expected item, find its match in the results and verify both value and path
|
||||
for _, expected := range tt.expected {
|
||||
found := false
|
||||
for _, r := range result {
|
||||
// First verify the value matches
|
||||
if reflect.DeepEqual(r.Value, expected.Value) {
|
||||
found = true
|
||||
// Then verify the path matches
|
||||
if r.Path != expected.Path {
|
||||
t.Errorf("Path mismatch for value %v: got %s, expected %s", r.Value, r.Path, expected.Path)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected node with value %v and path %s not found in results", expected.Value, expected.Path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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,262 +1,186 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/antchfx/xmlquery"
|
||||
"cook/utils"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// Processor defines the interface for all file processors
|
||||
type Processor interface {
|
||||
// Process handles processing a file with the given pattern and Lua expression
|
||||
// Now implemented as a base function in processor.go
|
||||
// Process(filename string, pattern string, luaExpr string) (int, int, error)
|
||||
//go:embed luahelper.lua
|
||||
var helperScript string
|
||||
|
||||
// ProcessContent handles processing a string content directly with the given pattern and Lua expression
|
||||
// Returns the modified content, modification count, match count, and any error
|
||||
ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error)
|
||||
// processorLogger is a scoped logger for the processor package.
|
||||
var processorLogger = logger.Default.WithPrefix("processor")
|
||||
|
||||
// ToLua converts processor-specific data to Lua variables
|
||||
ToLua(L *lua.LState, data interface{}) error
|
||||
// Maybe we make this an interface again for the shits and giggles
|
||||
// We will see, it could easily be...
|
||||
|
||||
// FromLua retrieves modified data from Lua
|
||||
FromLua(L *lua.LState) (interface{}, error)
|
||||
}
|
||||
var globalVariables = map[string]interface{}{}
|
||||
|
||||
// ModificationRecord tracks a single value modification
|
||||
type ModificationRecord struct {
|
||||
File string
|
||||
OldValue string
|
||||
NewValue string
|
||||
Operation string
|
||||
Context string
|
||||
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
|
||||
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 {
|
||||
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
|
||||
newLStateLogger.Debug("Initializing Lua helper functions")
|
||||
if err := InitLuaHelpers(L); err != nil {
|
||||
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(p Processor, filename string, pattern string, luaExpr string) (int, int, error) {
|
||||
// Read file content
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("error getting current working directory: %v", err)
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(cwd, filename)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
|
||||
fileContent := string(content)
|
||||
|
||||
// Process the content
|
||||
modifiedContent, modCount, matchCount, err := p.ProcessContent(fileContent, pattern, luaExpr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// If we made modifications, save the file
|
||||
if modCount > 0 {
|
||||
err = os.WriteFile(fullPath, []byte(modifiedContent), 0644)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("error writing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return modCount, matchCount, nil
|
||||
}
|
||||
|
||||
// ToLua converts a struct or map to a Lua table recursively
|
||||
func ToLua(L *lua.LState, data interface{}) (lua.LValue, error) {
|
||||
switch v := data.(type) {
|
||||
case *xmlquery.Node:
|
||||
luaTable := L.NewTable()
|
||||
luaTable.RawSetString("text", lua.LString(v.Data))
|
||||
// Should be a map, simple key value pairs
|
||||
attr, err := ToLua(L, v.Attr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
luaTable.RawSetString("attr", attr)
|
||||
return luaTable, nil
|
||||
case map[string]interface{}:
|
||||
luaTable := L.NewTable()
|
||||
for key, value := range v {
|
||||
luaValue, err := ToLua(L, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
luaTable.RawSetString(key, luaValue)
|
||||
}
|
||||
return luaTable, nil
|
||||
case []interface{}:
|
||||
luaTable := L.NewTable()
|
||||
for i, value := range v {
|
||||
luaValue, err := ToLua(L, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
luaTable.RawSetInt(i+1, luaValue) // Lua arrays are 1-indexed
|
||||
}
|
||||
return luaTable, nil
|
||||
case string:
|
||||
return lua.LString(v), nil
|
||||
case bool:
|
||||
return lua.LBool(v), nil
|
||||
case float64:
|
||||
return lua.LNumber(v), nil
|
||||
case nil:
|
||||
return lua.LNil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported data type: %T", data)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
isLuaTableArrayLogger.Error("Error determining if table is an array: %v", err)
|
||||
return false, fmt.Errorf("error determining if table is array: %w", err)
|
||||
}
|
||||
|
||||
// Check the result of our Lua function
|
||||
isArray := L.GetGlobal("is_array")
|
||||
// LVIsFalse returns true if a given LValue is a nil or false otherwise false.
|
||||
if !lua.LVIsFalse(isArray) {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
result := !lua.LVIsFalse(isArray)
|
||||
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 {
|
||||
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
|
||||
initLuaHelpersLogger := processorLogger.WithPrefix("InitLuaHelpers")
|
||||
initLuaHelpersLogger.Debug("Loading Lua helper functions")
|
||||
|
||||
-- String to number conversion helper
|
||||
function num(str)
|
||||
return tonumber(str) or 0
|
||||
end
|
||||
|
||||
-- Number to string conversion
|
||||
function str(num)
|
||||
return tostring(num)
|
||||
end
|
||||
|
||||
-- Check if string is numeric
|
||||
function is_number(str)
|
||||
return tonumber(str) ~= nil
|
||||
end
|
||||
|
||||
function isArray(t)
|
||||
if type(t) ~= "table" then return false end
|
||||
local max = 0
|
||||
local count = 0
|
||||
for k, _ in pairs(t) do
|
||||
if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then
|
||||
return false
|
||||
end
|
||||
max = math.max(max, k)
|
||||
count = count + 1
|
||||
end
|
||||
return max == count
|
||||
end
|
||||
|
||||
modified = false
|
||||
`
|
||||
if err := L.DoString(helperScript); err != nil {
|
||||
initLuaHelpersLogger.Error("Failed to load Lua helper functions: %v", err)
|
||||
return fmt.Errorf("error loading helper functions: %v", err)
|
||||
}
|
||||
initLuaHelpersLogger.Debug("Lua helper functions loaded")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Helper utility functions
|
||||
|
||||
// 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, "/") ||
|
||||
@@ -265,28 +189,32 @@ 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
|
||||
}
|
||||
|
||||
// BuildLuaScript prepares a Lua expression from shorthand notation
|
||||
func BuildLuaScript(luaExpr string) string {
|
||||
buildLuaScriptLogger := processorLogger.WithPrefix("BuildLuaScript").WithField("inputLuaExpr", luaExpr)
|
||||
buildLuaScriptLogger.Debug("Building full Lua script from expression")
|
||||
|
||||
// Perform $var substitutions from globalVariables
|
||||
luaExpr = replaceVariables(luaExpr)
|
||||
|
||||
luaExpr = PrependLuaAssignment(luaExpr)
|
||||
|
||||
// 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
|
||||
@@ -294,36 +222,321 @@ 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) string {
|
||||
buildJSONLuaScriptLogger := processorLogger.WithPrefix("BuildJSONLuaScript").WithField("inputLuaExpr", luaExpr)
|
||||
buildJSONLuaScriptLogger.Debug("Building full Lua script for JSON mode from expression")
|
||||
|
||||
// Perform $var substitutions from globalVariables
|
||||
luaExpr = replaceVariables(luaExpr)
|
||||
|
||||
fullScript := fmt.Sprintf(`
|
||||
function run()
|
||||
%s
|
||||
end
|
||||
local res = run()
|
||||
modified = res == nil or res
|
||||
`, luaExpr)
|
||||
buildJSONLuaScriptLogger.Trace("Generated full JSON Lua script: %q", utils.LimitString(fullScript, 200))
|
||||
|
||||
return fullScript
|
||||
}
|
||||
|
||||
func replaceVariables(expr string) string {
|
||||
// $varName -> literal value
|
||||
varNameRe := regexp.MustCompile(`\$(\w+)`)
|
||||
return varNameRe.ReplaceAllStringFunc(expr, func(m string) string {
|
||||
name := varNameRe.FindStringSubmatch(m)[1]
|
||||
if v, ok := globalVariables[name]; ok {
|
||||
switch val := v.(type) {
|
||||
case int, int64, float32, float64:
|
||||
return fmt.Sprintf("%v", val)
|
||||
case bool:
|
||||
if val {
|
||||
return "true"
|
||||
} else {
|
||||
return "false"
|
||||
}
|
||||
case string:
|
||||
// Quote strings for Lua literal
|
||||
return fmt.Sprintf("%q", val)
|
||||
default:
|
||||
return fmt.Sprintf("%q", fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
func printToGo(L *lua.LState) int {
|
||||
// Get the number of arguments passed to the Lua print function
|
||||
n := L.GetTop()
|
||||
// Create a slice to hold the arguments
|
||||
args := make([]interface{}, n)
|
||||
for i := 1; i <= n; i++ {
|
||||
args[i-1] = L.Get(i) // Get the argument from Lua stack
|
||||
printToGoLogger := processorLogger.WithPrefix("printToGo")
|
||||
printToGoLogger.Debug("Lua print function called, redirecting to Go logger")
|
||||
top := L.GetTop()
|
||||
|
||||
args := make([]interface{}, top)
|
||||
for i := 1; i <= top; i++ {
|
||||
args[i-1] = L.Get(i)
|
||||
}
|
||||
// Print the arguments to Go's stdout
|
||||
log.Print("Lua: ")
|
||||
log.Println(args...)
|
||||
return 0 // No return values
|
||||
|
||||
// Format the message with proper spacing between arguments
|
||||
var parts []string
|
||||
for _, arg := range args {
|
||||
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
|
||||
}
|
||||
|
||||
// Max returns the maximum of two integers
|
||||
func Max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
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
|
||||
}
|
||||
return b
|
||||
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
|
||||
}
|
||||
|
||||
// Min returns the minimum of two integers
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
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)
|
||||
}
|
||||
return b
|
||||
|
||||
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,87 +1,19 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// RegexProcessor implements the Processor interface using regex patterns
|
||||
type RegexProcessor struct{}
|
||||
|
||||
// ToLua sets capture groups as Lua variables (v1, v2, etc. for numeric values and s1, s2, etc. for strings)
|
||||
func (p *RegexProcessor) ToLua(L *lua.LState, data interface{}) error {
|
||||
captureGroups, ok := data.([]*CaptureGroup)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected []*CaptureGroup for captures, got %T", data)
|
||||
}
|
||||
|
||||
groupindex := 0
|
||||
for _, capture := range captureGroups {
|
||||
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++
|
||||
|
||||
L.SetGlobal("s"+tempName, lua.LString(capture.Value))
|
||||
|
||||
val, err := strconv.ParseFloat(capture.Value, 64)
|
||||
if err == nil {
|
||||
L.SetGlobal("v"+tempName, lua.LNumber(val))
|
||||
}
|
||||
} else {
|
||||
val, err := strconv.ParseFloat(capture.Value, 64)
|
||||
if err == nil {
|
||||
L.SetGlobal(capture.Name, lua.LNumber(val))
|
||||
} else {
|
||||
L.SetGlobal(capture.Name, lua.LString(capture.Value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RegexProcessor) FromLua(L *lua.LState) (interface{}, error) {
|
||||
// Stub to satisfy interface
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FromLua implements the Processor interface for RegexProcessor
|
||||
func (p *RegexProcessor) FromLuaCustom(L *lua.LState, captureGroups []*CaptureGroup) ([]*CaptureGroup, error) {
|
||||
captureIndex := 0
|
||||
for _, capture := range captureGroups {
|
||||
if capture.Name == "" {
|
||||
capture.Name = fmt.Sprintf("%d", captureIndex+1)
|
||||
|
||||
vVarName := fmt.Sprintf("v%s", capture.Name)
|
||||
sVarName := fmt.Sprintf("s%s", capture.Name)
|
||||
captureIndex++
|
||||
|
||||
vLuaVal := L.GetGlobal(vVarName)
|
||||
sLuaVal := L.GetGlobal(sVarName)
|
||||
|
||||
if sLuaVal.Type() == lua.LTString {
|
||||
capture.Updated = sLuaVal.String()
|
||||
}
|
||||
// Numbers have priority
|
||||
if vLuaVal.Type() == lua.LTNumber {
|
||||
capture.Updated = vLuaVal.String()
|
||||
}
|
||||
} else {
|
||||
// Easy shit
|
||||
capture.Updated = L.GetGlobal(capture.Name).String()
|
||||
}
|
||||
}
|
||||
|
||||
return captureGroups, nil
|
||||
}
|
||||
// regexLogger is a scoped logger for the processor/regex package.
|
||||
var regexLogger = logger.Default.WithPrefix("processor/regex")
|
||||
|
||||
type CaptureGroup struct {
|
||||
Name string
|
||||
@@ -89,52 +21,87 @@ type CaptureGroup struct {
|
||||
Updated string
|
||||
Range [2]int
|
||||
}
|
||||
type ReplaceCommand struct {
|
||||
From int
|
||||
To int
|
||||
With string
|
||||
}
|
||||
|
||||
// ProcessContent applies regex replacement with Lua processing
|
||||
func (p *RegexProcessor) ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error) {
|
||||
pattern = ResolveRegexPlaceholders(pattern)
|
||||
// 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)
|
||||
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 {
|
||||
log.Printf("Error compiling pattern: %v", err)
|
||||
return "", 0, 0, fmt.Errorf("error compiling pattern: %v", err)
|
||||
processRegexLogger.Error("Error compiling pattern %q: %v", pattern, err)
|
||||
return commands, fmt.Errorf("error compiling pattern: %v", err)
|
||||
}
|
||||
log.Printf("Compiled pattern successfully: %s", pattern)
|
||||
processRegexLogger.Debug("Compiled pattern successfully in %v. Pattern: %s", time.Since(patternCompileStart), pattern)
|
||||
|
||||
previous := luaExpr
|
||||
luaExpr = BuildLuaScript(luaExpr)
|
||||
log.Printf("Changing Lua expression from: %s to: %s", previous, luaExpr)
|
||||
|
||||
// Initialize Lua environment
|
||||
modificationCount := 0
|
||||
// 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
|
||||
previousLuaExpr := command.Lua
|
||||
luaExpr := BuildLuaScript(command.Lua)
|
||||
processRegexLogger.Debug("Transformed Lua expression: %q → %q", previousLuaExpr, luaExpr)
|
||||
processRegexLogger.Trace("Full Lua script: %q", utils.LimitString(luaExpr, 200))
|
||||
|
||||
// Process all regex matches
|
||||
result := content
|
||||
matchFindStart := time.Now()
|
||||
indices := compiledPattern.FindAllStringSubmatchIndex(content, -1)
|
||||
log.Printf("Found %d matches in the content", len(indices))
|
||||
matchFindDuration := time.Since(matchFindStart)
|
||||
|
||||
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)
|
||||
processRegexLogger.Debug("Pattern complexity estimate: %d", patternComplexity)
|
||||
|
||||
if len(indices) == 0 {
|
||||
processRegexLogger.Warning("No matches found for regex: %s", pattern)
|
||||
processRegexLogger.Debug("Total regex processing time: %v", time.Since(startTime))
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// We walk backwards because we're replacing something with something else that might be longer
|
||||
// And in the case it is longer than the original all indicces past that change will be fucked up
|
||||
// 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 := len(indices) - 1; i >= 0; i-- {
|
||||
for i, matchIndices := range indices {
|
||||
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 {
|
||||
log.Printf("Error creating Lua state: %v", err)
|
||||
return "", 0, 0, fmt.Errorf("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()
|
||||
log.Printf("Lua state created successfully")
|
||||
|
||||
matchIndices := indices[i]
|
||||
log.Printf("Processing match indices: %v", matchIndices)
|
||||
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
|
||||
@@ -143,22 +110,32 @@ func (p *RegexProcessor) ProcessContent(content string, pattern string, luaExpr
|
||||
// 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]]
|
||||
log.Printf("Matched content: %s", 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 {
|
||||
log.Println("No capture groups for lua to chew on")
|
||||
matchLogger.Warning("No capture groups found for match %q and regex %q", matchPreview, pattern)
|
||||
continue
|
||||
}
|
||||
if len(groups)%2 == 1 {
|
||||
log.Println("Odd number of indices of groups, what the fuck?")
|
||||
matchLogger.Warning("Invalid number of group indices (%d), should be even: %v", len(groups), groups)
|
||||
continue
|
||||
}
|
||||
|
||||
// Count how many valid groups we have
|
||||
validGroups := 0
|
||||
for j := 0; j < len(groups); j += 2 {
|
||||
if groups[j] != -1 && groups[j+1] != -1 {
|
||||
validGroups++
|
||||
}
|
||||
}
|
||||
matchLogger.Debug("Found %d valid capture groups in match", validGroups)
|
||||
|
||||
for _, index := range groups {
|
||||
if index == -1 {
|
||||
// return "", 0, 0, fmt.Errorf("negative indices encountered: %v. This indicates that there was an issue with the match indices, possibly due to an empty match or an unexpected pattern. Please check the regex pattern and input content.", matchIndices)
|
||||
log.Printf("Negative indices encountered: %v. This indicates that there was an issue with the match indices, possibly due to an empty match or an unexpected pattern. This is not an error but it's possibly not what you want.", 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
|
||||
}
|
||||
}
|
||||
@@ -170,79 +147,153 @@ func (p *RegexProcessor) ProcessContent(content string, pattern string, luaExpr
|
||||
captureGroups := make([]*CaptureGroup, 0, len(groups)/2)
|
||||
groupNames := compiledPattern.SubexpNames()[1:]
|
||||
for i, name := range groupNames {
|
||||
// if name == "" {
|
||||
// continue
|
||||
// }
|
||||
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
|
||||
}
|
||||
|
||||
value := content[start:end]
|
||||
captureGroups = append(captureGroups, &CaptureGroup{
|
||||
Name: name,
|
||||
Value: content[start:end],
|
||||
Value: value,
|
||||
Range: [2]int{start, end},
|
||||
})
|
||||
|
||||
// Include name info in log if available
|
||||
if name != "" {
|
||||
matchLogger.Trace("Capture group '%s': %q (pos %d-%d)", name, value, start, end)
|
||||
} else {
|
||||
matchLogger.Trace("Capture group #%d: %q (pos %d-%d)", i+1, value, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Capture groups: %v", 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 := p.ToLua(L, captureGroups); err != nil {
|
||||
log.Printf("Error setting Lua variables: %v", err)
|
||||
if err := toLua(L, captureGroups); err != nil {
|
||||
matchLogger.Error("Failed to set Lua variables for capture groups: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Println("Lua variables set successfully")
|
||||
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 {
|
||||
log.Printf("Error executing Lua code %s for groups %+v: %v", luaExpr, captureGroups, err)
|
||||
matchLogger.Error("Lua script execution failed: %v\nScript: %s\nCapture Groups: %+v",
|
||||
err, utils.LimitString(luaExpr, 200), captureGroups)
|
||||
continue
|
||||
}
|
||||
log.Println("Lua code executed successfully")
|
||||
matchLogger.Debug("Lua script executed successfully")
|
||||
|
||||
// Get modifications from Lua
|
||||
captureGroups, err = p.FromLuaCustom(L, captureGroups)
|
||||
updatedCaptureGroups, err := fromLua(L, captureGroups)
|
||||
if err != nil {
|
||||
log.Printf("Error getting modifications: %v", err)
|
||||
matchLogger.Error("Failed to retrieve modifications from Lua: %v", err)
|
||||
continue
|
||||
}
|
||||
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()
|
||||
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) {
|
||||
matchLogger.Debug("Skipping match - no modifications indicated by Lua script")
|
||||
continue
|
||||
}
|
||||
|
||||
if replacement == "" {
|
||||
commands := make([]ReplaceCommand, 0, len(captureGroups))
|
||||
// Apply the modifications to the original match
|
||||
replacement = match
|
||||
for _, capture := range captureGroups {
|
||||
log.Printf("Applying modification: %s", capture.Updated)
|
||||
// Count groups that were actually modified
|
||||
modifiedGroupsCount := 0
|
||||
for _, capture := range updatedCaptureGroups {
|
||||
if capture.Value != capture.Updated {
|
||||
modifiedGroupsCount++
|
||||
}
|
||||
}
|
||||
matchLogger.Info("%d of %d capture groups identified for modification", modifiedGroupsCount, len(updatedCaptureGroups))
|
||||
|
||||
for _, capture := range updatedCaptureGroups {
|
||||
if capture.Value == capture.Updated {
|
||||
matchLogger.Debug("Capture group unchanged: %s", utils.LimitString(capture.Value, 50))
|
||||
continue
|
||||
}
|
||||
|
||||
// Log what changed with context
|
||||
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
|
||||
// replacement = replacement[:groupStart] + newVal + replacement[groupEnd:]
|
||||
commands = append(commands, ReplaceCommand{
|
||||
From: capture.Range[0] - matchIndices[0],
|
||||
To: capture.Range[1] - matchIndices[0],
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: capture.Range[0],
|
||||
To: capture.Range[1],
|
||||
With: capture.Updated,
|
||||
})
|
||||
matchLogger.Trace("Added replacement command: %+v", commands[len(commands)-1])
|
||||
}
|
||||
|
||||
sort.Slice(commands, func(i, j int) bool {
|
||||
return commands[i].From > commands[j].From
|
||||
} 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,
|
||||
})
|
||||
|
||||
for _, command := range commands {
|
||||
replacement = replacement[:command.From] + command.With + replacement[command.To:]
|
||||
}
|
||||
matchLogger.Trace("Added full replacement command: %+v", commands[len(commands)-1])
|
||||
}
|
||||
modificationCount++
|
||||
result = result[:matchIndices[0]] + replacement + result[matchIndices[1]:]
|
||||
log.Printf("Modification count updated: %d", modificationCount)
|
||||
}
|
||||
|
||||
log.Printf("Process completed with %d modifications", modificationCount)
|
||||
return result, modificationCount, len(indices), nil
|
||||
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 {
|
||||
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
|
||||
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
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if overlaps {
|
||||
groupLogger.Warning("Overlapping capture group detected and skipped.")
|
||||
continue
|
||||
}
|
||||
|
||||
groupLogger.Debug("Capture group does not overlap with previously accepted groups. Adding.")
|
||||
accepted = append(accepted, group)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -250,36 +301,199 @@ func (p *RegexProcessor) ProcessContent(content string, pattern string, luaExpr
|
||||
// If it were not here our !num in a named capture group would
|
||||
// Expand to another capture group in the capture group
|
||||
// We really only want one (our named) capture group
|
||||
func ResolveRegexPlaceholders(pattern string) string {
|
||||
func resolveRegexPlaceholders(pattern string) string {
|
||||
resolveLogger := regexLogger.WithPrefix("resolveRegexPlaceholders").WithField("originalPattern", utils.LimitString(pattern, 100))
|
||||
resolveLogger.Debug("Resolving regex placeholders in pattern")
|
||||
|
||||
// Handle special pattern modifications
|
||||
if !strings.HasPrefix(pattern, "(?s)") {
|
||||
pattern = "(?s)" + pattern
|
||||
log.Printf("Pattern modified to include (?s): %s", 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
|
||||
})
|
||||
pattern = strings.ReplaceAll(pattern, "!num", `"?(-?\d*\.?\d+)"?`)
|
||||
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 {
|
||||
groupLogger := fromLuaLogger.WithField("originalCaptureName", capture.Name).WithField("originalValue", utils.LimitString(capture.Value, 50))
|
||||
groupLogger.Debug("Processing capture group to retrieve updated value")
|
||||
|
||||
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, 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
|
||||
complexity += strings.Count(pattern, ".*") * 10 // Greedy wildcard
|
||||
complexity += strings.Count(pattern, ".*?") * 5 // Non-greedy wildcard
|
||||
complexity += strings.Count(pattern, "[^") * 3 // Negated character class
|
||||
complexity += strings.Count(pattern, "\\b") * 2 // Word boundary
|
||||
complexity += strings.Count(pattern, "(") * 2 // Capture groups
|
||||
complexity += strings.Count(pattern, "(?:") * 1 // Non-capture groups
|
||||
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")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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)
|
||||
}
|
||||
}
|
||||
853
processor/xml.go
853
processor/xml.go
@@ -1,412 +1,533 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"cook/utils"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"modify/processor/xpath"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/antchfx/xmlquery"
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// XMLProcessor implements the Processor interface for XML documents
|
||||
type XMLProcessor struct{}
|
||||
var xmlLogger = logger.Default.WithPrefix("processor/xml")
|
||||
|
||||
// ProcessContent implements the Processor interface for XMLProcessor
|
||||
func (p *XMLProcessor) ProcessContent(content string, path string, luaExpr string) (string, int, int, error) {
|
||||
// Parse XML document
|
||||
// We can't really use encoding/xml here because it requires a pre defined struct
|
||||
// And we HAVE TO parse dynamic unknown XML
|
||||
doc, err := xmlquery.Parse(strings.NewReader(content))
|
||||
if err != nil {
|
||||
return content, 0, 0, fmt.Errorf("error parsing XML: %v", err)
|
||||
}
|
||||
|
||||
// Find nodes matching the XPath pattern
|
||||
nodes, err := xpath.Get(doc, path)
|
||||
if err != nil {
|
||||
return content, 0, 0, fmt.Errorf("error executing XPath: %v", err)
|
||||
}
|
||||
|
||||
matchCount := len(nodes)
|
||||
if matchCount == 0 {
|
||||
return content, 0, 0, nil
|
||||
}
|
||||
|
||||
// Apply modifications to each node
|
||||
modCount := 0
|
||||
for _, node := range nodes {
|
||||
L, err := NewLuaState()
|
||||
if err != nil {
|
||||
return content, 0, 0, fmt.Errorf("error creating Lua state: %v", err)
|
||||
}
|
||||
defer L.Close()
|
||||
|
||||
err = p.ToLua(L, node)
|
||||
if err != nil {
|
||||
return content, modCount, matchCount, fmt.Errorf("error converting to Lua: %v", err)
|
||||
}
|
||||
|
||||
err = L.DoString(BuildLuaScript(luaExpr))
|
||||
if err != nil {
|
||||
return content, modCount, matchCount, fmt.Errorf("error executing Lua: %v", err)
|
||||
}
|
||||
|
||||
result, err := p.FromLua(L)
|
||||
if err != nil {
|
||||
return content, modCount, matchCount, fmt.Errorf("error getting result from Lua: %v", err)
|
||||
}
|
||||
log.Printf("%#v", result)
|
||||
|
||||
modified := false
|
||||
modified = L.GetGlobal("modified").String() == "true"
|
||||
if !modified {
|
||||
log.Printf("No changes made to node at path: %s", node.Data)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply modification based on the result
|
||||
if updatedValue, ok := result.(string); ok {
|
||||
// If the result is a simple string, update the node value directly
|
||||
xpath.Set(doc, path, updatedValue)
|
||||
} else if nodeData, ok := result.(map[string]interface{}); ok {
|
||||
// If the result is a map, apply more complex updates
|
||||
updateNodeFromMap(node, nodeData)
|
||||
}
|
||||
|
||||
modCount++
|
||||
}
|
||||
|
||||
// Serialize the modified XML document to string
|
||||
if doc.FirstChild != nil && doc.FirstChild.Type == xmlquery.DeclarationNode {
|
||||
// If we have an XML declaration, start with it
|
||||
declaration := doc.FirstChild.OutputXML(true)
|
||||
// Remove the firstChild (declaration) before serializing the rest of the document
|
||||
doc.FirstChild = doc.FirstChild.NextSibling
|
||||
return ConvertToNamedEntities(declaration + doc.OutputXML(true)), modCount, matchCount, nil
|
||||
}
|
||||
|
||||
// Convert numeric entities to named entities for better readability
|
||||
return ConvertToNamedEntities(doc.OutputXML(true)), modCount, matchCount, nil
|
||||
// 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
|
||||
}
|
||||
|
||||
func (p *XMLProcessor) ToLua(L *lua.LState, data interface{}) error {
|
||||
table, err := p.ToLuaTable(L, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
L.SetGlobal("v", table)
|
||||
return nil
|
||||
// XMLAttribute represents an attribute with its position in the source
|
||||
type XMLAttribute struct {
|
||||
Value string
|
||||
ValueStart int64
|
||||
ValueEnd int64
|
||||
}
|
||||
|
||||
// ToLua converts XML node values to Lua variables
|
||||
func (p *XMLProcessor) ToLuaTable(L *lua.LState, data interface{}) (lua.LValue, error) {
|
||||
// Check if data is an xmlquery.Node
|
||||
node, ok := data.(*xmlquery.Node)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected xmlquery.Node, got %T", data)
|
||||
}
|
||||
// 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
|
||||
|
||||
// Create a simple table with essential data
|
||||
table := L.NewTable()
|
||||
|
||||
// For element nodes, just provide basic info
|
||||
L.SetField(table, "type", lua.LString(nodeTypeToString(node.Type)))
|
||||
L.SetField(table, "name", lua.LString(node.Data))
|
||||
L.SetField(table, "value", lua.LString(node.InnerText()))
|
||||
|
||||
// Add children if any
|
||||
children := L.NewTable()
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
childTable, err := p.ToLuaTable(L, child)
|
||||
if err == nil {
|
||||
children.Append(childTable)
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
L.SetField(table, "children", children)
|
||||
|
||||
attrs := L.NewTable()
|
||||
if len(node.Attr) > 0 {
|
||||
for _, attr := range node.Attr {
|
||||
L.SetField(attrs, attr.Name.Local, lua.LString(attr.Value))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse XML: %v", err)
|
||||
}
|
||||
}
|
||||
L.SetField(table, "attr", attrs)
|
||||
|
||||
return table, nil
|
||||
}
|
||||
offset := decoder.InputOffset()
|
||||
|
||||
// FromLua gets modified values from Lua
|
||||
func (p *XMLProcessor) FromLua(L *lua.LState) (interface{}, error) {
|
||||
luaValue := L.GetGlobal("v")
|
||||
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))
|
||||
|
||||
// Handle string values directly
|
||||
if luaValue.Type() == lua.LTString {
|
||||
return luaValue.String(), nil
|
||||
}
|
||||
|
||||
// Handle tables (for attributes and more complex updates)
|
||||
if luaValue.Type() == lua.LTTable {
|
||||
return luaTableToMap(L, luaValue.(*lua.LTable)), nil
|
||||
}
|
||||
|
||||
return luaValue.String(), nil
|
||||
}
|
||||
|
||||
// Simple helper to convert a Lua table to a Go map
|
||||
func luaTableToMap(L *lua.LState, table *lua.LTable) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
table.ForEach(func(k, v lua.LValue) {
|
||||
if k.Type() == lua.LTString {
|
||||
key := k.String()
|
||||
|
||||
if v.Type() == lua.LTTable {
|
||||
result[key] = luaTableToMap(L, v.(*lua.LTable))
|
||||
} else {
|
||||
result[key] = v.String()
|
||||
element := &XMLElement{
|
||||
Tag: t.Name.Local,
|
||||
Attributes: make(map[string]XMLAttribute),
|
||||
StartPos: startPos,
|
||||
Children: []*XMLElement{},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
// Parse attributes - search within the tag boundaries
|
||||
if len(t.Attr) > 0 {
|
||||
tagEnd := offset
|
||||
tagSection := content[startPos:tagEnd]
|
||||
|
||||
// Simple helper to convert node type to string
|
||||
func nodeTypeToString(nodeType xmlquery.NodeType) string {
|
||||
switch nodeType {
|
||||
case xmlquery.ElementNode:
|
||||
return "element"
|
||||
case xmlquery.TextNode:
|
||||
return "text"
|
||||
case xmlquery.AttributeNode:
|
||||
return "attribute"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to update an XML node from a map
|
||||
func updateNodeFromMap(node *xmlquery.Node, data map[string]interface{}) {
|
||||
// Update node value if present
|
||||
if value, ok := data["value"]; ok {
|
||||
if strValue, ok := value.(string); ok {
|
||||
// For element nodes, replace text content
|
||||
if node.Type == xmlquery.ElementNode {
|
||||
// Find the first text child if it exists
|
||||
var textNode *xmlquery.Node
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type == xmlquery.TextNode {
|
||||
textNode = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if textNode != nil {
|
||||
// Update existing text node
|
||||
textNode.Data = strValue
|
||||
} else {
|
||||
// Create new text node
|
||||
newText := &xmlquery.Node{
|
||||
Type: xmlquery.TextNode,
|
||||
Data: strValue,
|
||||
Parent: node,
|
||||
}
|
||||
|
||||
// Insert at beginning of children
|
||||
if node.FirstChild != nil {
|
||||
newText.NextSibling = node.FirstChild
|
||||
node.FirstChild.PrevSibling = newText
|
||||
node.FirstChild = newText
|
||||
} else {
|
||||
node.FirstChild = newText
|
||||
node.LastChild = newText
|
||||
}
|
||||
}
|
||||
} else if node.Type == xmlquery.TextNode {
|
||||
// Directly update text node
|
||||
node.Data = strValue
|
||||
} else if node.Type == xmlquery.AttributeNode {
|
||||
// Update attribute value
|
||||
if node.Parent != nil {
|
||||
for i, attr := range node.Parent.Attr {
|
||||
if attr.Name.Local == node.Data {
|
||||
node.Parent.Attr[i].Value = strValue
|
||||
break
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Update attributes if present
|
||||
if attrs, ok := data["attr"].(map[string]interface{}); ok && node.Type == xmlquery.ElementNode {
|
||||
for name, value := range attrs {
|
||||
if strValue, ok := value.(string); ok {
|
||||
// Look for existing attribute
|
||||
found := false
|
||||
for i, attr := range node.Attr {
|
||||
if attr.Name.Local == name {
|
||||
node.Attr[i].Value = strValue
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// Add new attribute if not found
|
||||
if !found {
|
||||
node.Attr = append(node.Attr, xmlquery.Attr{
|
||||
Name: struct {
|
||||
Space, Local string
|
||||
}{Local: name},
|
||||
Value: strValue,
|
||||
})
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Helper function to get a string representation of node type
|
||||
func nodeTypeName(nodeType xmlquery.NodeType) string {
|
||||
switch nodeType {
|
||||
case xmlquery.ElementNode:
|
||||
return "element"
|
||||
case xmlquery.TextNode:
|
||||
return "text"
|
||||
case xmlquery.AttributeNode:
|
||||
return "attribute"
|
||||
case xmlquery.CommentNode:
|
||||
return "comment"
|
||||
case xmlquery.DeclarationNode:
|
||||
return "declaration"
|
||||
default:
|
||||
return "unknown"
|
||||
// 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()
|
||||
}
|
||||
|
||||
// ConvertToNamedEntities replaces numeric XML entities with their named counterparts
|
||||
func ConvertToNamedEntities(xml string) string {
|
||||
// Basic XML entities
|
||||
replacements := map[string]string{
|
||||
// Basic XML entities
|
||||
""": """, // double quote
|
||||
"'": "'", // single quote
|
||||
"<": "<", // less than
|
||||
">": ">", // greater than
|
||||
"&": "&", // ampersand
|
||||
// applyXMLChanges generates ReplaceCommands from detected XML changes
|
||||
func applyXMLChanges(changes []XMLChange) []utils.ReplaceCommand {
|
||||
var commands []utils.ReplaceCommand
|
||||
|
||||
// Common symbols
|
||||
" ": " ", // non-breaking space
|
||||
"©": "©", // copyright
|
||||
"®": "®", // registered trademark
|
||||
"€": "€", // euro
|
||||
"£": "£", // pound
|
||||
"¥": "¥", // yen
|
||||
"¢": "¢", // cent
|
||||
"§": "§", // section
|
||||
"™": "™", // trademark
|
||||
"♠": "♠", // spade
|
||||
"♣": "♣", // club
|
||||
"♥": "♥", // heart
|
||||
"♦": "♦", // diamond
|
||||
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,
|
||||
})
|
||||
|
||||
// Special characters
|
||||
"¡": "¡", // inverted exclamation
|
||||
"¿": "¿", // inverted question
|
||||
"«": "«", // left angle quotes
|
||||
"»": "»", // right angle quotes
|
||||
"·": "·", // middle dot
|
||||
"•": "•", // bullet
|
||||
"…": "…", // horizontal ellipsis
|
||||
"′": "′", // prime
|
||||
"″": "″", // double prime
|
||||
"‾": "‾", // overline
|
||||
"⁄": "⁄", // fraction slash
|
||||
case "attribute":
|
||||
commands = append(commands, utils.ReplaceCommand{
|
||||
From: int(change.StartPos),
|
||||
To: int(change.EndPos),
|
||||
With: change.NewValue,
|
||||
})
|
||||
|
||||
// Math symbols
|
||||
"±": "±", // plus-minus
|
||||
"×": "×", // multiplication
|
||||
"÷": "÷", // division
|
||||
"∞": "∞", // infinity
|
||||
"≈": "≈", // almost equal
|
||||
"≠": "≠", // not equal
|
||||
"≤": "≤", // less than or equal
|
||||
"≥": "≥", // greater than or equal
|
||||
"∑": "∑", // summation
|
||||
"√": "√", // square root
|
||||
"∫": "∫", // integral
|
||||
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,
|
||||
})
|
||||
|
||||
// Accented characters
|
||||
"À": "À", // A grave
|
||||
"Á": "Á", // A acute
|
||||
"Â": "Â", // A circumflex
|
||||
"Ã": "Ã", // A tilde
|
||||
"Ä": "Ä", // A umlaut
|
||||
"Å": "Å", // A ring
|
||||
"Æ": "Æ", // AE ligature
|
||||
"Ç": "Ç", // C cedilla
|
||||
"È": "È", // E grave
|
||||
"É": "É", // E acute
|
||||
"Ê": "Ê", // E circumflex
|
||||
"Ë": "Ë", // E umlaut
|
||||
"Ì": "Ì", // I grave
|
||||
"Í": "Í", // I acute
|
||||
"Î": "Î", // I circumflex
|
||||
"Ï": "Ï", // I umlaut
|
||||
"Ð": "Ð", // Eth
|
||||
"Ñ": "Ñ", // N tilde
|
||||
"Ò": "Ò", // O grave
|
||||
"Ó": "Ó", // O acute
|
||||
"Ô": "Ô", // O circumflex
|
||||
"Õ": "Õ", // O tilde
|
||||
"Ö": "Ö", // O umlaut
|
||||
"Ø": "Ø", // O slash
|
||||
"Ù": "Ù", // U grave
|
||||
"Ú": "Ú", // U acute
|
||||
"Û": "Û", // U circumflex
|
||||
"Ü": "Ü", // U umlaut
|
||||
"Ý": "Ý", // Y acute
|
||||
"Þ": "Þ", // Thorn
|
||||
"ß": "ß", // Sharp s
|
||||
"à": "à", // a grave
|
||||
"á": "á", // a acute
|
||||
"â": "â", // a circumflex
|
||||
"ã": "ã", // a tilde
|
||||
"ä": "ä", // a umlaut
|
||||
"å": "å", // a ring
|
||||
"æ": "æ", // ae ligature
|
||||
"ç": "ç", // c cedilla
|
||||
"è": "è", // e grave
|
||||
"é": "é", // e acute
|
||||
"ê": "ê", // e circumflex
|
||||
"ë": "ë", // e umlaut
|
||||
"ì": "ì", // i grave
|
||||
"í": "í", // i acute
|
||||
"î": "î", // i circumflex
|
||||
"ï": "ï", // i umlaut
|
||||
"ð": "ð", // eth
|
||||
"ñ": "ñ", // n tilde
|
||||
"ò": "ò", // o grave
|
||||
"ó": "ó", // o acute
|
||||
"ô": "ô", // o circumflex
|
||||
"õ": "õ", // o tilde
|
||||
"ö": "ö", // o umlaut
|
||||
"ø": "ø", // o slash
|
||||
"ù": "ù", // u grave
|
||||
"ú": "ú", // u acute
|
||||
"û": "û", // u circumflex
|
||||
"ü": "ü", // u umlaut
|
||||
"ý": "ý", // y acute
|
||||
"þ": "þ", // thorn
|
||||
"ÿ": "ÿ", // y umlaut
|
||||
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: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result := xml
|
||||
for numeric, named := range replacements {
|
||||
result = strings.ReplaceAll(result, numeric, named)
|
||||
}
|
||||
return result
|
||||
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) // 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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
// The package is now using github.com/antchfx/xmlquery for XPath parsing.
|
||||
// The parsing functionality tests have been removed since we're now
|
||||
// delegating XPath parsing to the xmlquery library.
|
||||
package xpath
|
||||
@@ -1,4 +0,0 @@
|
||||
// The package is now using github.com/antchfx/xmlquery for XPath parsing.
|
||||
// The parsing functionality tests have been removed since we're now
|
||||
// delegating XPath parsing to the xmlquery library.
|
||||
package xpath
|
||||
@@ -1,133 +0,0 @@
|
||||
package xpath
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/antchfx/xmlquery"
|
||||
)
|
||||
|
||||
// Get retrieves nodes from XML data using an XPath expression
|
||||
func Get(node *xmlquery.Node, path string) ([]*xmlquery.Node, error) {
|
||||
if node == nil {
|
||||
return nil, errors.New("nil node provided")
|
||||
}
|
||||
|
||||
// Execute xpath query directly
|
||||
nodes, err := xmlquery.QueryAll(node, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute XPath query: %v", err)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// Set updates a single node in the XML data using an XPath expression
|
||||
func Set(node *xmlquery.Node, path string, value interface{}) error {
|
||||
if node == nil {
|
||||
return errors.New("nil node provided")
|
||||
}
|
||||
|
||||
// Find the node to update
|
||||
nodes, err := xmlquery.QueryAll(node, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute XPath query: %v", err)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
return fmt.Errorf("no nodes found for path: %s", path)
|
||||
}
|
||||
|
||||
// Update the first matching node
|
||||
updateNodeValue(nodes[0], value)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAll updates all nodes that match the XPath expression
|
||||
func SetAll(node *xmlquery.Node, path string, value interface{}) error {
|
||||
if node == nil {
|
||||
return errors.New("nil node provided")
|
||||
}
|
||||
|
||||
// Find all nodes to update
|
||||
nodes, err := xmlquery.QueryAll(node, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute XPath query: %v", err)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
return fmt.Errorf("no nodes found for path: %s", path)
|
||||
}
|
||||
|
||||
// Update all matching nodes
|
||||
for _, matchNode := range nodes {
|
||||
updateNodeValue(matchNode, value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to update a node's value
|
||||
func updateNodeValue(node *xmlquery.Node, value interface{}) {
|
||||
strValue := fmt.Sprintf("%v", value)
|
||||
|
||||
// Handle different node types
|
||||
switch node.Type {
|
||||
case xmlquery.AttributeNode:
|
||||
// For attribute nodes, update the attribute value
|
||||
parent := node.Parent
|
||||
if parent != nil {
|
||||
for i, attr := range parent.Attr {
|
||||
if attr.Name.Local == node.Data {
|
||||
parent.Attr[i].Value = strValue
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case xmlquery.TextNode:
|
||||
// For text nodes, update the text content
|
||||
node.Data = strValue
|
||||
case xmlquery.ElementNode:
|
||||
// For element nodes, clear existing text children and add a new text node
|
||||
// First, remove all existing text children
|
||||
var nonTextChildren []*xmlquery.Node
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
if child.Type != xmlquery.TextNode {
|
||||
nonTextChildren = append(nonTextChildren, child)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all children
|
||||
node.FirstChild = nil
|
||||
node.LastChild = nil
|
||||
|
||||
// Add a new text node
|
||||
textNode := &xmlquery.Node{
|
||||
Type: xmlquery.TextNode,
|
||||
Data: strValue,
|
||||
Parent: node,
|
||||
}
|
||||
|
||||
// Set the text node as the first child
|
||||
node.FirstChild = textNode
|
||||
node.LastChild = textNode
|
||||
|
||||
// Add back non-text children
|
||||
for _, child := range nonTextChildren {
|
||||
child.Parent = node
|
||||
|
||||
// If this is the first child being added back
|
||||
if node.FirstChild == textNode && node.LastChild == textNode {
|
||||
node.FirstChild.NextSibling = child
|
||||
child.PrevSibling = node.FirstChild
|
||||
node.LastChild = child
|
||||
} else {
|
||||
// Add to the end of the chain
|
||||
node.LastChild.NextSibling = child
|
||||
child.PrevSibling = node.LastChild
|
||||
node.LastChild = child
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
package xpath
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/antchfx/xmlquery"
|
||||
)
|
||||
|
||||
// Parse test XML data once at the beginning for use in multiple tests
|
||||
func parseTestXML(t *testing.T, xmlData string) *xmlquery.Node {
|
||||
doc, err := xmlquery.Parse(strings.NewReader(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse test XML: %v", err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// XML test data as a string for our tests
|
||||
var testXML = `
|
||||
<store>
|
||||
<book category="fiction">
|
||||
<title lang="en">The Fellowship of the Ring</title>
|
||||
<author>J.R.R. Tolkien</author>
|
||||
<year>1954</year>
|
||||
<price>22.99</price>
|
||||
</book>
|
||||
<book category="fiction">
|
||||
<title lang="en">The Two Towers</title>
|
||||
<author>J.R.R. Tolkien</author>
|
||||
<year>1954</year>
|
||||
<price>23.45</price>
|
||||
</book>
|
||||
<book category="technical">
|
||||
<title lang="en">Learning XML</title>
|
||||
<author>Erik T. Ray</author>
|
||||
<year>2003</year>
|
||||
<price>39.95</price>
|
||||
</book>
|
||||
<bicycle>
|
||||
<color>red</color>
|
||||
<price>199.95</price>
|
||||
</bicycle>
|
||||
</store>
|
||||
`
|
||||
|
||||
func TestEvaluator(t *testing.T) {
|
||||
// Parse the test XML data once for all test cases
|
||||
doc := parseTestXML(t, testXML)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
error bool
|
||||
}{
|
||||
{
|
||||
name: "simple_element_access",
|
||||
path: "/store/bicycle/color",
|
||||
},
|
||||
{
|
||||
name: "recursive_element_access",
|
||||
path: "//price",
|
||||
},
|
||||
{
|
||||
name: "wildcard_element_access",
|
||||
path: "/store/book/*",
|
||||
},
|
||||
{
|
||||
name: "attribute_exists_predicate",
|
||||
path: "//title[@lang]",
|
||||
},
|
||||
{
|
||||
name: "attribute_equals_predicate",
|
||||
path: "//title[@lang='en']",
|
||||
},
|
||||
{
|
||||
name: "value_comparison_predicate",
|
||||
path: "/store/book[price>35.00]/title",
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "last_predicate",
|
||||
path: "/store/book[last()]/title",
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "last_minus_predicate",
|
||||
path: "/store/book[last()-1]/title",
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "position_predicate",
|
||||
path: "/store/book[position()<3]/title",
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "invalid_index",
|
||||
path: "/store/book[10]/title",
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent_element",
|
||||
path: "/store/nonexistent",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Get(doc, tt.path)
|
||||
|
||||
// Handle expected errors
|
||||
if tt.error {
|
||||
if err == nil && len(result) == 0 {
|
||||
// If we expected an error but got empty results instead, that's okay
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// If we got an error as expected, that's okay
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
// If we didn't expect an error but got one, that's a test failure
|
||||
t.Errorf("Get(%q) returned unexpected error: %v", tt.path, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Special cases where we don't care about exact matches
|
||||
switch tt.name {
|
||||
case "wildcard_element_access":
|
||||
// Just check that we got some elements
|
||||
if len(result) == 0 {
|
||||
t.Errorf("Expected multiple elements for wildcard, got none")
|
||||
}
|
||||
return
|
||||
case "attribute_exists_predicate", "attribute_equals_predicate":
|
||||
// Just check that we got some titles
|
||||
if len(result) == 0 {
|
||||
t.Errorf("Expected titles with lang attribute, got none")
|
||||
}
|
||||
// Ensure all are title elements
|
||||
for _, node := range result {
|
||||
if node.Data != "title" {
|
||||
t.Errorf("Expected title elements, got: %s", node.Data)
|
||||
}
|
||||
}
|
||||
return
|
||||
case "nonexistent_element":
|
||||
// Just check that we got empty results
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected empty results for nonexistent element, got %d items", len(result))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For other cases, just verify we got results
|
||||
if len(result) == 0 {
|
||||
t.Errorf("Expected results for path %s, got none", tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
t.Run("nil_node", func(t *testing.T) {
|
||||
result, err := Get(nil, "/store/book")
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for nil node")
|
||||
return
|
||||
}
|
||||
if len(result) > 0 {
|
||||
t.Errorf("Expected empty result, got %v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_xml", func(t *testing.T) {
|
||||
invalidXML, err := xmlquery.Parse(strings.NewReader("<invalid>xml"))
|
||||
if err != nil {
|
||||
// If parsing fails, that's expected
|
||||
return
|
||||
}
|
||||
|
||||
_, err = Get(invalidXML, "/store")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid XML structure")
|
||||
}
|
||||
})
|
||||
|
||||
// For these tests with the simple XML, we expect just one result
|
||||
simpleXML := `<root><book><title lang="en">Test</title></book></root>`
|
||||
doc := parseTestXML(t, simpleXML)
|
||||
|
||||
t.Run("current_node", func(t *testing.T) {
|
||||
result, err := Get(doc, "/root/book/.")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) > 1 {
|
||||
t.Errorf("Expected at most 1 result, got %d", len(result))
|
||||
}
|
||||
if len(result) > 0 {
|
||||
// Verify it's the book node
|
||||
if result[0].Data != "book" {
|
||||
t.Errorf("Expected book node, got %v", result[0].Data)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attributes", func(t *testing.T) {
|
||||
result, err := Get(doc, "/root/book/title/@lang")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) != 1 || result[0].InnerText() != "en" {
|
||||
t.Errorf("Expected 'en', got %v", result[0].InnerText())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetWithPaths(t *testing.T) {
|
||||
// Use a simplified, well-formed XML document
|
||||
simpleXML := `<store>
|
||||
<book category="fiction">
|
||||
<title lang="en">The Book Title</title>
|
||||
<author>Author Name</author>
|
||||
<price>19.99</price>
|
||||
</book>
|
||||
<bicycle>
|
||||
<color>red</color>
|
||||
<price>199.95</price>
|
||||
</bicycle>
|
||||
</store>`
|
||||
|
||||
// Parse the XML for testing
|
||||
doc := parseTestXML(t, simpleXML)
|
||||
|
||||
// Debug: Print the test XML
|
||||
t.Logf("Test XML:\n%s", simpleXML)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedValue string
|
||||
}{
|
||||
{
|
||||
name: "simple_element_access",
|
||||
path: "/store/bicycle/color",
|
||||
expectedValue: "red",
|
||||
},
|
||||
{
|
||||
name: "attribute_access",
|
||||
path: "/store/book/title/@lang",
|
||||
expectedValue: "en",
|
||||
},
|
||||
{
|
||||
name: "recursive_with_attribute",
|
||||
path: "//title[@lang='en']",
|
||||
expectedValue: "The Book Title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Debug: Print the path we're looking for
|
||||
t.Logf("Looking for path: %s", tt.path)
|
||||
|
||||
result, err := Get(doc, tt.path)
|
||||
if err != nil {
|
||||
t.Errorf("Get(%q) returned error: %v", tt.path, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: Print the results
|
||||
t.Logf("Got %d results", len(result))
|
||||
for i, r := range result {
|
||||
t.Logf("Result %d: Node=%s, Value=%v", i, r.Data, r.InnerText())
|
||||
}
|
||||
|
||||
// Check that we got results
|
||||
if len(result) == 0 {
|
||||
t.Errorf("Get(%q) returned no results", tt.path)
|
||||
return
|
||||
}
|
||||
|
||||
// For attribute access test, do more specific checks
|
||||
if tt.name == "attribute_access" {
|
||||
// Check the first result's value matches expected
|
||||
if result[0].InnerText() != tt.expectedValue {
|
||||
t.Errorf("Attribute value: got %v, expected %s", result[0].InnerText(), tt.expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// For simple element access, check the text content
|
||||
if tt.name == "simple_element_access" {
|
||||
if text := result[0].InnerText(); text != tt.expectedValue {
|
||||
t.Errorf("Element text: got %s, expected %s", text, tt.expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// For recursive with attribute test, check title elements with lang="en"
|
||||
if tt.name == "recursive_with_attribute" {
|
||||
for _, node := range result {
|
||||
// Check the node is a title
|
||||
if node.Data != "title" {
|
||||
t.Errorf("Expected title element, got %s", node.Data)
|
||||
}
|
||||
|
||||
// Check text content
|
||||
if text := node.InnerText(); text != tt.expectedValue {
|
||||
t.Errorf("Text content: got %s, expected %s", text, tt.expectedValue)
|
||||
}
|
||||
|
||||
// Check attributes - find the lang attribute
|
||||
hasLang := false
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Name.Local == "lang" && attr.Value == "en" {
|
||||
hasLang = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLang {
|
||||
t.Errorf("Expected lang=\"en\" attribute, but it was not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
t.Run("simple element", func(t *testing.T) {
|
||||
xmlData := `<root><name>John</name></root>`
|
||||
doc := parseTestXML(t, xmlData)
|
||||
|
||||
err := Set(doc, "/root/name", "Jane")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the change
|
||||
result, err := Get(doc, "/root/name")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// Check text content
|
||||
if text := result[0].InnerText(); text != "Jane" {
|
||||
t.Errorf("Expected text 'Jane', got '%s'", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attribute", func(t *testing.T) {
|
||||
xmlData := `<root><element id="123"></element></root>`
|
||||
doc := parseTestXML(t, xmlData)
|
||||
|
||||
err := Set(doc, "/root/element/@id", "456")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the change
|
||||
result, err := Get(doc, "/root/element/@id")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// For attributes, check the inner text
|
||||
if text := result[0].InnerText(); text != "456" {
|
||||
t.Errorf("Expected attribute value '456', got '%s'", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indexed element", func(t *testing.T) {
|
||||
xmlData := `<root><items><item>first</item><item>second</item></items></root>`
|
||||
doc := parseTestXML(t, xmlData)
|
||||
|
||||
err := Set(doc, "/root/items/item[1]", "changed")
|
||||
if err != nil {
|
||||
t.Errorf("Set() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the change using XPath that specifically targets the first item
|
||||
result, err := Get(doc, "/root/items/item[1]")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have results
|
||||
if len(result) == 0 {
|
||||
t.Errorf("Expected at least one result for /root/items/item[1]")
|
||||
return
|
||||
}
|
||||
|
||||
// Check text content
|
||||
if text := result[0].InnerText(); text != "changed" {
|
||||
t.Errorf("Expected text 'changed', got '%s'", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetAll(t *testing.T) {
|
||||
t.Run("multiple elements", func(t *testing.T) {
|
||||
xmlData := `<root><items><item>first</item><item>second</item></items></root>`
|
||||
doc := parseTestXML(t, xmlData)
|
||||
|
||||
err := SetAll(doc, "//item", "changed")
|
||||
if err != nil {
|
||||
t.Errorf("SetAll() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify all items are changed
|
||||
result, err := Get(doc, "//item")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 results, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// Check each node
|
||||
for i, node := range result {
|
||||
if text := node.InnerText(); text != "changed" {
|
||||
t.Errorf("Item %d: expected text 'changed', got '%s'", i, text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attributes", func(t *testing.T) {
|
||||
xmlData := `<root><item id="1"/><item id="2"/></root>`
|
||||
doc := parseTestXML(t, xmlData)
|
||||
|
||||
err := SetAll(doc, "//item/@id", "new")
|
||||
if err != nil {
|
||||
t.Errorf("SetAll() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify all attributes are changed
|
||||
result, err := Get(doc, "//item/@id")
|
||||
if err != nil {
|
||||
t.Errorf("Get() returned error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 results, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// For attributes, check inner text
|
||||
for i, node := range result {
|
||||
if text := node.InnerText(); text != "new" {
|
||||
t.Errorf("Attribute %d: expected value 'new', got '%s'", i, text)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,29 @@
|
||||
package regression
|
||||
|
||||
import (
|
||||
"modify/processor"
|
||||
"cook/processor"
|
||||
"cook/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ApiAdaptor(content string, regex string, lua string) (string, int, int, error) {
|
||||
command := utils.ModifyCommand{
|
||||
Regex: regex,
|
||||
Lua: lua,
|
||||
LogLevel: "TRACE",
|
||||
}
|
||||
|
||||
commands, err := processor.ProcessRegex(content, command, "test")
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
|
||||
result, modifications := utils.ExecuteModifications(commands, content)
|
||||
return result, modifications, len(commands), nil
|
||||
}
|
||||
|
||||
func TestTalentsMechanicOutOfRange(t *testing.T) {
|
||||
given := `<Talent identifier="quickfixer">
|
||||
<Icon texture="Content/UI/TalentsIcons2.png" sheetindex="5,2" sheetelementsize="128,128"/>
|
||||
@@ -62,22 +81,57 @@ func TestTalentsMechanicOutOfRange(t *testing.T) {
|
||||
</AbilityGroupEffect>
|
||||
</Talent>`
|
||||
|
||||
p := &processor.RegexProcessor{}
|
||||
result, mods, matches, err := p.ProcessContent(given, `<Talent identifier="quickfixer">!anyvalue="(?<movementspeed>!num)"!anyvalue="(?<duration>!num)"!anyvalue="(?<repairspeed>!num)"!anyamount="(?<durationv>!num)"`, "movementspeed=round(movementspeed*1.5, 2) duration=round(duration*2, 2) repairspeed=round(repairspeed*2, 2) durationv=duration")
|
||||
result, mods, matches, err := ApiAdaptor(given, `<Talent identifier="quickfixer">!anyvalue="(?<movementspeed>!num)"!anyvalue="(?<duration>!num)"!anyvalue="(?<repairspeed>!num)"!anyamount="(?<durationv>!num)"`, "movementspeed=round(movementspeed*1.5, 2) duration=round(duration*2, 2) repairspeed=round(repairspeed*2, 2) durationv=duration")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Error processing content: %v", err)
|
||||
}
|
||||
|
||||
if matches != 1 {
|
||||
t.Errorf("Expected 1 match, got %d", matches)
|
||||
if matches != 4 {
|
||||
t.Errorf("Expected 4 matches, got %d", matches)
|
||||
}
|
||||
|
||||
if mods != 1 {
|
||||
t.Errorf("Expected 1 modification, got %d", mods)
|
||||
if mods != 4 {
|
||||
t.Errorf("Expected 4 modifications, got %d", mods)
|
||||
}
|
||||
|
||||
if result != actual {
|
||||
t.Errorf("expected %s, got %s", actual, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexExplosions_ShouldNotPanic(t *testing.T) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting current working directory: %v", err)
|
||||
}
|
||||
|
||||
given, err := os.ReadFile(filepath.Join(cwd, "..", "testfiles", "OutpostItems.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Error reading file: %v", err)
|
||||
}
|
||||
|
||||
expected, err := os.ReadFile(filepath.Join(cwd, "..", "testfiles", "OutpostItemsExpected.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Error reading file: %v", err)
|
||||
}
|
||||
|
||||
result, _, _, err := ApiAdaptor(string(given), `(?-s)LightComponent!anyrange="(!num)"`, "*4")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Error processing content: %v", err)
|
||||
}
|
||||
|
||||
// We don't really care how many god damn matches there are as long as the result is correct
|
||||
// if matches != 45 {
|
||||
// t.Errorf("Expected 45 match, got %d", matches)
|
||||
// }
|
||||
//
|
||||
// if mods != 45 {
|
||||
// t.Errorf("Expected 45 modification, got %d", mods)
|
||||
// }
|
||||
|
||||
if string(result) != string(expected) {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<config>
|
||||
<item>
|
||||
<value>75</value>
|
||||
<multiplier>2</multiplier>
|
||||
<divider>4</divider>
|
||||
</item>
|
||||
<item>
|
||||
<value>150</value>
|
||||
<multiplier>3</multiplier>
|
||||
<divider>2</divider>
|
||||
</item>
|
||||
</config>
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testdata>
|
||||
<!-- Numeric values -->
|
||||
<item>
|
||||
<id>1</id>
|
||||
<value>200</value>
|
||||
<price>24.99</price>
|
||||
<quantity>5</quantity>
|
||||
</item>
|
||||
|
||||
<!-- Text values -->
|
||||
<item>
|
||||
<id>2</id>
|
||||
<name>Test Product</name>
|
||||
<description>This is a test product description</description>
|
||||
<category>Test</category>
|
||||
</item>
|
||||
|
||||
<!-- Mixed content -->
|
||||
<item>
|
||||
<id>3</id>
|
||||
<name>Mixed Product</name>
|
||||
<price>19.99</price>
|
||||
<code>PRD-123</code>
|
||||
<tags>sale,discount,new</tags>
|
||||
</item>
|
||||
|
||||
<!-- Empty and special values -->
|
||||
<item>
|
||||
<id>4</id>
|
||||
<value></value>
|
||||
<specialChars>Hello & World < > " '</specialChars>
|
||||
<multiline>Line 1
|
||||
Line 2
|
||||
Line 3</multiline>
|
||||
</item>
|
||||
</testdata>
|
||||
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>
|
||||
1252
testfiles/OutpostItems.xml
Normal file
1252
testfiles/OutpostItems.xml
Normal file
File diff suppressed because it is too large
Load Diff
1252
testfiles/OutpostItemsExpected.xml
Normal file
1252
testfiles/OutpostItemsExpected.xml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
<config><item><value>100</value></item></config>
|
||||
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))
|
||||
}
|
||||
580
utils/modifycommand.go
Normal file
580
utils/modifycommand.go
Normal file
@@ -0,0 +1,580 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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,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"`
|
||||
}
|
||||
|
||||
type CookFile []ModifyCommand
|
||||
|
||||
func (c *ModifyCommand) Validate() error {
|
||||
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
|
||||
}
|
||||
|
||||
// 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]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 {
|
||||
// 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 {
|
||||
associateFilesLogger.Warning("Failed to match glob %q with file %q: %v", glob, file, err)
|
||||
continue
|
||||
}
|
||||
if matches {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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{} {
|
||||
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 {
|
||||
// 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{}{}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
|
||||
for pattern := range patterns {
|
||||
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 {
|
||||
expandGlobsLogger.Warning("Error expanding glob %q in %q: %v", pattern, static, err)
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
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, 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{})
|
||||
|
||||
for _, arg := range args {
|
||||
loadCommandsLogger.Debug("Processing argument for commands: %q", arg)
|
||||
var newCommands []ModifyCommand
|
||||
var newVariables map[string]interface{}
|
||||
var err error
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 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{}
|
||||
variables := make(map[string]interface{})
|
||||
cookFiles, err := doublestar.Glob(os.DirFS(static), pattern)
|
||||
if err != nil {
|
||||
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 {
|
||||
loadCookFilesLogger.Error("Failed to read cook file %q: %v", cookFile, err)
|
||||
return nil, nil, fmt.Errorf("failed to read cook file: %w", err)
|
||||
}
|
||||
loadCookFilesLogger.Trace("Read %d bytes from cook file %q", len(cookFileData), cookFile)
|
||||
newCommands, newVariables, err := LoadCommandsFromCookFile(cookFileData)
|
||||
if err != nil {
|
||||
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...)
|
||||
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))
|
||||
}
|
||||
|
||||
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, 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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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"])
|
||||
}
|
||||
1064
utils/modifycommand_test.go
Normal file
1064
utils/modifycommand_test.go
Normal file
File diff suppressed because it is too large
Load Diff
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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
79
utils/replacecommand.go
Normal file
79
utils/replacecommand.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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
|
||||
With string
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
executeModificationsLogger.Debug("Modifications sorted in reverse order for safe replacement")
|
||||
executeModificationsLogger.Trace("Sorted modifications: %v", modifications)
|
||||
|
||||
executed := 0
|
||||
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 {
|
||||
executeModificationsLogger.Error("Failed to execute replacement for modification %+v: %v", modification, err)
|
||||
continue
|
||||
}
|
||||
executed++
|
||||
executeModificationsLogger.Trace("File data length after modification: %d", len(fileData))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
504
utils/replacecommand_test.go
Normal file
504
utils/replacecommand_test.go
Normal file
@@ -0,0 +1,504 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReplaceCommandExecute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
command ReplaceCommand
|
||||
expected string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "Simple replacement",
|
||||
input: "This is a test string",
|
||||
command: ReplaceCommand{From: 5, To: 7, With: "was"},
|
||||
expected: "This was a test string",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Replace at beginning",
|
||||
input: "Hello world",
|
||||
command: ReplaceCommand{From: 0, To: 5, With: "Hi"},
|
||||
expected: "Hi world",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Replace at end",
|
||||
input: "Hello world",
|
||||
command: ReplaceCommand{From: 6, To: 11, With: "everyone"},
|
||||
expected: "Hello everyone",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Replace entire string",
|
||||
input: "Hello world",
|
||||
command: ReplaceCommand{From: 0, To: 11, With: "Goodbye!"},
|
||||
expected: "Goodbye!",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Error: From > To",
|
||||
input: "Test string",
|
||||
command: ReplaceCommand{From: 7, To: 5, With: "fail"},
|
||||
expected: "Test string",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "Error: From > string length",
|
||||
input: "Test",
|
||||
command: ReplaceCommand{From: 10, To: 12, With: "fail"},
|
||||
expected: "Test",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "Error: To > string length",
|
||||
input: "Test",
|
||||
command: ReplaceCommand{From: 2, To: 10, With: "fail"},
|
||||
expected: "Test",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := tc.command.Execute(tc.input)
|
||||
|
||||
if tc.shouldError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected an error for command %+v but got none", tc.command)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteModifications(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
modifications []ReplaceCommand
|
||||
expected string
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "Single modification",
|
||||
input: "Hello world",
|
||||
modifications: []ReplaceCommand{
|
||||
{From: 0, To: 5, With: "Hi"},
|
||||
},
|
||||
expected: "Hi world",
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "Multiple modifications",
|
||||
input: "This is a test string",
|
||||
modifications: []ReplaceCommand{
|
||||
{From: 0, To: 4, With: "That"},
|
||||
{From: 8, To: 14, With: "sample"},
|
||||
},
|
||||
expected: "That is sample string",
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "Overlapping modifications",
|
||||
input: "ABCDEF",
|
||||
modifications: []ReplaceCommand{
|
||||
{From: 0, To: 3, With: "123"}, // ABC -> 123
|
||||
{From: 2, To: 5, With: "xyz"}, // CDE -> xyz
|
||||
},
|
||||
// The actual behavior with the current implementation
|
||||
expected: "123yzF",
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "Sequential modifications",
|
||||
input: "Hello world",
|
||||
modifications: []ReplaceCommand{
|
||||
{From: 0, To: 5, With: "Hi"},
|
||||
{From: 5, To: 6, With: ""}, // Remove the space
|
||||
{From: 6, To: 11, With: "everyone"},
|
||||
},
|
||||
expected: "Hieveryone",
|
||||
expectedCount: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Make a copy of the modifications to avoid modifying the test case
|
||||
mods := make([]ReplaceCommand, len(tc.modifications))
|
||||
copy(mods, tc.modifications)
|
||||
|
||||
result, count := ExecuteModifications(mods, tc.input)
|
||||
|
||||
if count != tc.expectedCount {
|
||||
t.Errorf("Expected %d modifications, got %d", tc.expectedCount, count)
|
||||
}
|
||||
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %q, got %q", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseOrderExecution(t *testing.T) {
|
||||
// This test verifies the current behavior of modification application
|
||||
input := "Original text with multiple sections"
|
||||
|
||||
// Modifications in specific positions
|
||||
modifications := []ReplaceCommand{
|
||||
{From: 0, To: 8, With: "Modified"}, // Original -> Modified
|
||||
{From: 9, To: 13, With: "document"}, // text -> document
|
||||
{From: 14, To: 22, With: "without"}, // with -> without
|
||||
{From: 23, To: 31, With: "any"}, // multiple -> any
|
||||
}
|
||||
|
||||
// The actual current behavior of our implementation
|
||||
expected := "Modified document withouttanytions"
|
||||
|
||||
result, count := ExecuteModifications(modifications, input)
|
||||
|
||||
if count != 4 {
|
||||
t.Errorf("Expected 4 modifications, got %d", count)
|
||||
}
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Replace text in the middle of a string with new content
|
||||
func TestReplaceCommandExecute_ReplacesTextInMiddle(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: 6,
|
||||
To: 11,
|
||||
With: "replaced",
|
||||
}
|
||||
|
||||
fileContent := "Hello world, how are you?"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello replaced, how are you?", result)
|
||||
}
|
||||
|
||||
// Replace with empty string (deletion)
|
||||
func TestReplaceCommandExecute_DeletesText(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: 6,
|
||||
To: 11,
|
||||
With: "",
|
||||
}
|
||||
|
||||
fileContent := "Hello world, how are you?"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello , how are you?", result)
|
||||
}
|
||||
|
||||
// Replace with longer string than original segment
|
||||
func TestReplaceCommandExecute_WithLongerString(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: 6,
|
||||
To: 11,
|
||||
With: "longerreplacement",
|
||||
}
|
||||
|
||||
fileContent := "Hello world, how are you?"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello longerreplacement, how are you?", result)
|
||||
}
|
||||
|
||||
// From and To values are the same (zero-length replacement)
|
||||
func TestReplaceCommandExecute_ZeroLengthReplacement(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: 5,
|
||||
To: 5,
|
||||
With: "inserted",
|
||||
}
|
||||
|
||||
fileContent := "Hello world"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Helloinserted world", result)
|
||||
}
|
||||
|
||||
// From value is greater than To value
|
||||
func TestReplaceCommandExecute_FromGreaterThanTo(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: 10,
|
||||
To: 5,
|
||||
With: "replaced",
|
||||
}
|
||||
|
||||
fileContent := "Hello world, how are you?"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Hello world, how are you?", result)
|
||||
}
|
||||
|
||||
// From or To values exceed string length
|
||||
func TestReplaceCommandExecute_FromOrToExceedsLength(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: 5,
|
||||
To: 50, // Exceeds the length of the fileContent
|
||||
With: "replaced",
|
||||
}
|
||||
|
||||
fileContent := "Hello world"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Hello world", result)
|
||||
}
|
||||
|
||||
// From or To values are negative
|
||||
func TestReplaceCommandExecute_NegativeFromOrTo(t *testing.T) {
|
||||
// Arrange
|
||||
cmd := &ReplaceCommand{
|
||||
From: -1,
|
||||
To: 10,
|
||||
With: "replaced",
|
||||
}
|
||||
|
||||
fileContent := "Hello world, how are you?"
|
||||
|
||||
// Act
|
||||
result, err := cmd.Execute(fileContent)
|
||||
|
||||
// Assert
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Hello world, how are you?", result)
|
||||
}
|
||||
|
||||
// Modifications are applied in reverse order (from highest to lowest 'From' value)
|
||||
func TestExecuteModificationsAppliesInReverseOrder(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "This is a test string for replacements"
|
||||
|
||||
modifications := []ReplaceCommand{
|
||||
{From: 0, To: 4, With: "That"},
|
||||
{From: 10, To: 14, With: "sample"},
|
||||
{From: 26, To: 38, With: "modifications"},
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
result, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify results
|
||||
expectedResult := "That is a sample string for modifications"
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected result to be %q, but got %q", expectedResult, result)
|
||||
}
|
||||
|
||||
if executed != 3 {
|
||||
t.Errorf("Expected 3 modifications to be executed, but got %d", executed)
|
||||
}
|
||||
}
|
||||
|
||||
// One or more modifications fail but others succeed
|
||||
func TestExecuteModificationsWithPartialFailures(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "This is a test string for replacements"
|
||||
|
||||
// Create a custom ReplaceCommand implementation that will fail
|
||||
failingCommand := ReplaceCommand{
|
||||
From: 15,
|
||||
To: 10, // Invalid range (To < From) to cause failure
|
||||
With: "will fail",
|
||||
}
|
||||
|
||||
// Valid commands
|
||||
validCommand1 := ReplaceCommand{
|
||||
From: 0,
|
||||
To: 4,
|
||||
With: "That",
|
||||
}
|
||||
|
||||
validCommand2 := ReplaceCommand{
|
||||
From: 26,
|
||||
To: 38,
|
||||
With: "modifications",
|
||||
}
|
||||
|
||||
modifications := []ReplaceCommand{failingCommand, validCommand1, validCommand2}
|
||||
|
||||
// Execute the function
|
||||
result, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify results
|
||||
expectedResult := "That is a test string for modifications"
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected result to be %q, but got %q", expectedResult, result)
|
||||
}
|
||||
|
||||
// Only 2 out of 3 modifications should succeed
|
||||
if executed != 2 {
|
||||
t.Errorf("Expected 2 modifications to be executed successfully, but got %d", executed)
|
||||
}
|
||||
}
|
||||
|
||||
// All valid modifications are executed and the modified string is returned
|
||||
func TestExecuteModificationsAllValid(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "Hello world, this is a test"
|
||||
|
||||
modifications := []ReplaceCommand{
|
||||
{From: 0, To: 5, With: "Hi"},
|
||||
{From: 18, To: 20, With: "was"},
|
||||
{From: 21, To: 27, With: "an example"},
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
result, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify results
|
||||
expectedResult := "Hi world, this was an example"
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected result to be %q, but got %q", expectedResult, result)
|
||||
}
|
||||
|
||||
if executed != 3 {
|
||||
t.Errorf("Expected 3 modifications to be executed, but got %d", executed)
|
||||
}
|
||||
}
|
||||
|
||||
// The count of successfully executed modifications is returned
|
||||
func TestExecuteModificationsReturnsCorrectCount(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "Initial text for testing"
|
||||
|
||||
modifications := []ReplaceCommand{
|
||||
{From: 0, To: 7, With: "Final"},
|
||||
{From: 12, To: 16, With: "example"},
|
||||
{From: 17, To: 24, With: "process"},
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
_, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify the count of executed modifications
|
||||
expectedExecuted := 3
|
||||
if executed != expectedExecuted {
|
||||
t.Errorf("Expected %d modifications to be executed, but got %d", expectedExecuted, executed)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty modifications list returns the original string with zero executed count
|
||||
func TestExecuteModificationsWithEmptyList(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "This is a test string for replacements"
|
||||
|
||||
modifications := []ReplaceCommand{}
|
||||
|
||||
// Execute the function
|
||||
result, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify results
|
||||
if result != fileData {
|
||||
t.Errorf("Expected result to be %q, but got %q", fileData, result)
|
||||
}
|
||||
|
||||
if executed != 0 {
|
||||
t.Errorf("Expected 0 modifications to be executed, but got %d", executed)
|
||||
}
|
||||
}
|
||||
|
||||
// Modifications with identical 'From' values
|
||||
func TestExecuteModificationsWithIdenticalFromValues(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "This is a test string for replacements"
|
||||
|
||||
modifications := []ReplaceCommand{
|
||||
{From: 10, To: 14, With: "sample"},
|
||||
{From: 10, To: 14, With: "example"},
|
||||
{From: 26, To: 38, With: "modifications"},
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
result, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify results
|
||||
// Yes, it's mangled, yes, it's intentional
|
||||
// Every subsequent command works with the modified contents of the previous command
|
||||
// So by the time we get to "example" the indices have already eaten into "sample"... In fact they have eaten into "samp", "le" is left
|
||||
// So we prepend "example" and end up with "examplele"
|
||||
// Whether sample or example goes first here is irrelevant to us
|
||||
// But it just so happens that sample goes first, so we end up with "examplele"
|
||||
expectedResult := "This is a examplele string for modifications"
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected result to be %q, but got %q", expectedResult, result)
|
||||
}
|
||||
|
||||
if executed != 3 {
|
||||
t.Errorf("Expected 3 modifications to be executed, but got %d", executed)
|
||||
}
|
||||
}
|
||||
|
||||
// Modifications that would affect each other if not sorted properly
|
||||
func TestExecuteModificationsHandlesOverlappingRanges(t *testing.T) {
|
||||
// Setup test data
|
||||
fileData := "The quick brown fox jumps over the lazy dog"
|
||||
|
||||
modifications := []ReplaceCommand{
|
||||
{From: 4, To: 9, With: "slow"},
|
||||
{From: 10, To: 15, With: "red"},
|
||||
{From: 16, To: 19, With: "cat"},
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
result, executed := ExecuteModifications(modifications, fileData)
|
||||
|
||||
// Verify results
|
||||
expectedResult := "The slow red cat jumps over the lazy dog"
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected result to be %q, but got %q", expectedResult, result)
|
||||
}
|
||||
|
||||
if executed != 3 {
|
||||
t.Errorf("Expected 3 modifications to be executed, but got %d", executed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user