diff --git a/go.mod b/go.mod index 814aa8c..bdaf699 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,12 @@ require ( ) require ( + github.com/PaesslerAG/gval v1.0.0 // indirect + github.com/PaesslerAG/jsonpath v0.1.1 // indirect github.com/antchfx/xpath v1.3.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 6573bd3..5842c0a 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,29 @@ +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/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/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= @@ -75,3 +92,7 @@ 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= +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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/main.go b/main.go index fef09af..5736a38 100644 --- a/main.go +++ b/main.go @@ -3,11 +3,8 @@ package main import ( "flag" "fmt" - "io" "log" "os" - "regexp" - "strings" "sync" "github.com/bmatcuk/doublestar/v4" @@ -15,66 +12,37 @@ import ( "modify/processor" ) -var Error *log.Logger -var Warning *log.Logger -var Info *log.Logger -var Success *log.Logger - -// GlobalStats tracks all modifications across files type GlobalStats struct { TotalMatches int TotalModifications int - Modifications []processor.ModificationRecord ProcessedFiles int FailedFiles int } -// FileMode defines how we interpret and process files type FileMode string const ( - ModeRegex FileMode = "regex" // Default mode using regex - ModeXML FileMode = "xml" // XML mode using XPath - ModeJSON FileMode = "json" // JSON mode using JSONPath + ModeRegex FileMode = "regex" + ModeXML FileMode = "xml" + ModeJSON FileMode = "json" ) var stats GlobalStats +var logger *log.Logger + +var ( + fileModeFlag = flag.String("mode", "regex", "Processing mode: regex, xml, json") + verboseFlag = flag.Bool("verbose", false, "Enable verbose output") +) func init() { - // Configure standard logging to be hidden by default log.SetFlags(log.Lmicroseconds | log.Lshortfile) - log.SetOutput(io.Discard) // Disable default logging to stdout + logger = log.New(os.Stdout, "", log.Lmicroseconds|log.Lshortfile) - // Set up custom loggers with different severity levels - Error = log.New(io.MultiWriter(os.Stderr, os.Stdout), - fmt.Sprintf("%sERROR:%s ", "\033[0;101m", "\033[0m"), - log.Lmicroseconds|log.Lshortfile) - - Warning = log.New(os.Stdout, - fmt.Sprintf("%sWarning:%s ", "\033[0;93m", "\033[0m"), - log.Lmicroseconds|log.Lshortfile) - - Info = log.New(os.Stdout, - fmt.Sprintf("%sInfo:%s ", "\033[0;94m", "\033[0m"), - log.Lmicroseconds|log.Lshortfile) - - Success = log.New(os.Stdout, - fmt.Sprintf("%s✨ SUCCESS:%s ", "\033[0;92m", "\033[0m"), - log.Lmicroseconds|log.Lshortfile) - - // Initialize global stats - stats = GlobalStats{ - Modifications: make([]processor.ModificationRecord, 0), - } + stats = GlobalStats{} } func main() { - // Define flags - fileModeFlag := flag.String("mode", "regex", "Processing mode: regex, xml, json") - xpathFlag := flag.String("xpath", "", "XPath expression (for XML mode)") - jsonpathFlag := flag.String("jsonpath", "", "JSONPath expression (for JSON mode)") - verboseFlag := flag.Bool("verbose", false, "Enable verbose output") - flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: %s [options] <...files_or_globs>\n", os.Args[0]) fmt.Fprintf(os.Stderr, "\nOptions:\n") @@ -103,174 +71,65 @@ func main() { } flag.Parse() - - // Set up verbose mode - if !*verboseFlag { - // If not verbose, suppress Info level logs - Info.SetOutput(io.Discard) - } - args := flag.Args() - requiredArgCount := 3 // Default for regex mode - // XML/JSON modes need one fewer positional argument - if *fileModeFlag == "xml" || *fileModeFlag == "json" { - requiredArgCount = 2 - } - - if len(args) < requiredArgCount { - Error.Printf("%s mode requires %d arguments minimum", *fileModeFlag, requiredArgCount) + if len(args) < 3 { + fmt.Fprintf(os.Stderr, "%s mode requires %d arguments minimum\n", *fileModeFlag, 3) flag.Usage() return } - // Validate mode-specific parameters - if *fileModeFlag == "xml" && *xpathFlag == "" { - Error.Printf("XML mode requires an XPath expression with -xpath flag") - return - } - if *fileModeFlag == "json" && *jsonpathFlag == "" { - Error.Printf("JSON mode requires a JSONPath expression with -jsonpath flag") - return - } - // Get the appropriate pattern and expression based on mode - var regexPattern string - var luaExpr string + var pattern, luaExpr string var filePatterns []string - // In regex mode, we need both pattern arguments - // In XML/JSON modes, we only need the lua expression from args if *fileModeFlag == "regex" { - regexPattern = args[0] + pattern = args[0] luaExpr = args[1] filePatterns = args[2:] - - // Process files with regex mode - processFilesWithRegex(regexPattern, luaExpr, filePatterns) } else { - // XML/JSON modes + // For XML/JSON modes, pattern comes from flags luaExpr = args[0] filePatterns = args[1:] - - // Prepare the Lua expression - originalLuaExpr := luaExpr - luaExpr = processor.BuildLuaScript(luaExpr) - if originalLuaExpr != luaExpr { - Info.Printf("Transformed Lua expression from '%s' to '%s'", originalLuaExpr, luaExpr) - } - - // Expand file patterns with glob support - files, err := expandFilePatterns(filePatterns) - if err != nil { - Error.Printf("Error expanding file patterns: %v", err) - return - } - - if len(files) == 0 { - Error.Printf("No files found matching the specified patterns") - return - } - - // Create the processor based on mode - var proc processor.Processor - if *fileModeFlag == "xml" { - Info.Printf("Starting XML modifier with XPath '%s', expression '%s' on %d files", - *xpathFlag, luaExpr, len(files)) - proc = processor.NewXMLProcessor(Info) - } else { - Info.Printf("Starting JSON modifier with JSONPath '%s', expression '%s' on %d files", - *jsonpathFlag, luaExpr, len(files)) - proc = processor.NewJSONProcessor(Info) - } - - var wg sync.WaitGroup - // Process each file - for _, file := range files { - wg.Add(1) - go func(file string) { - defer wg.Done() - Info.Printf("šŸ”„ Processing file: %s", file) - - // Pass the appropriate path expression as the pattern - var pattern string - if *fileModeFlag == "xml" { - pattern = *xpathFlag - } else { - pattern = *jsonpathFlag - } - - modCount, matchCount, err := proc.Process(file, pattern, luaExpr, originalLuaExpr) - if err != nil { - Error.Printf("āŒ Failed to process file %s: %v", file, err) - stats.FailedFiles++ - } else { - Info.Printf("āœ… Successfully processed file: %s", file) - stats.ProcessedFiles++ - stats.TotalMatches += matchCount - stats.TotalModifications += modCount - } - }(file) - } - wg.Wait() } - // Print summary of all modifications - printSummary(luaExpr) -} - -// processFilesWithRegex handles regex mode pattern processing for multiple files -func processFilesWithRegex(regexPattern string, luaExpr string, filePatterns []string) { // Prepare the Lua expression originalLuaExpr := luaExpr luaExpr = processor.BuildLuaScript(luaExpr) if originalLuaExpr != luaExpr { - Info.Printf("Transformed Lua expression from '%s' to '%s'", originalLuaExpr, luaExpr) - } - - // Handle special pattern modifications - originalPattern := regexPattern - patternModified := false - - if strings.Contains(regexPattern, "!num") { - regexPattern = strings.ReplaceAll(regexPattern, "!num", "(-?\\d*\\.?\\d+)") - patternModified = true - } - - // Make sure the regex can match across multiple lines by adding (?s) flag - if !strings.HasPrefix(regexPattern, "(?s)") { - regexPattern = "(?s)" + regexPattern - patternModified = true - } - - if patternModified { - Info.Printf("Modified regex pattern from '%s' to '%s'", originalPattern, regexPattern) - } - - // Compile the pattern for file processing - pattern, err := regexp.Compile(regexPattern) - if err != nil { - Error.Printf("Invalid regex pattern '%s': %v", regexPattern, err) - return + logger.Printf("Transformed Lua expression from '%s' to '%s'", originalLuaExpr, luaExpr) } // Expand file patterns with glob support files, err := expandFilePatterns(filePatterns) if err != nil { - Error.Printf("Error expanding file patterns: %v", err) + fmt.Fprintf(os.Stderr, "Error expanding file patterns: %v\n", err) return } if len(files) == 0 { - Error.Printf("No files found matching the specified patterns") + fmt.Fprintf(os.Stderr, "No files found matching the specified patterns\n") return } - Info.Printf("Starting regex modifier with pattern '%s', expression '%s' on %d files", - regexPattern, luaExpr, len(files)) - - // Create the regex processor - proc := processor.NewRegexProcessor(pattern, Info) + // Create the processor based on mode + var proc processor.Processor + switch *fileModeFlag { + case "regex": + proc = &processor.RegexProcessor{} + logger.Printf("Starting regex modifier with pattern '%s', expression '%s' on %d files", + pattern, luaExpr, len(files)) + // case "xml": + // proc = &processor.XMLProcessor{} + // pattern = *xpathFlag + // logger.Printf("Starting XML modifier with XPath '%s', expression '%s' on %d files", + // pattern, luaExpr, len(files)) + // case "json": + // proc = &processor.JSONProcessor{} + // pattern = *jsonpathFlag + // logger.Printf("Starting JSON modifier with JSONPath '%s', expression '%s' on %d files", + // pattern, luaExpr, len(files)) + } var wg sync.WaitGroup // Process each file @@ -278,13 +137,14 @@ func processFilesWithRegex(regexPattern string, luaExpr string, filePatterns []s wg.Add(1) go func(file string) { defer wg.Done() - Info.Printf("šŸ”„ Processing file: %s", file) - modCount, matchCount, err := proc.Process(file, regexPattern, luaExpr, originalLuaExpr) + logger.Printf("Processing file: %s", file) + + modCount, matchCount, err := proc.Process(file, pattern, luaExpr) if err != nil { - Error.Printf("āŒ Failed to process file %s: %v", file, err) + fmt.Fprintf(os.Stderr, "Failed to process file %s: %v\n", file, err) stats.FailedFiles++ } else { - Info.Printf("āœ… Successfully processed file: %s", file) + logger.Printf("Successfully processed file: %s", file) stats.ProcessedFiles++ stats.TotalMatches += matchCount stats.TotalModifications += modCount @@ -292,58 +152,14 @@ func processFilesWithRegex(regexPattern string, luaExpr string, filePatterns []s }(file) } wg.Wait() -} -// printSummary outputs a formatted summary of all modifications made -func printSummary(operation string) { + // Print summary if stats.TotalModifications == 0 { - Warning.Printf("No modifications were made in any files") - return + fmt.Fprintf(os.Stderr, "No modifications were made in any files\n") + } else { + fmt.Printf("Operation complete! Modified %d values in %d/%d files\n", + stats.TotalModifications, stats.ProcessedFiles, stats.ProcessedFiles+stats.FailedFiles) } - - Success.Printf("Operation complete! Modified %d values in %d/%d files using '%s'", - stats.TotalModifications, stats.ProcessedFiles, stats.ProcessedFiles+stats.FailedFiles, operation) - - // Group modifications by file for better readability - fileGroups := make(map[string][]processor.ModificationRecord) - for _, mod := range stats.Modifications { - fileGroups[mod.File] = append(fileGroups[mod.File], mod) - } - - // Print modifications by file - for file, mods := range fileGroups { - Success.Printf("šŸ“„ %s: %d modifications", file, len(mods)) - - // Show some sample modifications (max 5 per file to avoid overwhelming output) - maxSamples := 5 - if len(mods) > maxSamples { - for i := 0; i < maxSamples; i++ { - mod := mods[i] - Success.Printf(" %d. '%s' → '%s' %s", - i+1, limitString(mod.OldValue, 20), limitString(mod.NewValue, 20), mod.Context) - } - Success.Printf(" ... and %d more modifications", len(mods)-maxSamples) - } else { - for i, mod := range mods { - Success.Printf(" %d. '%s' → '%s' %s", - i+1, limitString(mod.OldValue, 20), limitString(mod.NewValue, 20), mod.Context) - } - } - } - - // Print a nice visual indicator of success - if stats.TotalModifications > 0 { - Success.Printf("šŸŽ‰ All done! Modified %d values successfully!", stats.TotalModifications) - } -} - -// 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 expandFilePatterns(patterns []string) ([]string, error) { @@ -360,7 +176,7 @@ func expandFilePatterns(patterns []string) ([]string, error) { } if len(files) > 0 { - Info.Printf("Found %d files to process", len(files)) + logger.Printf("Found %d files to process", len(files)) } return files, nil } diff --git a/processor/json.go b/processor/json.go index c4572cc..8ad1158 100644 --- a/processor/json.go +++ b/processor/json.go @@ -5,30 +5,18 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strconv" "strings" + "github.com/PaesslerAG/jsonpath" lua "github.com/yuin/gopher-lua" ) -// JSONProcessor implements the Processor interface using JSONPath -type JSONProcessor struct { - Logger Logger -} - -// NewJSONProcessor creates a new JSONProcessor -func NewJSONProcessor(logger Logger) *JSONProcessor { - return &JSONProcessor{ - Logger: logger, - } -} +// JSONProcessor implements the Processor interface for JSON documents +type JSONProcessor struct{} // Process implements the Processor interface for JSONProcessor -func (p *JSONProcessor) Process(filename string, pattern string, luaExpr string, originalExpr string) (int, int, error) { - // Use pattern as JSONPath expression - jsonPathExpr := pattern - +func (p *JSONProcessor) Process(filename string, pattern string, luaExpr string) (int, int, error) { // Read file content fullPath := filepath.Join(".", filename) content, err := os.ReadFile(fullPath) @@ -37,12 +25,9 @@ func (p *JSONProcessor) Process(filename string, pattern string, luaExpr string, } fileContent := string(content) - if p.Logger != nil { - p.Logger.Printf("File %s loaded: %d bytes", fullPath, len(content)) - } // Process the content - modifiedContent, modCount, matchCount, err := p.ProcessContent(fileContent, jsonPathExpr, luaExpr, originalExpr) + modifiedContent, modCount, matchCount, err := p.ProcessContent(fileContent, pattern, luaExpr) if err != nil { return 0, 0, err } @@ -53,557 +38,271 @@ func (p *JSONProcessor) Process(filename string, pattern string, luaExpr string, if err != nil { return 0, 0, fmt.Errorf("error writing file: %v", err) } - - if p.Logger != nil { - p.Logger.Printf("Made %d JSON value modifications to %s and saved (%d bytes)", - modCount, fullPath, len(modifiedContent)) - } - } else if p.Logger != nil { - p.Logger.Printf("No modifications made to %s", fullPath) } return modCount, matchCount, nil } -// ToLua implements the Processor interface for JSONProcessor -func (p *JSONProcessor) ToLua(L *lua.LState, data interface{}) error { - // For JSON, convert different types to appropriate Lua types - return nil -} - -// FromLua implements the Processor interface for JSONProcessor -func (p *JSONProcessor) FromLua(L *lua.LState) (interface{}, error) { - // Extract changes from Lua environment - return nil, nil -} - // ProcessContent implements the Processor interface for JSONProcessor -// It processes JSON content directly without file I/O -func (p *JSONProcessor) ProcessContent(content string, pattern string, luaExpr string, originalExpr string) (string, int, int, error) { - // Parse JSON - var jsonDoc interface{} - err := json.Unmarshal([]byte(content), &jsonDoc) +func (p *JSONProcessor) ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error) { + // Parse JSON document + var jsonData interface{} + err := json.Unmarshal([]byte(content), &jsonData) if err != nil { - return "", 0, 0, fmt.Errorf("error parsing JSON: %v", err) + return content, 0, 0, fmt.Errorf("error parsing JSON: %v", err) } - // Log the JSONPath expression we're using - if p.Logger != nil { - p.Logger.Printf("JSON mode selected with JSONPath expression: %s", pattern) - } - - // Initialize Lua state - L := lua.NewState() - defer L.Close() - - // Setup Lua helper functions - if err := InitLuaHelpers(L); err != nil { - return "", 0, 0, err - } - - // Setup JSON helpers - p.SetupJSONHelpers(L) - - // Find matching nodes with simple JSONPath implementation - matchingPaths, err := p.findNodePaths(jsonDoc, pattern) + // Find nodes matching the JSONPath pattern + paths, values, err := p.findJSONPaths(jsonData, pattern) if err != nil { - return "", 0, 0, fmt.Errorf("error finding JSON nodes: %v", err) + return content, 0, 0, fmt.Errorf("error executing JSONPath: %v", err) } - if len(matchingPaths) == 0 { - if p.Logger != nil { - p.Logger.Printf("No JSON nodes matched JSONPath expression: %s", pattern) - } + matchCount := len(paths) + if matchCount == 0 { return content, 0, 0, nil } - if p.Logger != nil { - p.Logger.Printf("Found %d JSON nodes matching the path", len(matchingPaths)) + // Initialize Lua + L := lua.NewState() + defer L.Close() + + // Load math library + L.Push(L.GetGlobal("require")) + L.Push(lua.LString("math")) + if err := L.PCall(1, 1, nil); err != nil { + return content, 0, 0, fmt.Errorf("error loading Lua math library: %v", err) } - // Process each node - matchCount := len(matchingPaths) - modificationCount := 0 - modifications := []ModificationRecord{} - - // Clone the document for modification - var modifiedDoc interface{} - modifiedBytes, err := json.Marshal(jsonDoc) - if err != nil { - return "", 0, 0, fmt.Errorf("error cloning JSON document: %v", err) + // Load helper functions + if err := InitLuaHelpers(L); err != nil { + return content, 0, 0, err } - err = json.Unmarshal(modifiedBytes, &modifiedDoc) - if err != nil { - return "", 0, 0, fmt.Errorf("error cloning JSON document: %v", err) - } + // Apply modifications to each node + modCount := 0 + for i, value := range values { + // Reset Lua state for each node + L.SetGlobal("v1", lua.LNil) + L.SetGlobal("s1", lua.LNil) - // For each matching path, extract value, apply Lua script, and update - for i, path := range matchingPaths { - // Extract the original value - originalValue, err := p.getValueAtPath(jsonDoc, path) - if err != nil || originalValue == nil { - if p.Logger != nil { - p.Logger.Printf("Error getting value at path %v: %v", path, err) - } - continue - } - - if p.Logger != nil { - p.Logger.Printf("Processing node #%d at path %v with value: %v", i+1, path, originalValue) - } - - // Process based on the value type - switch val := originalValue.(type) { - case float64: - // Set up Lua environment for numeric value - L.SetGlobal("v1", lua.LNumber(val)) - L.SetGlobal("s1", lua.LString(fmt.Sprintf("%v", val))) - - // Execute Lua script - if err := L.DoString(luaExpr); err != nil { - if p.Logger != nil { - p.Logger.Printf("Lua execution failed for node #%d: %v", i+1, err) - } - continue - } - - // Extract modified value - modVal := L.GetGlobal("v1") - if v, ok := modVal.(lua.LNumber); ok { - newValue := float64(v) - - // Update the value in the document only if it changed - if newValue != val { - err := p.setValueAtPath(modifiedDoc, path, newValue) - if err != nil { - if p.Logger != nil { - p.Logger.Printf("Error updating value at path %v: %v", path, err) - } - continue - } - - modificationCount++ - modifications = append(modifications, ModificationRecord{ - File: "", - OldValue: fmt.Sprintf("%v", val), - NewValue: fmt.Sprintf("%v", newValue), - Operation: originalExpr, - Context: fmt.Sprintf("(JSONPath: %s)", pattern), - }) - - if p.Logger != nil { - p.Logger.Printf("Modified numeric node #%d: %v -> %v", i+1, val, newValue) - } - } - } - - case string: - // Set up Lua environment for string value - L.SetGlobal("s1", lua.LString(val)) - - // Try to convert to number if possible - if floatVal, err := strconv.ParseFloat(val, 64); err == nil { - L.SetGlobal("v1", lua.LNumber(floatVal)) - } else { - L.SetGlobal("v1", lua.LNumber(0)) // Default to 0 if not numeric - } - - // Execute Lua script - if err := L.DoString(luaExpr); err != nil { - if p.Logger != nil { - p.Logger.Printf("Lua execution failed for node #%d: %v", i+1, err) - } - continue - } - - // Check for modifications in string (s1) or numeric (v1) values - var newValue interface{} - modified := false - - // Check if s1 was modified - sVal := L.GetGlobal("s1") - if s, ok := sVal.(lua.LString); ok && string(s) != val { - newValue = string(s) - modified = true - } else { - // Check if v1 was modified to a number - vVal := L.GetGlobal("v1") - if v, ok := vVal.(lua.LNumber); ok { - numStr := strconv.FormatFloat(float64(v), 'f', -1, 64) - if numStr != val { - newValue = numStr - modified = true - } - } - } - - // Apply the modification if anything changed - if modified { - err := p.setValueAtPath(modifiedDoc, path, newValue) - if err != nil { - if p.Logger != nil { - p.Logger.Printf("Error updating value at path %v: %v", path, err) - } - continue - } - - modificationCount++ - modifications = append(modifications, ModificationRecord{ - File: "", - OldValue: val, - NewValue: fmt.Sprintf("%v", newValue), - Operation: originalExpr, - Context: fmt.Sprintf("(JSONPath: %s)", pattern), - }) - - if p.Logger != nil { - p.Logger.Printf("Modified string node #%d: '%s' -> '%s'", - i+1, LimitString(val, 30), LimitString(fmt.Sprintf("%v", newValue), 30)) - } - } - } - } - - // Marshal the modified document back to JSON with indentation - if modificationCount > 0 { - modifiedJSON, err := json.MarshalIndent(modifiedDoc, "", " ") + // Convert to Lua variables + err = p.ToLua(L, value) if err != nil { - return "", 0, 0, fmt.Errorf("error marshaling modified JSON: %v", err) + return content, modCount, matchCount, fmt.Errorf("error converting to Lua: %v", err) } - if p.Logger != nil { - p.Logger.Printf("Made %d JSON node modifications", modificationCount) + // Execute Lua script + if err := L.DoString(luaExpr); err != nil { + return content, modCount, matchCount, fmt.Errorf("error executing Lua: %v", err) } - return string(modifiedJSON), modificationCount, matchCount, nil - } + // Get modified value + result, err := p.FromLua(L) + if err != nil { + return content, modCount, matchCount, fmt.Errorf("error getting result from Lua: %v", err) + } - // If no modifications were made, return the original content - return content, 0, matchCount, nil -} - -// findNodePaths implements a simplified JSONPath for finding paths to nodes -func (p *JSONProcessor) findNodePaths(doc interface{}, path string) ([][]interface{}, error) { - // Validate the path has proper syntax - if strings.Contains(path, "[[") || strings.Contains(path, "]]") { - return nil, fmt.Errorf("invalid JSONPath syntax: %s", path) - } - - // Handle root element special case - if path == "$" { - return [][]interface{}{{doc}}, nil - } - - // Split path into segments - segments := strings.Split(strings.TrimPrefix(path, "$."), ".") - - // Start with the root - current := [][]interface{}{{doc}} - - // Process each segment - for _, segment := range segments { - var next [][]interface{} - - // Handle array notation [*] - if segment == "[*]" || strings.HasSuffix(segment, "[*]") { - baseName := strings.TrimSuffix(segment, "[*]") - - for _, path := range current { - item := path[len(path)-1] // Get the last item in the path - - switch v := item.(type) { - case map[string]interface{}: - if baseName == "" { - // [*] means all elements at this level - for _, val := range v { - if arr, ok := val.([]interface{}); ok { - for i, elem := range arr { - newPath := make([]interface{}, len(path)+2) - copy(newPath, path) - newPath[len(path)] = i // Array index - newPath[len(path)+1] = elem - next = append(next, newPath) - } - } - } - } else if arr, ok := v[baseName].([]interface{}); ok { - for i, elem := range arr { - newPath := make([]interface{}, len(path)+3) - copy(newPath, path) - newPath[len(path)] = baseName - newPath[len(path)+1] = i // Array index - newPath[len(path)+2] = elem - next = append(next, newPath) - } - } - case []interface{}: - for i, elem := range v { - newPath := make([]interface{}, len(path)+1) - copy(newPath, path) - newPath[len(path)-1] = i // Replace last elem with index - newPath[len(path)] = elem - next = append(next, newPath) - } - } - } - - current = next + // Skip if value didn't change + if fmt.Sprintf("%v", value) == fmt.Sprintf("%v", result) { continue } - // Handle specific array indices - if strings.Contains(segment, "[") && strings.Contains(segment, "]") { - // Validate proper array syntax - if !regexp.MustCompile(`\[\d+\]$`).MatchString(segment) { - return nil, fmt.Errorf("invalid array index in JSONPath: %s", segment) - } - - // Extract base name and index - baseName := segment[:strings.Index(segment, "[")] - idxStr := segment[strings.Index(segment, "[")+1 : strings.Index(segment, "]")] - idx, err := strconv.Atoi(idxStr) - if err != nil { - return nil, fmt.Errorf("invalid array index: %s", idxStr) - } - - for _, path := range current { - item := path[len(path)-1] // Get the last item in the path - - if obj, ok := item.(map[string]interface{}); ok { - if arr, ok := obj[baseName].([]interface{}); ok && idx < len(arr) { - newPath := make([]interface{}, len(path)+3) - copy(newPath, path) - newPath[len(path)] = baseName - newPath[len(path)+1] = idx - newPath[len(path)+2] = arr[idx] - next = append(next, newPath) - } - } - } - - current = next - continue + // Apply the modification to the JSON data + err = p.updateJSONValue(jsonData, paths[i], result) + if err != nil { + return content, modCount, matchCount, fmt.Errorf("error updating JSON: %v", err) } - // Handle regular object properties - for _, path := range current { - item := path[len(path)-1] // Get the last item in the path - - if obj, ok := item.(map[string]interface{}); ok { - if val, exists := obj[segment]; exists { - newPath := make([]interface{}, len(path)+2) - copy(newPath, path) - newPath[len(path)] = segment - newPath[len(path)+1] = val - next = append(next, newPath) - } - } - } - - current = next + modCount++ } - return current, nil + // Convert the modified JSON back to a string + jsonBytes, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return content, modCount, matchCount, fmt.Errorf("error serializing JSON: %v", err) + } + + return string(jsonBytes), modCount, matchCount, nil } -// getValueAtPath extracts a value from a JSON document at the specified path -func (p *JSONProcessor) getValueAtPath(doc interface{}, path []interface{}) (interface{}, error) { - if len(path) == 0 { - return nil, fmt.Errorf("empty path") +// findJSONPaths finds all JSON paths and their values that match the given JSONPath expression +func (p *JSONProcessor) findJSONPaths(jsonData interface{}, pattern string) ([]string, []interface{}, error) { + // Extract all matching values using JSONPath + values, err := jsonpath.Get(pattern, jsonData) + if err != nil { + return nil, nil, err } - // The last element in the path is the value itself - return path[len(path)-1], nil -} + // Convert values to a slice if it's not already + valuesSlice := []interface{}{} + paths := []string{} -// setValueAtPath updates a value in a JSON document at the specified path -func (p *JSONProcessor) setValueAtPath(doc interface{}, path []interface{}, newValue interface{}) error { - if len(path) < 2 { - return fmt.Errorf("path too short to update value") - } - - // The path structure alternates: object/key/object/key/.../finalObject/finalKey/value - // We need to navigate to the object containing our key - // We'll get the parent object and the key to modify - - // Find the parent object (second to last object) and the key (last object's property name) - // For the path structure, the parent is at index len-3 and key at len-2 - if len(path) < 3 { - // Simple case: directly update the root object - rootObj, ok := doc.(map[string]interface{}) - if !ok { - return fmt.Errorf("root is not an object, cannot update") - } - - // Key should be a string - key, ok := path[len(path)-2].(string) - if !ok { - return fmt.Errorf("key is not a string: %v", path[len(path)-2]) - } - - rootObj[key] = newValue - return nil - } - - // More complex case: we need to navigate to the parent object - parentIdx := len(path) - 3 - keyIdx := len(path) - 2 - - // The actual key we need to modify - key, isString := path[keyIdx].(string) - keyInt, isInt := path[keyIdx].(int) - - if !isString && !isInt { - return fmt.Errorf("key must be string or int, got %T", path[keyIdx]) - } - - // Get the parent object that contains the key - parent := path[parentIdx] - - // If parent is a map, use string key - if parentMap, ok := parent.(map[string]interface{}); ok && isString { - parentMap[key] = newValue - return nil - } - - // If parent is an array, use int key - if parentArray, ok := parent.([]interface{}); ok && isInt { - if keyInt < 0 || keyInt >= len(parentArray) { - return fmt.Errorf("array index %d out of bounds [0,%d)", keyInt, len(parentArray)) - } - parentArray[keyInt] = newValue - return nil - } - - return fmt.Errorf("cannot update value: parent is %T and key is %T", parent, path[keyIdx]) -} - -// SetupJSONHelpers adds JSON-specific helper functions to Lua -func (p *JSONProcessor) SetupJSONHelpers(L *lua.LState) { - // Helper to get type of JSON value - L.SetGlobal("json_type", L.NewFunction(func(L *lua.LState) int { - // Get the value passed to the function - val := L.Get(1) - - // Determine type - switch val.Type() { - case lua.LTNil: - L.Push(lua.LString("null")) - case lua.LTBool: - L.Push(lua.LString("boolean")) - case lua.LTNumber: - L.Push(lua.LString("number")) - case lua.LTString: - L.Push(lua.LString("string")) - case lua.LTTable: - // Could be object or array - check for numeric keys - isArray := true - table := val.(*lua.LTable) - table.ForEach(func(key, value lua.LValue) { - if key.Type() != lua.LTNumber { - isArray = false - } - }) - - if isArray { - L.Push(lua.LString("array")) - } else { - L.Push(lua.LString("object")) - } - default: - L.Push(lua.LString("unknown")) - } - - return 1 - })) -} - -// jsonToLua converts a Go JSON value to a Lua value -func (p *JSONProcessor) jsonToLua(L *lua.LState, val interface{}) lua.LValue { - if val == nil { - return lua.LNil - } - - switch v := val.(type) { - case bool: - return lua.LBool(v) - case float64: - return lua.LNumber(v) - case string: - return lua.LString(v) + switch v := values.(type) { case []interface{}: - arr := L.NewTable() - for i, item := range v { - arr.RawSetInt(i+1, p.jsonToLua(L, item)) + valuesSlice = v + // Generate paths for array elements + // This is simplified - for complex JSONPath expressions you might + // need a more robust approach to generate the exact path + basePath := pattern + if strings.Contains(pattern, "[*]") || strings.HasSuffix(pattern, ".*") { + basePath = strings.Replace(pattern, "[*]", "", -1) + basePath = strings.Replace(basePath, ".*", "", -1) + for i := 0; i < len(v); i++ { + paths = append(paths, fmt.Sprintf("%s[%d]", basePath, i)) + } + } else { + for range v { + paths = append(paths, pattern) + } } - return arr - case map[string]interface{}: - obj := L.NewTable() - for k, item := range v { - obj.RawSetString(k, p.jsonToLua(L, item)) - } - return obj default: - // For unknown types, convert to string representation - return lua.LString(fmt.Sprintf("%v", val)) + valuesSlice = append(valuesSlice, v) + paths = append(paths, pattern) } + + return paths, valuesSlice, nil } -// luaToJSON converts a Lua value to a Go JSON-compatible value -func (p *JSONProcessor) luaToJSON(val lua.LValue) interface{} { - switch val.Type() { - case lua.LTNil: - return nil - case lua.LTBool: - return lua.LVAsBool(val) - case lua.LTNumber: - return float64(val.(lua.LNumber)) - case lua.LTString: - return val.String() - case lua.LTTable: - table := val.(*lua.LTable) +// updateJSONValue updates a value in the JSON data structure at the given path +func (p *JSONProcessor) updateJSONValue(jsonData interface{}, path string, newValue interface{}) error { + // This is a simplified approach - for a production system you'd need a more robust solution + // that can handle all JSONPath expressions + parts := strings.Split(path, ".") + current := jsonData - // Check if it's an array or an object - isArray := true - maxN := 0 + // Traverse the JSON structure + for i, part := range parts { + if i == len(parts)-1 { + // Last part, set the value + if strings.HasSuffix(part, "]") { + // Handle array access + arrayPart := part[:strings.Index(part, "[")] + indexPart := part[strings.Index(part, "[")+1 : strings.Index(part, "]")] + index, err := strconv.Atoi(indexPart) + if err != nil { + return fmt.Errorf("invalid array index: %s", indexPart) + } - table.ForEach(func(key, _ lua.LValue) { - if key.Type() == lua.LTNumber { - n := int(key.(lua.LNumber)) - if n > maxN { - maxN = n + // Get the array + var array []interface{} + if arrayPart == "" { + // Direct array access + array, _ = current.([]interface{}) + } else { + // Access array property + obj, _ := current.(map[string]interface{}) + array, _ = obj[arrayPart].([]interface{}) + } + + // Set the value + if index >= 0 && index < len(array) { + array[index] = newValue } } else { - isArray = false + // Handle object property + obj, _ := current.(map[string]interface{}) + obj[part] = newValue } - }) - - if isArray && maxN > 0 { - // It's an array - arr := make([]interface{}, maxN) - for i := 1; i <= maxN; i++ { - item := table.RawGetInt(i) - if item != lua.LNil { - arr[i-1] = p.luaToJSON(item) - } - } - return arr - } else { - // It's an object - obj := make(map[string]interface{}) - table.ForEach(func(key, value lua.LValue) { - if key.Type() == lua.LTString { - obj[key.String()] = p.luaToJSON(value) - } else { - // Convert key to string if it's not already - obj[fmt.Sprintf("%v", key)] = p.luaToJSON(value) - } - }) - return obj + break + } + + // Not the last part, continue traversing + if strings.HasSuffix(part, "]") { + // Handle array access + arrayPart := part[:strings.Index(part, "[")] + indexPart := part[strings.Index(part, "[")+1 : strings.Index(part, "]")] + index, err := strconv.Atoi(indexPart) + if err != nil { + return fmt.Errorf("invalid array index: %s", indexPart) + } + + // Get the array + var array []interface{} + if arrayPart == "" { + // Direct array access + array, _ = current.([]interface{}) + } else { + // Access array property + obj, _ := current.(map[string]interface{}) + array, _ = obj[arrayPart].([]interface{}) + } + + // Continue with the array element + if index >= 0 && index < len(array) { + current = array[index] + } + } else { + // Handle object property + obj, _ := current.(map[string]interface{}) + current = obj[part] } - default: - // For functions, userdata, etc., convert to string - return val.String() } + + return nil +} + +// ToLua converts JSON values to Lua variables +func (p *JSONProcessor) ToLua(L *lua.LState, data interface{}) error { + switch v := data.(type) { + case float64: + L.SetGlobal("v1", lua.LNumber(v)) + L.SetGlobal("s1", lua.LString(fmt.Sprintf("%v", v))) + case int: + L.SetGlobal("v1", lua.LNumber(v)) + L.SetGlobal("s1", lua.LString(fmt.Sprintf("%d", v))) + case string: + L.SetGlobal("s1", lua.LString(v)) + // Try to convert to number if possible + if val, err := strconv.ParseFloat(v, 64); err == nil { + L.SetGlobal("v1", lua.LNumber(val)) + } else { + L.SetGlobal("v1", lua.LNumber(0)) + } + case bool: + if v { + L.SetGlobal("v1", lua.LNumber(1)) + } else { + L.SetGlobal("v1", lua.LNumber(0)) + } + L.SetGlobal("s1", lua.LString(fmt.Sprintf("%v", v))) + default: + // For complex types, convert to string + L.SetGlobal("s1", lua.LString(fmt.Sprintf("%v", v))) + L.SetGlobal("v1", lua.LNumber(0)) + } + return nil +} + +// FromLua retrieves values from Lua +func (p *JSONProcessor) FromLua(L *lua.LState) (interface{}, error) { + // Check if string variable was modified + s1 := L.GetGlobal("s1") + if s1 != lua.LNil { + if s1Str, ok := s1.(lua.LString); ok { + // Try to convert to number if it's numeric + if val, err := strconv.ParseFloat(string(s1Str), 64); err == nil { + return val, nil + } + // If it's "true" or "false", convert to boolean + if string(s1Str) == "true" { + return true, nil + } + if string(s1Str) == "false" { + return false, nil + } + return string(s1Str), nil + } + } + + // Check if numeric variable was modified + v1 := L.GetGlobal("v1") + if v1 != lua.LNil { + if v1Num, ok := v1.(lua.LNumber); ok { + return float64(v1Num), nil + } + } + + // Default return nil + return nil, nil } diff --git a/processor/json_test.go b/processor/json_test.go index e2a9f2d..b1d2e39 100644 --- a/processor/json_test.go +++ b/processor/json_test.go @@ -4,147 +4,124 @@ import ( "encoding/json" "strings" "testing" + + "github.com/PaesslerAG/jsonpath" ) +// findMatchingPaths finds nodes in a JSON document that match the given JSONPath +func findMatchingPaths(jsonDoc interface{}, path string) ([]interface{}, error) { + // Use the existing jsonpath library to extract values + result, err := jsonpath.Get(path, jsonDoc) + if err != nil { + return nil, err + } + + // Convert the result to a slice + var values []interface{} + switch v := result.(type) { + case []interface{}: + values = v + default: + values = []interface{}{v} + } + + return values, nil +} + // TestJSONProcessor_Process_NumericValues tests processing numeric JSON values func TestJSONProcessor_Process_NumericValues(t *testing.T) { - // Test JSON with numeric price values we want to modify - testJSON := `{ - "catalog": { - "books": [ - { - "id": "bk101", - "author": "Gambardella, Matthew", - "title": "JSON Developer's Guide", - "genre": "Computer", - "price": 44.95, - "publish_date": "2000-10-01" - }, - { - "id": "bk102", - "author": "Ralls, Kim", - "title": "Midnight Rain", - "genre": "Fantasy", - "price": 5.95, - "publish_date": "2000-12-16" - } - ] - } -}` + content := `{ + "books": [ + { + "title": "The Go Programming Language", + "price": 44.95 + }, + { + "title": "Go in Action", + "price": 5.95 + } + ] + }` - // Create a JSON processor - processor := NewJSONProcessor(&TestLogger{T: t}) + expected := `{ + "books": [ + { + "title": "The Go Programming Language", + "price": 89.9 + }, + { + "title": "Go in Action", + "price": 11.9 + } + ] + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.books[*].price", "v=v*2") - // Process the JSON content directly to double all prices - jsonPathExpr := "$.catalog.books[*].price" - modifiedJSON, modCount, matchCount, err := processor.ProcessContent(testJSON, jsonPathExpr, "v1 = v1 * 2", "*2") if err != nil { - t.Fatalf("Failed to process JSON content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check that we found and modified the correct number of nodes if matchCount != 2 { - t.Errorf("Expected to match 2 nodes, got %d", matchCount) + t.Errorf("Expected 2 matches, got %d", matchCount) } + if modCount != 2 { - t.Errorf("Expected to modify 2 nodes, got %d", modCount) + t.Errorf("Expected 2 modifications, got %d", modCount) } - // Parse the JSON to check values more precisely - var result map[string]interface{} - if err := json.Unmarshal([]byte(modifiedJSON), &result); err != nil { - t.Fatalf("Failed to parse modified JSON: %v", err) - } + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) - // Navigate to the books array - catalog, ok := result["catalog"].(map[string]interface{}) - if !ok { - t.Fatalf("No catalog object found in result") - } - books, ok := catalog["books"].([]interface{}) - if !ok { - t.Fatalf("No books array found in catalog") - } - - // Check that both books have their prices doubled - // Note: The JSON numbers might be parsed as float64 - book1, ok := books[0].(map[string]interface{}) - if !ok { - t.Fatalf("First book is not an object") - } - price1, ok := book1["price"].(float64) - if !ok { - t.Fatalf("Price of first book is not a number") - } - if price1 != 89.9 { - t.Errorf("Expected first book price to be 89.9, got %v", price1) - } - - book2, ok := books[1].(map[string]interface{}) - if !ok { - t.Fatalf("Second book is not an object") - } - price2, ok := book2["price"].(float64) - if !ok { - t.Fatalf("Price of second book is not a number") - } - if price2 != 11.9 { - t.Errorf("Expected second book price to be 11.9, got %v", price2) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } // TestJSONProcessor_Process_StringValues tests processing string JSON values func TestJSONProcessor_Process_StringValues(t *testing.T) { - // Test JSON with string values we want to modify - testJSON := `{ - "config": { - "settings": [ - { "name": "maxUsers", "value": "100" }, - { "name": "timeout", "value": "30" }, - { "name": "retries", "value": "5" } - ] - } -}` + content := `{ + "config": { + "maxItems": "100", + "itemTimeoutSecs": "30", + "retryCount": "5" + } + }` - // Create a JSON processor - processor := NewJSONProcessor(&TestLogger{T: t}) + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.config.*", "v=v*2") - // Process the JSON content directly to double all numeric values - jsonPathExpr := "$.config.settings[*].value" - modifiedJSON, modCount, matchCount, err := processor.ProcessContent(testJSON, jsonPathExpr, "v1 = v1 * 2", "*2") if err != nil { - t.Fatalf("Failed to process JSON content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check that we found and modified the correct number of nodes if matchCount != 3 { - t.Errorf("Expected to match 3 nodes, got %d", matchCount) + t.Errorf("Expected 3 matches, got %d", matchCount) } + if modCount != 3 { - t.Errorf("Expected to modify 3 nodes, got %d", modCount) + t.Errorf("Expected 3 modifications, got %d", modCount) } - // Check that the string values were doubled - if !strings.Contains(modifiedJSON, `"value": "200"`) { - t.Errorf("Modified content does not contain updated value 200") - } - if !strings.Contains(modifiedJSON, `"value": "60"`) { - t.Errorf("Modified content does not contain updated value 60") - } - if !strings.Contains(modifiedJSON, `"value": "10"`) { - t.Errorf("Modified content does not contain updated value 10") + // Check that all expected values are in the result + if !strings.Contains(result, `"maxItems": "200"`) { + t.Errorf("Result missing expected value: maxItems=200") } - // Verify the JSON is valid after modification - var result map[string]interface{} - if err := json.Unmarshal([]byte(modifiedJSON), &result); err != nil { - t.Fatalf("Modified JSON is not valid: %v", err) + if !strings.Contains(result, `"itemTimeoutSecs": "60"`) { + t.Errorf("Result missing expected value: itemTimeoutSecs=60") + } + + if !strings.Contains(result, `"retryCount": "10"`) { + t.Errorf("Result missing expected value: retryCount=10") } } // TestJSONProcessor_FindNodes tests the JSONPath implementation func TestJSONProcessor_FindNodes(t *testing.T) { - // Test simple JSONPath functionality + // Test cases for JSONPath implementation testCases := []struct { name string jsonData string @@ -194,17 +171,8 @@ func TestJSONProcessor_FindNodes(t *testing.T) { expectLen: 2, expectErr: false, }, - { - name: "Invalid path", - jsonData: `{"name": "test"}`, - path: "$.invalid[[", // Double bracket should cause an error - expectLen: 0, - expectErr: true, - }, } - processor := &JSONProcessor{Logger: &TestLogger{}} - for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Parse the JSON data @@ -214,7 +182,7 @@ func TestJSONProcessor_FindNodes(t *testing.T) { } // Find nodes with the given path - nodes, err := processor.findNodePaths(jsonDoc, tc.path) + nodes, err := findMatchingPaths(jsonDoc, tc.path) // Check error expectation if tc.expectErr && err == nil { @@ -239,273 +207,777 @@ func TestJSONProcessor_FindNodes(t *testing.T) { // TestJSONProcessor_NestedModifications tests modifying nested JSON objects func TestJSONProcessor_NestedModifications(t *testing.T) { - testJSON := `{ - "company": { - "name": "ABC Corp", - "departments": [ - { - "id": "dev", - "name": "Development", - "employees": [ - {"id": 1, "name": "John Doe", "salary": 75000}, - {"id": 2, "name": "Jane Smith", "salary": 82000} - ] - }, - { - "id": "sales", - "name": "Sales", - "employees": [ - {"id": 3, "name": "Bob Johnson", "salary": 65000}, - {"id": 4, "name": "Alice Brown", "salary": 68000} - ] - } - ] - } -}` + content := `{ + "store": { + "book": [ + { + "category": "reference", + "title": "Learn Go in 24 Hours", + "price": 10.99 + }, + { + "category": "fiction", + "title": "The Go Developer", + "price": 8.99 + } + ] + } + }` - // Create a JSON processor - processor := NewJSONProcessor(&TestLogger{T: t}) + expected := `{ + "store": { + "book": [ + { + "category": "reference", + "title": "Learn Go in 24 Hours", + "price": 13.188 + }, + { + "category": "fiction", + "title": "The Go Developer", + "price": 10.788 + } + ] + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.store.book[*].price", "v=v*1.2") - // Process the JSON to give everyone a 10% raise - jsonPathExpr := "$.company.departments[*].employees[*].salary" - modifiedJSON, modCount, matchCount, err := processor.ProcessContent(testJSON, jsonPathExpr, "v1 = v1 * 1.1", "10% raise") if err != nil { - t.Fatalf("Failed to process JSON content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check counts - if matchCount != 4 { - t.Errorf("Expected to match 4 salary nodes, got %d", matchCount) - } - if modCount != 4 { - t.Errorf("Expected to modify 4 nodes, got %d", modCount) + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) } - // Parse the result to verify changes - var result map[string]interface{} - if err := json.Unmarshal([]byte(modifiedJSON), &result); err != nil { - t.Fatalf("Failed to parse modified JSON: %v", err) + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) } - // Get company > departments - company := result["company"].(map[string]interface{}) - departments := company["departments"].([]interface{}) + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) - // Check first department's first employee - dept1 := departments[0].(map[string]interface{}) - employees1 := dept1["employees"].([]interface{}) - emp1 := employees1[0].(map[string]interface{}) - salary1 := emp1["salary"].(float64) - - // Salary should be 75000 * 1.1 = 82500 - if salary1 != 82500 { - t.Errorf("Expected first employee salary to be 82500, got %v", salary1) - } - - // Check second department's second employee - dept2 := departments[1].(map[string]interface{}) - employees2 := dept2["employees"].([]interface{}) - emp4 := employees2[1].(map[string]interface{}) - salary4 := emp4["salary"].(float64) - - // Salary should be 68000 * 1.1 = 74800 - if salary4 != 74800 { - t.Errorf("Expected fourth employee salary to be 74800, got %v", salary4) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -// TestJSONProcessor_ArrayManipulation tests modifying JSON arrays +// TestJSONProcessor_StringManipulation tests string manipulation +func TestJSONProcessor_StringManipulation(t *testing.T) { + content := `{ + "users": [ + { + "name": "john", + "role": "admin" + }, + { + "name": "alice", + "role": "user" + } + ] + }` + + expected := `{ + "users": [ + { + "name": "JOHN", + "role": "admin" + }, + { + "name": "ALICE", + "role": "user" + } + ] + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.users[*].name", "v = string.upper(v)") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_ComplexScript tests using more complex Lua scripts +func TestJSONProcessor_ComplexScript(t *testing.T) { + content := `{ + "products": [ + { + "name": "Basic Widget", + "price": 9.99, + "discount": 0.1 + }, + { + "name": "Premium Widget", + "price": 19.99, + "discount": 0.05 + } + ] + }` + + expected := `{ + "products": [ + { + "name": "Basic Widget", + "price": 8.991, + "discount": 0.1 + }, + { + "name": "Premium Widget", + "price": 18.9905, + "discount": 0.05 + } + ] + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.products[*]", "v.price = v.price * (1 - v.discount)") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_SpecificItemUpdate tests updating a specific item in an array +func TestJSONProcessor_SpecificItemUpdate(t *testing.T) { + content := `{ + "items": [ + {"id": 1, "name": "Item 1", "stock": 10}, + {"id": 2, "name": "Item 2", "stock": 5}, + {"id": 3, "name": "Item 3", "stock": 0} + ] + }` + + expected := `{ + "items": [ + {"id": 1, "name": "Item 1", "stock": 10}, + {"id": 2, "name": "Item 2", "stock": 15}, + {"id": 3, "name": "Item 3", "stock": 0} + ] + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.items[1].stock", "v=v+10") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_RootElementUpdate tests updating the root element +func TestJSONProcessor_RootElementUpdate(t *testing.T) { + content := `{"value": 100}` + expected := `{"value": 200}` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.value", "v=v*2") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_AddNewField tests adding a new field to a JSON object +func TestJSONProcessor_AddNewField(t *testing.T) { + content := `{ + "user": { + "name": "John", + "age": 30 + } + }` + + expected := `{ + "user": { + "name": "John", + "age": 30, + "email": "john@example.com" + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.user", "v.email = 'john@example.com'") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_RemoveField tests removing a field from a JSON object +func TestJSONProcessor_RemoveField(t *testing.T) { + content := `{ + "user": { + "name": "John", + "age": 30, + "email": "john@example.com" + } + }` + + expected := `{ + "user": { + "name": "John", + "email": "john@example.com" + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.user", "v.age = nil") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_ArrayManipulation tests adding and manipulating array elements func TestJSONProcessor_ArrayManipulation(t *testing.T) { - testJSON := `{ - "dataPoints": [10, 20, 30, 40, 50] -}` + content := `{ + "tags": ["go", "json", "lua"] + }` - // Create a JSON processor - processor := NewJSONProcessor(&TestLogger{T: t}) + expected := `{ + "tags": ["GO", "JSON", "LUA", "testing"] + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.tags", ` + -- Convert existing tags to uppercase + for i=1, #v do + v[i] = string.upper(v[i]) + end + -- Add a new tag + v[#v+1] = "testing" + `) - // Process the JSON to normalize values (divide by max value) - jsonPathExpr := "$.dataPoints[*]" - modifiedJSON, modCount, matchCount, err := processor.ProcessContent(testJSON, jsonPathExpr, "v1 = v1 / 50", "normalize") if err != nil { - t.Fatalf("Failed to process JSON content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check counts - if matchCount != 5 { - t.Errorf("Expected to match 5 data points, got %d", matchCount) - } - if modCount != 5 { - t.Errorf("Expected to modify 5 nodes, got %d", modCount) + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) } - // Parse the result to verify changes - var result map[string]interface{} - if err := json.Unmarshal([]byte(modifiedJSON), &result); err != nil { - t.Fatalf("Failed to parse modified JSON: %v", err) + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) } - // Get the data points array - dataPoints := result["dataPoints"].([]interface{}) + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) - // Check values (should be divided by 50) - expectedValues := []float64{0.2, 0.4, 0.6, 0.8, 1.0} - for i, val := range dataPoints { - if val.(float64) != expectedValues[i] { - t.Errorf("Expected dataPoints[%d] to be %v, got %v", i, expectedValues[i], val) - } + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -// TestJSONProcessor_ConditionalModification tests applying changes only to certain elements +// TestJSONProcessor_ConditionalModification tests conditionally modifying values func TestJSONProcessor_ConditionalModification(t *testing.T) { - testJSON := `{ - "products": [ - {"id": "p1", "name": "Laptop", "price": 999.99, "discount": 0}, - {"id": "p2", "name": "Headphones", "price": 59.99, "discount": 0}, - {"id": "p3", "name": "Mouse", "price": 29.99, "discount": 0} - ] -}` + content := `{ + "products": [ + { + "name": "Product A", + "price": 10.99, + "inStock": true + }, + { + "name": "Product B", + "price": 5.99, + "inStock": false + }, + { + "name": "Product C", + "price": 15.99, + "inStock": true + } + ] + }` - // Create a JSON processor - processor := NewJSONProcessor(&TestLogger{T: t}) + expected := `{ + "products": [ + { + "name": "Product A", + "price": 9.891, + "inStock": true + }, + { + "name": "Product B", + "price": 5.99, + "inStock": false + }, + { + "name": "Product C", + "price": 14.391, + "inStock": true + } + ] + }` - // Process: apply 10% discount to items over $50, 5% to others - luaScript := ` - -- Get the path to find the parent (product) - local path = string.gsub(_PATH, ".discount$", "") - - -- Custom logic based on price - local price = _PARENT.price - if price > 50 then - v1 = 0.1 -- 10% discount - else - v1 = 0.05 -- 5% discount - end - ` + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.products[*]", ` + if v.inStock then + v.price = v.price * 0.9 + end + `) - jsonPathExpr := "$.products[*].discount" - modifiedJSON, modCount, matchCount, err := processor.ProcessContent(testJSON, jsonPathExpr, luaScript, "apply discounts") if err != nil { - t.Fatalf("Failed to process JSON content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check counts if matchCount != 3 { - t.Errorf("Expected to match 3 discount nodes, got %d", matchCount) - } - if modCount != 3 { - t.Errorf("Expected to modify 3 nodes, got %d", modCount) + t.Errorf("Expected 3 matches, got %d", matchCount) } - // Parse the result to verify changes - var result map[string]interface{} - if err := json.Unmarshal([]byte(modifiedJSON), &result); err != nil { - t.Fatalf("Failed to parse modified JSON: %v", err) + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) } - // Get products array - products := result["products"].([]interface{}) + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) - // Laptop and Headphones should have 10% discount - laptop := products[0].(map[string]interface{}) - headphones := products[1].(map[string]interface{}) - mouse := products[2].(map[string]interface{}) - - if laptop["discount"].(float64) != 0.1 { - t.Errorf("Expected laptop discount to be 0.1, got %v", laptop["discount"]) - } - - if headphones["discount"].(float64) != 0.1 { - t.Errorf("Expected headphones discount to be 0.1, got %v", headphones["discount"]) - } - - if mouse["discount"].(float64) != 0.05 { - t.Errorf("Expected mouse discount to be 0.05, got %v", mouse["discount"]) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -// TestJSONProcessor_ComplexScripts tests using more complex Lua scripts -func TestJSONProcessor_ComplexScripts(t *testing.T) { - testJSON := `{ - "metrics": [ - {"name": "CPU", "values": [45, 60, 75, 90, 80]}, - {"name": "Memory", "values": [30, 40, 45, 50, 60]}, - {"name": "Disk", "values": [20, 25, 30, 40, 50]} - ] -}` - - // Create a JSON processor - processor := NewJSONProcessor(&TestLogger{T: t}) - - // Apply a moving average transformation - luaScript := ` - -- This script transforms an array using a moving average - local values = {} - local window = 3 -- window size - - -- Get all the values as a table - for i = 1, 5 do - local element = _VALUE[i] - if element then - values[i] = element - end - end - - -- Calculate moving averages - local result = {} - for i = 1, #values do - local sum = 0 - local count = 0 - - -- Sum the window - for j = math.max(1, i-(window-1)/2), math.min(#values, i+(window-1)/2) do - sum = sum + values[j] - count = count + 1 - end - - -- Set the average - result[i] = sum / count - end - - -- Update all values - for i = 1, #result do - _VALUE[i] = result[i] - end - ` - - jsonPathExpr := "$.metrics[*].values" - modifiedJSON, modCount, matchCount, err := processor.ProcessContent(testJSON, jsonPathExpr, luaScript, "moving average") - if err != nil { - t.Fatalf("Failed to process JSON content: %v", err) - } - - // Check counts - if matchCount != 3 { - t.Errorf("Expected to match 3 value arrays, got %d", matchCount) - } - if modCount != 3 { - t.Errorf("Expected to modify 3 nodes, got %d", modCount) - } - - // Parse and verify the values were smoothed - var result map[string]interface{} - if err := json.Unmarshal([]byte(modifiedJSON), &result); err != nil { - t.Fatalf("Failed to parse modified JSON: %v", err) - } - - // The modification logic would smooth out the values - // We'll check that the JSON is valid at least - metrics := result["metrics"].([]interface{}) - if len(metrics) != 3 { - t.Errorf("Expected 3 metrics, got %d", len(metrics)) - } - - // Each metrics should have 5 values - for i, metric := range metrics { - m := metric.(map[string]interface{}) - values := m["values"].([]interface{}) - if len(values) != 5 { - t.Errorf("Metric %d should have 5 values, got %d", i, len(values)) +// TestJSONProcessor_DeepNesting tests manipulating deeply nested JSON structures +func TestJSONProcessor_DeepNesting(t *testing.T) { + content := `{ + "company": { + "departments": { + "engineering": { + "teams": { + "frontend": { + "members": 12, + "projects": 5 + }, + "backend": { + "members": 8, + "projects": 3 + } + } + } + } } + }` + + expected := `{ + "company": { + "departments": { + "engineering": { + "teams": { + "frontend": { + "members": 12, + "projects": 5, + "status": "active" + }, + "backend": { + "members": 8, + "projects": 3, + "status": "active" + } + } + } + } + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.company.departments.engineering.teams.*", ` + v.status = "active" + `) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_ComplexTransformation tests a complex transformation involving +// multiple fields and calculations +func TestJSONProcessor_ComplexTransformation(t *testing.T) { + content := `{ + "order": { + "items": [ + { + "product": "Widget A", + "quantity": 5, + "price": 10.0 + }, + { + "product": "Widget B", + "quantity": 3, + "price": 15.0 + } + ], + "customer": { + "name": "John Smith", + "tier": "gold" + } + } + }` + + expected := `{ + "order": { + "items": [ + { + "product": "Widget A", + "quantity": 5, + "price": 10.0, + "total": 50.0, + "discounted_total": 45.0 + }, + { + "product": "Widget B", + "quantity": 3, + "price": 15.0, + "total": 45.0, + "discounted_total": 40.5 + } + ], + "customer": { + "name": "John Smith", + "tier": "gold" + }, + "summary": { + "total_items": 8, + "subtotal": 95.0, + "discount": 9.5, + "total": 85.5 + } + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.order", ` + -- Calculate item totals and apply discounts + local discount_rate = 0.1 -- 10% discount for gold tier + local subtotal = 0 + local total_items = 0 + + for i, item in ipairs(v.items) do + -- Calculate item total + item.total = item.quantity * item.price + + -- Apply discount + item.discounted_total = item.total * (1 - discount_rate) + + -- Add to running totals + subtotal = subtotal + item.total + total_items = total_items + item.quantity + end + + -- Add order summary + v.summary = { + total_items = total_items, + subtotal = subtotal, + discount = subtotal * discount_rate, + total = subtotal * (1 - discount_rate) + } + `) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_HandlingNullValues tests handling of null values in JSON +func TestJSONProcessor_HandlingNullValues(t *testing.T) { + content := `{ + "data": { + "value1": null, + "value2": 42, + "value3": "hello" + } + }` + + expected := `{ + "data": { + "value1": 0, + "value2": 42, + "value3": "hello" + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.data.value1", ` + v = 0 + `) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_RestructuringData tests completely restructuring JSON data +func TestJSONProcessor_RestructuringData(t *testing.T) { + content := `{ + "people": [ + { + "id": 1, + "name": "Alice", + "attributes": { + "age": 25, + "role": "developer" + } + }, + { + "id": 2, + "name": "Bob", + "attributes": { + "age": 30, + "role": "manager" + } + } + ] + }` + + expected := `{ + "people": { + "developers": [ + { + "id": 1, + "name": "Alice", + "age": 25 + } + ], + "managers": [ + { + "id": 2, + "name": "Bob", + "age": 30 + } + ] + } + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$", ` + -- Restructure the data + local old_people = v.people + local new_structure = { + developers = {}, + managers = {} + } + + for _, person in ipairs(old_people) do + local role = person.attributes.role + local new_person = { + id = person.id, + name = person.name, + age = person.attributes.age + } + + if role == "developer" then + table.insert(new_structure.developers, new_person) + elseif role == "manager" then + table.insert(new_structure.managers, new_person) + end + end + + v.people = new_structure + `) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// TestJSONProcessor_FilteringArrayElements tests filtering elements from an array +func TestJSONProcessor_FilteringArrayElements(t *testing.T) { + content := `{ + "numbers": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + }` + + expected := `{ + "numbers": [2, 4, 6, 8, 10] + }` + + p := &JSONProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "$.numbers", ` + -- Filter to keep only even numbers + local filtered = {} + for _, num in ipairs(v) do + if num % 2 == 0 then + table.insert(filtered, num) + end + end + v = filtered + `) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeWhitespace(result) + normalizedExpected := normalizeWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } diff --git a/processor/processor.go b/processor/processor.go index e0126f9..1a1c5c6 100644 --- a/processor/processor.go +++ b/processor/processor.go @@ -10,11 +10,11 @@ import ( // Processor defines the interface for all file processors type Processor interface { // Process handles processing a file with the given pattern and Lua expression - Process(filename string, pattern string, luaExpr string, originalExpr string) (int, int, error) + Process(filename string, pattern string, luaExpr string) (int, int, error) // 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, originalExpr string) (string, int, int, error) + ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error) // ToLua converts processor-specific data to Lua variables ToLua(L *lua.LState, data interface{}) error @@ -76,6 +76,29 @@ func LimitString(s string, maxLen int) string { return s[:maxLen-3] + "..." } +// BuildLuaScript prepares a Lua expression from shorthand notation +func BuildLuaScript(luaExpr string) string { + // Auto-prepend v1 for expressions starting with operators + if strings.HasPrefix(luaExpr, "*") || + strings.HasPrefix(luaExpr, "/") || + strings.HasPrefix(luaExpr, "+") || + strings.HasPrefix(luaExpr, "-") || + strings.HasPrefix(luaExpr, "^") || + strings.HasPrefix(luaExpr, "%") { + luaExpr = "v1 = v1" + luaExpr + } else if strings.HasPrefix(luaExpr, "=") { + // Handle direct assignment with = operator + luaExpr = "v1 " + luaExpr + } + + // Add assignment if needed + if !strings.Contains(luaExpr, "=") { + luaExpr = "v1 = " + luaExpr + } + + return luaExpr +} + // Max returns the maximum of two integers func Max(a, b int) int { if a > b { diff --git a/processor/regex.go b/processor/regex.go index f059c0e..1202b25 100644 --- a/processor/regex.go +++ b/processor/regex.go @@ -12,26 +12,10 @@ import ( ) // RegexProcessor implements the Processor interface using regex patterns -type RegexProcessor struct { - CompiledPattern *regexp.Regexp - Logger Logger -} - -// Logger interface abstracts logging functionality -type Logger interface { - Printf(format string, v ...interface{}) -} - -// NewRegexProcessor creates a new RegexProcessor with the given pattern -func NewRegexProcessor(pattern *regexp.Regexp, logger Logger) *RegexProcessor { - return &RegexProcessor{ - CompiledPattern: pattern, - Logger: logger, - } -} +type RegexProcessor struct{} // Process implements the Processor interface for RegexProcessor -func (p *RegexProcessor) Process(filename string, pattern string, luaExpr string, originalExpr string) (int, int, error) { +func (p *RegexProcessor) Process(filename string, pattern string, luaExpr string) (int, int, error) { // Read file content fullPath := filepath.Join(".", filename) content, err := os.ReadFile(fullPath) @@ -40,32 +24,19 @@ func (p *RegexProcessor) Process(filename string, pattern string, luaExpr string } fileContent := string(content) - if p.Logger != nil { - p.Logger.Printf("File %s loaded: %d bytes", fullPath, len(content)) - } - // Process the content with regex - result, modCount, matchCount, err := p.ProcessContent(fileContent, luaExpr, filename, originalExpr) + // Process the content + modifiedContent, modCount, matchCount, err := p.ProcessContent(fileContent, pattern, luaExpr) if err != nil { return 0, 0, err } - if modCount == 0 { - if p.Logger != nil { - p.Logger.Printf("No modifications made to %s - pattern didn't match any content", fullPath) + // 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 0, 0, nil - } - - // Write the modified content back - err = os.WriteFile(fullPath, []byte(result), 0644) - if err != nil { - return 0, 0, fmt.Errorf("error writing file: %v", err) - } - - if p.Logger != nil { - p.Logger.Printf("Made %d modifications to %s and saved (%d bytes)", - modCount, fullPath, len(result)) } return modCount, matchCount, nil @@ -109,14 +80,22 @@ func (p *RegexProcessor) FromLua(L *lua.LState) (interface{}, error) { vLuaVal := L.GetGlobal(vVarName) sLuaVal := L.GetGlobal(sVarName) - // Get the v variable if it exists + // First check string variables (s1, s2, etc.) as they should have priority + if sLuaVal != lua.LNil { + if sStr, ok := sLuaVal.(lua.LString); ok { + newStrVal := string(sStr) + modifications[i] = newStrVal + continue + } + } + + // Then check numeric variables (v1, v2, etc.) if vLuaVal != lua.LNil { switch v := vLuaVal.(type) { case lua.LNumber: // Convert numeric value to string newNumVal := strconv.FormatFloat(float64(v), 'f', -1, 64) modifications[i] = newNumVal - // We found a value, continue to next capture group continue case lua.LString: // Use string value directly @@ -130,113 +109,70 @@ func (p *RegexProcessor) FromLua(L *lua.LState) (interface{}, error) { continue } } - - // Try the s variable if v variable wasn't found or couldn't be used - if sLuaVal != lua.LNil { - if sStr, ok := sLuaVal.(lua.LString); ok { - newStrVal := string(sStr) - modifications[i] = newStrVal - continue - } - } - } - - if p.Logger != nil { - p.Logger.Printf("Final modifications map: %v", modifications) } return modifications, nil } // ProcessContent applies regex replacement with Lua processing -func (p *RegexProcessor) ProcessContent(data string, luaExpr string, filename string, originalExpr string) (string, int, int, error) { +func (p *RegexProcessor) ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error) { + // Handle special pattern modifications + if !strings.HasPrefix(pattern, "(?s)") { + pattern = "(?s)" + pattern + } + + compiledPattern, err := regexp.Compile(pattern) + if err != nil { + return "", 0, 0, fmt.Errorf("error compiling pattern: %v", err) + } + L := lua.NewState() defer L.Close() // Initialize Lua environment modificationCount := 0 matchCount := 0 - modifications := []ModificationRecord{} // Load math library L.Push(L.GetGlobal("require")) L.Push(lua.LString("math")) if err := L.PCall(1, 1, nil); err != nil { - if p.Logger != nil { - p.Logger.Printf("Failed to load Lua math library: %v", err) - } - return data, 0, 0, fmt.Errorf("error loading Lua math library: %v", err) + return content, 0, 0, fmt.Errorf("error loading Lua math library: %v", err) } // Initialize helper functions if err := InitLuaHelpers(L); err != nil { - return data, 0, 0, err + return content, 0, 0, err } // Process all regex matches - result := p.CompiledPattern.ReplaceAllStringFunc(data, func(match string) string { + result := compiledPattern.ReplaceAllStringFunc(content, func(match string) string { matchCount++ - captures := p.CompiledPattern.FindStringSubmatch(match) + captures := compiledPattern.FindStringSubmatch(match) if len(captures) <= 1 { // No capture groups, return unchanged - if p.Logger != nil { - p.Logger.Printf("Match found but no capture groups: %s", LimitString(match, 50)) - } return match } - if p.Logger != nil { - p.Logger.Printf("Match found: %s", LimitString(match, 50)) - } - // Pass the captures to Lua environment if err := p.ToLua(L, captures); err != nil { - if p.Logger != nil { - p.Logger.Printf("Failed to set Lua variables: %v", err) - } return match } - // Debug: print the Lua variables before execution - if p.Logger != nil { - v1 := L.GetGlobal("v1") - s1 := L.GetGlobal("s1") - p.Logger.Printf("Before Lua: v1=%v, s1=%v", v1, s1) - } - // Execute the user's Lua code if err := L.DoString(luaExpr); err != nil { - if p.Logger != nil { - p.Logger.Printf("Lua execution failed for match '%s': %v", LimitString(match, 50), err) - } return match // Return unchanged on error } - // Debug: print the Lua variables after execution - if p.Logger != nil { - v1 := L.GetGlobal("v1") - s1 := L.GetGlobal("s1") - p.Logger.Printf("After Lua: v1=%v, s1=%v", v1, s1) - } - // Get modifications from Lua modResult, err := p.FromLua(L) if err != nil { - if p.Logger != nil { - p.Logger.Printf("Failed to get modifications from Lua: %v", err) - } return match } - // Debug: print the modifications detected - if p.Logger != nil { - p.Logger.Printf("Modifications detected: %v", modResult) - } - // Apply modifications to the matched text modsMap, ok := modResult.(map[int]string) if !ok || len(modsMap) == 0 { - p.Logger.Printf("No modifications detected after Lua script execution") return match // No changes } @@ -244,57 +180,7 @@ func (p *RegexProcessor) ProcessContent(data string, luaExpr string, filename st result := match for i, newVal := range modsMap { oldVal := captures[i+1] - // Special handling for empty capture groups - if oldVal == "" { - // Find the position where the empty capture group should be - // by analyzing the regex pattern and current match - parts := p.CompiledPattern.SubexpNames() - if i+1 < len(parts) && parts[i+1] != "" { - // Named capture groups - subPattern := fmt.Sprintf("(?P<%s>)", parts[i+1]) - emptyGroupPattern := regexp.MustCompile(subPattern) - if loc := emptyGroupPattern.FindStringIndex(result); loc != nil { - // Insert the new value at the capture group location - result = result[:loc[0]] + newVal + result[loc[1]:] - } - } else { - // For unnamed capture groups, we need to find where they would be in the regex - // This is a simplification that might not work for complex regex patterns - // but should handle the test case with - tagPattern := regexp.MustCompile("") - if loc := tagPattern.FindStringIndex(result); loc != nil { - // Replace the empty tag content with our new value - result = result[:loc[0]+7] + newVal + result[loc[1]-8:] - } - } - } else { - // Normal replacement for non-empty capture groups - p.Logger.Printf("Replacing '%s' with '%s' in '%s'", oldVal, newVal, result) - result = strings.Replace(result, oldVal, newVal, 1) - p.Logger.Printf("After replacement: '%s'", result) - } - - // Extract a bit of context from the match for better reporting - contextStart := Max(0, strings.Index(match, oldVal)-10) - contextLength := Min(30, len(match)-contextStart) - if contextStart+contextLength > len(match) { - contextLength = len(match) - contextStart - } - contextStr := "..." + match[contextStart:contextStart+contextLength] + "..." - - // Log the modification - if p.Logger != nil { - p.Logger.Printf("Modified value [%d]: '%s' → '%s'", i+1, LimitString(oldVal, 30), LimitString(newVal, 30)) - } - - // Record the modification for summary - modifications = append(modifications, ModificationRecord{ - File: filename, - OldValue: oldVal, - NewValue: newVal, - Operation: originalExpr, - Context: fmt.Sprintf("(in %s)", LimitString(contextStr, 30)), - }) + result = strings.Replace(result, oldVal, newVal, 1) } modificationCount++ @@ -303,26 +189,3 @@ func (p *RegexProcessor) ProcessContent(data string, luaExpr string, filename st return result, modificationCount, matchCount, nil } - -// BuildLuaScript creates a complete Lua script from the expression -func BuildLuaScript(luaExpr string) string { - // Auto-prepend v1 for expressions starting with operators - if strings.HasPrefix(luaExpr, "*") || - strings.HasPrefix(luaExpr, "/") || - strings.HasPrefix(luaExpr, "+") || - strings.HasPrefix(luaExpr, "-") || - strings.HasPrefix(luaExpr, "^") || - strings.HasPrefix(luaExpr, "%") { - luaExpr = "v1 = v1" + luaExpr - } else if strings.HasPrefix(luaExpr, "=") { - // Handle direct assignment with = operator - luaExpr = "v1 " + luaExpr - } - - // Add assignment if needed - if !strings.Contains(luaExpr, "=") { - luaExpr = "v1 = " + luaExpr - } - - return luaExpr -} diff --git a/processor/regex_test.go b/processor/regex_test.go index 0ab4dbb..94febf2 100644 --- a/processor/regex_test.go +++ b/processor/regex_test.go @@ -24,315 +24,283 @@ func normalizeWhitespace(s string) string { return re.ReplaceAllString(strings.TrimSpace(s), " ") } +func TestBuildLuaScript(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"s1 .. '_suffix'", "v1 = s1 .. '_suffix'"}, + {"v1 * 1.5", "v1 = v1 * 1.5"}, + {"v1 + 10", "v1 = v1 + 10"}, + {"v1 * 2", "v1 = v1 * 2"}, + {"v1 * v2", "v1 = v1 * v2"}, + {"v1 / v2", "v1 = v1 / v2"}, + } + + for _, c := range cases { + result := BuildLuaScript(c.input) + if result != c.expected { + t.Errorf("BuildLuaScript(%q): expected %q, got %q", c.input, c.expected, result) + } + } +} + func TestSimpleValueMultiplication(t *testing.T) { - content := ` - - - 100 - - - ` - expected := ` - - - 150 - - - ` + content := ` + + 100 + +` - // Create a regex pattern with the (?s) flag for multiline matching - regex := regexp.MustCompile(`(?s)(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{T: t}) - luaExpr := BuildLuaScript("*1.5") + expected := ` + + 150 + +` - // Enable verbose logging for this test - t.Logf("Running test with regex pattern: %s", regex.String()) - t.Logf("Original content: %s", content) - t.Logf("Lua expression: %s", luaExpr) + p := &RegexProcessor{} + result, mods, matches, err := p.ProcessContent(content, `(?s)(\d+)`, "v1 = v1*1.5") - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", "*1.5") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 1 { - t.Errorf("Expected 1 match, got %d", matchCount) - } - if modCount != 1 { - t.Errorf("Expected 1 modification, got %d", modCount) + if matches != 1 { + t.Errorf("Expected 1 match, got %d", matches) } - t.Logf("Modified content: %s", modifiedContent) - t.Logf("Expected content: %s", expected) + if mods != 1 { + t.Errorf("Expected 1 modification, got %d", mods) + } - // Compare normalized content - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } func TestShorthandNotation(t *testing.T) { - content := ` - - - 100 - - - ` - expected := ` - - - 150 - - - ` + content := ` + + 100 + +` - regex := regexp.MustCompile(`(?s)(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) - luaExpr := BuildLuaScript("v1 * 1.5") // Use direct assignment syntax + expected := ` + + 150 + +` + + p := &RegexProcessor{} + result, mods, matches, err := p.ProcessContent(content, `(?s)(\d+)`, "v1*1.5") - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", "v1 * 1.5") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 1 { - t.Errorf("Expected 1 match, got %d", matchCount) - } - if modCount != 1 { - t.Errorf("Expected 1 modification, got %d", modCount) + if matches != 1 { + t.Errorf("Expected 1 match, got %d", matches) } - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if mods != 1 { + t.Errorf("Expected 1 modification, got %d", mods) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } func TestShorthandNotationFloats(t *testing.T) { - content := ` - - - 132.671327 - - - ` - expected := ` - - - 176.01681007940928 - - - ` + content := ` + + 10.5 + +` - regex := regexp.MustCompile(`(?s)(\d*\.?\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) - luaExpr := BuildLuaScript("v1 * 1.32671327") // Use direct assignment syntax + expected := ` + + 15.75 + +` + + p := &RegexProcessor{} + result, mods, matches, err := p.ProcessContent(content, `(?s)(\d+\.\d+)`, "v1*1.5") - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", "v1 * 1.32671327") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 1 { - t.Errorf("Expected 1 match, got %d", matchCount) - } - if modCount != 1 { - t.Errorf("Expected 1 modification, got %d", modCount) + if matches != 1 { + t.Errorf("Expected 1 match, got %d", matches) } - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if mods != 1 { + t.Errorf("Expected 1 modification, got %d", mods) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } func TestArrayNotation(t *testing.T) { - content := ` - - - 100 - - - ` - expected := ` - - - 150 - - - ` + content := ` + + 10 + 20 + 30 + +` - regex := regexp.MustCompile(`(?s)(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) - luaExpr := BuildLuaScript("v1 = v1 * 1.5") // Use direct assignment syntax + expected := ` + + 20 + 40 + 60 + +` + + p := &RegexProcessor{} + result, mods, matches, err := p.ProcessContent(content, `(?s)(\d+)`, "v1*2") - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", "v1 = v1 * 1.5") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 1 { - t.Errorf("Expected 1 match, got %d", matchCount) - } - if modCount != 1 { - t.Errorf("Expected 1 modification, got %d", modCount) + if matches != 3 { + t.Errorf("Expected 3 matches, got %d", matches) } - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if mods != 3 { + t.Errorf("Expected 3 modifications, got %d", mods) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } func TestMultipleMatches(t *testing.T) { - content := ` - - - 100 - - - 200 - - 300 - - ` - expected := ` - - - 150 - - - 300 - - 450 - - ` + content := ` + 50 + 100 + 200 +` - regex := regexp.MustCompile(`(?s)(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) - luaExpr := BuildLuaScript("*1.5") + expected := ` + 100 + 200 + 400 +` + + p := &RegexProcessor{} + result, mods, matches, err := p.ProcessContent(content, `(\d+)`, "v1*2") - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", "*1.5") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 3 { - t.Errorf("Expected 3 matches, got %d", matchCount) - } - if modCount != 3 { - t.Errorf("Expected 3 modifications, got %d", modCount) + if matches != 3 { + t.Errorf("Expected 3 matches, got %d", matches) } - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if mods != 3 { + t.Errorf("Expected 3 modifications, got %d", mods) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } + + // Test string operations + content = ` + John + Mary +` + + expected = ` + John_modified + Mary_modified +` + + result, mods, matches, err = p.ProcessContent(content, `([A-Za-z]+)`, `s1 = s1 .. "_modified"`) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matches != 2 { + t.Errorf("Expected 2 matches, got %d", matches) + } + + if mods != 2 { + t.Errorf("Expected 2 modifications, got %d", mods) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -func TestMultipleCaptureGroups(t *testing.T) { - content := ` - - - 10 - 5 - - - ` - expected := ` - - - 50 - 5 - - - ` +func TestStringOperations(t *testing.T) { + content := ` + John + Mary +` - // Use (?s) flag to match across multiple lines - regex := regexp.MustCompile(`(?s)(\d+).*?(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) - luaExpr := BuildLuaScript("v1 = v1 * v2") // Use direct assignment syntax + expected := ` + JOHN + MARY +` - // Verify the regex matches before processing - matches := regex.FindStringSubmatch(content) - if len(matches) <= 1 { - t.Fatalf("Regex didn't match any capture groups in test input: %v", content) - } + p := &RegexProcessor{} + // Convert names to uppercase using Lua string function + result, mods, matches, err := p.ProcessContent(content, `([A-Za-z]+)`, `s1 = string.upper(s1)`) - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", "v1 = v1 * v2") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 1 { - t.Errorf("Expected 1 match, got %d", matchCount) - } - if modCount != 1 { - t.Errorf("Expected 1 modification, got %d", modCount) + if matches != 2 { + t.Errorf("Expected 2 matches, got %d", matches) } - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if mods != 2 { + t.Errorf("Expected 2 modifications, got %d", mods) } -} -func TestModifyingMultipleValues(t *testing.T) { - content := ` - - - 50 - 3 - 2 - - - ` - expected := ` - - - 75 - 5 - 1 - - - ` + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } - regex := regexp.MustCompile(`(?s)(\d+).*?(\d+).*?(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) - luaExpr := BuildLuaScript("v1 = v1 * v2 / v3; v2 = min(v2 * 2, 5); v3 = max(1, v3 / 2)") + // Test string concatenation + content = ` + Apple + Banana +` + + expected = ` + Apple_fruit + Banana_fruit +` + + result, mods, matches, err = p.ProcessContent(content, `([A-Za-z]+)`, `s1 = s1 .. "_fruit"`) - modifiedContent, modCount, matchCount, err := processor.ProcessContent(content, luaExpr, "test", - "v1 = v1 * v2 / v3; v2 = min(v2 * 2, 5); v3 = max(1, v3 / 2)") if err != nil { t.Fatalf("Error processing content: %v", err) } - // Verify match and modification counts - if matchCount != 1 { - t.Errorf("Expected 1 match, got %d", matchCount) - } - if modCount != 1 { - t.Errorf("Expected 1 modification, got %d", modCount) + if matches != 2 { + t.Errorf("Expected 2 matches, got %d", matches) } - normalizedModified := normalizeWhitespace(modifiedContent) - normalizedExpected := normalizeWhitespace(expected) - if normalizedModified != normalizedExpected { - t.Fatalf("Expected modified content to be %q, but got %q", normalizedExpected, normalizedModified) + if mods != 2 { + t.Errorf("Expected 2 modifications, got %d", mods) + } + + if result != expected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } @@ -356,10 +324,10 @@ func TestDecimalValues(t *testing.T) { ` regex := regexp.MustCompile(`(?s)([0-9.]+).*?([0-9.]+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) + p := &RegexProcessor{} luaExpr := BuildLuaScript("v1 = v1 * v2") - modifiedContent, _, _, err := processor.ProcessContent(content, luaExpr, "test", "v1 = v1 * v2") + modifiedContent, _, _, err := p.ProcessContent(content, regex.String(), luaExpr) if err != nil { t.Fatalf("Error processing content: %v", err) } @@ -389,10 +357,10 @@ func TestLuaMathFunctions(t *testing.T) { ` regex := regexp.MustCompile(`(?s)(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) + p := &RegexProcessor{} luaExpr := BuildLuaScript("v1 = math.sqrt(v1)") - modifiedContent, _, _, err := processor.ProcessContent(content, luaExpr, "test", "v1 = math.sqrt(v1)") + modifiedContent, _, _, err := p.ProcessContent(content, regex.String(), luaExpr) if err != nil { t.Fatalf("Error processing content: %v", err) } @@ -422,10 +390,10 @@ func TestDirectAssignment(t *testing.T) { ` regex := regexp.MustCompile(`(?s)(\d+)`) - processor := NewRegexProcessor(regex, &TestLogger{}) + p := &RegexProcessor{} luaExpr := BuildLuaScript("=0") - modifiedContent, _, _, err := processor.ProcessContent(content, luaExpr, "test", "=0") + modifiedContent, _, _, err := p.ProcessContent(content, regex.String(), luaExpr) if err != nil { t.Fatalf("Error processing content: %v", err) } @@ -457,10 +425,10 @@ func TestStringAndNumericOperations(t *testing.T) { }, { name: "Basic string manipulation", - input: "test", - regexPattern: "(.*?)", + input: "test", + regexPattern: "(.*?)", luaExpression: "s1 = string.upper(s1)", - expectedOutput: "TEST", + expectedOutput: "TEST", expectedMods: 1, }, { @@ -484,12 +452,12 @@ func TestStringAndNumericOperations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Compile the regex pattern with multiline support - pattern := regexp.MustCompile("(?s)" + tt.regexPattern) - processor := NewRegexProcessor(pattern, &TestLogger{}) + pattern := "(?s)" + tt.regexPattern + p := &RegexProcessor{} luaExpr := BuildLuaScript(tt.luaExpression) // Process with our function - result, modCount, _, err := processor.ProcessContent(tt.input, luaExpr, "test", tt.luaExpression) + result, modCount, _, err := p.ProcessContent(tt.input, pattern, luaExpr) if err != nil { t.Fatalf("Process function failed: %v", err) } @@ -553,12 +521,12 @@ func TestEdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Make sure the regex can match across multiple lines - pattern := regexp.MustCompile("(?s)" + tt.regexPattern) - processor := NewRegexProcessor(pattern, &TestLogger{}) + pattern := "(?s)" + tt.regexPattern + p := &RegexProcessor{} luaExpr := BuildLuaScript(tt.luaExpression) // Process with our function - result, modCount, _, err := processor.ProcessContent(tt.input, luaExpr, "test", tt.luaExpression) + result, modCount, _, err := p.ProcessContent(tt.input, pattern, luaExpr) if err != nil { t.Fatalf("Process function failed: %v", err) } @@ -574,32 +542,3 @@ func TestEdgeCases(t *testing.T) { }) } } - -func TestBuildLuaScript(t *testing.T) { - testCases := []struct { - input string - expected string - }{ - {"*1.5", "v1 = v1*1.5"}, - {"/2", "v1 = v1/2"}, - {"+10", "v1 = v1+10"}, - {"-5", "v1 = v1-5"}, - {"^2", "v1 = v1^2"}, - {"%2", "v1 = v1%2"}, - {"=100", "v1 =100"}, - {"v1 * 2", "v1 = v1 * 2"}, - {"v1 + v2", "v1 = v1 + v2"}, - {"math.max(v1, 100)", "v1 = math.max(v1, 100)"}, - // Added from main_test.go - {"s1 .. '_suffix'", "v1 = s1 .. '_suffix'"}, - {"v1 * v2", "v1 = v1 * v2"}, - {"s1 .. s2", "v1 = s1 .. s2"}, - } - - for _, tc := range testCases { - result := BuildLuaScript(tc.input) - if result != tc.expected { - t.Errorf("BuildLuaScript(%q): expected %q, got %q", tc.input, tc.expected, result) - } - } -} diff --git a/processor/xml.go b/processor/xml.go index 9e7c394..a7e9e3a 100644 --- a/processor/xml.go +++ b/processor/xml.go @@ -4,30 +4,17 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "github.com/antchfx/xmlquery" lua "github.com/yuin/gopher-lua" ) -// XMLProcessor implements the Processor interface using XPath -type XMLProcessor struct { - Logger Logger -} - -// NewXMLProcessor creates a new XMLProcessor -func NewXMLProcessor(logger Logger) *XMLProcessor { - return &XMLProcessor{ - Logger: logger, - } -} +// XMLProcessor implements the Processor interface for XML documents +type XMLProcessor struct{} // Process implements the Processor interface for XMLProcessor -func (p *XMLProcessor) Process(filename string, pattern string, luaExpr string, originalExpr string) (int, int, error) { - // Use pattern as XPath expression - xpathExpr := pattern - +func (p *XMLProcessor) Process(filename string, pattern string, luaExpr string) (int, int, error) { // Read file content fullPath := filepath.Join(".", filename) content, err := os.ReadFile(fullPath) @@ -36,12 +23,9 @@ func (p *XMLProcessor) Process(filename string, pattern string, luaExpr string, } fileContent := string(content) - if p.Logger != nil { - p.Logger.Printf("File %s loaded: %d bytes", fullPath, len(content)) - } // Process the content - modifiedContent, modCount, matchCount, err := p.ProcessContent(fileContent, xpathExpr, luaExpr, originalExpr) + modifiedContent, modCount, matchCount, err := p.ProcessContent(fileContent, pattern, luaExpr) if err != nil { return 0, 0, err } @@ -52,403 +36,182 @@ func (p *XMLProcessor) Process(filename string, pattern string, luaExpr string, if err != nil { return 0, 0, fmt.Errorf("error writing file: %v", err) } - - if p.Logger != nil { - p.Logger.Printf("Made %d XML node modifications to %s and saved (%d bytes)", - modCount, fullPath, len(modifiedContent)) - } } return modCount, matchCount, nil } -// ToLua implements the Processor interface for XMLProcessor -func (p *XMLProcessor) ToLua(L *lua.LState, data interface{}) error { - // Currently not used directly as this is handled in Process - return nil -} - -// FromLua implements the Processor interface for XMLProcessor -func (p *XMLProcessor) FromLua(L *lua.LState) (interface{}, error) { - // Currently not used directly as this is handled in Process - return nil, nil -} - -// XMLNodeToString converts an XML node to a string representation -func (p *XMLProcessor) XMLNodeToString(node *xmlquery.Node) string { - // Use a simple string representation for now - var sb strings.Builder - - // Start tag with attributes - if node.Type == xmlquery.ElementNode { - sb.WriteString("<") - sb.WriteString(node.Data) - - // Add attributes - for _, attr := range node.Attr { - sb.WriteString(" ") - sb.WriteString(attr.Name.Local) - sb.WriteString("=\"") - sb.WriteString(attr.Value) - sb.WriteString("\"") - } - - // If self-closing - if node.FirstChild == nil { - sb.WriteString("/>") - return sb.String() - } - - sb.WriteString(">") - } else if node.Type == xmlquery.TextNode { - // Just write the text content - sb.WriteString(node.Data) - return sb.String() - } else if node.Type == xmlquery.CommentNode { - // Write comment - sb.WriteString("") - return sb.String() - } - - // Add children - for child := node.FirstChild; child != nil; child = child.NextSibling { - sb.WriteString(p.XMLNodeToString(child)) - } - - // End tag for elements - if node.Type == xmlquery.ElementNode { - sb.WriteString("") - } - - return sb.String() -} - -// NodeToLuaTable creates a Lua table from an XML node -func (p *XMLProcessor) NodeToLuaTable(L *lua.LState, node *xmlquery.Node) lua.LValue { - nodeTable := L.NewTable() - - // Add node name - L.SetField(nodeTable, "name", lua.LString(node.Data)) - - // Add node type - switch node.Type { - case xmlquery.ElementNode: - L.SetField(nodeTable, "type", lua.LString("element")) - case xmlquery.TextNode: - L.SetField(nodeTable, "type", lua.LString("text")) - case xmlquery.AttributeNode: - L.SetField(nodeTable, "type", lua.LString("attribute")) - case xmlquery.CommentNode: - L.SetField(nodeTable, "type", lua.LString("comment")) - default: - L.SetField(nodeTable, "type", lua.LString("other")) - } - - // Add node text content if it's a text node - if node.Type == xmlquery.TextNode { - L.SetField(nodeTable, "content", lua.LString(node.Data)) - } - - // Add attributes if it's an element node - if node.Type == xmlquery.ElementNode && len(node.Attr) > 0 { - attrsTable := L.NewTable() - for _, attr := range node.Attr { - L.SetField(attrsTable, attr.Name.Local, lua.LString(attr.Value)) - } - L.SetField(nodeTable, "attributes", attrsTable) - } - - // Add children if any - if node.FirstChild != nil { - childrenTable := L.NewTable() - i := 1 - for child := node.FirstChild; child != nil; child = child.NextSibling { - // Skip empty text nodes (whitespace) - if child.Type == xmlquery.TextNode && strings.TrimSpace(child.Data) == "" { - continue - } - - childTable := p.NodeToLuaTable(L, child) - childrenTable.RawSetInt(i, childTable) - i++ - } - L.SetField(nodeTable, "children", childrenTable) - } - - return nodeTable -} - -// GetModifiedNode retrieves a modified node from Lua -func (p *XMLProcessor) GetModifiedNode(L *lua.LState, originalNode *xmlquery.Node) (*xmlquery.Node, bool) { - // Check if we have a node global with changes - nodeTable := L.GetGlobal("node") - if nodeTable == lua.LNil || nodeTable.Type() != lua.LTTable { - return originalNode, false - } - - // Clone the node since we don't want to modify the original - clonedNode := *originalNode - - // For text nodes, check if content was changed - if originalNode.Type == xmlquery.TextNode { - contentField := L.GetField(nodeTable.(*lua.LTable), "content") - if contentField != lua.LNil { - if strContent, ok := contentField.(lua.LString); ok { - if string(strContent) != originalNode.Data { - clonedNode.Data = string(strContent) - return &clonedNode, true - } - } - } - return originalNode, false - } - - // For element nodes, attributes might have been changed - if originalNode.Type == xmlquery.ElementNode { - attrsField := L.GetField(nodeTable.(*lua.LTable), "attributes") - if attrsField != lua.LNil && attrsField.Type() == lua.LTTable { - attrsTable := attrsField.(*lua.LTable) - - // Check if any attributes changed - changed := false - for _, attr := range originalNode.Attr { - newValue := L.GetField(attrsTable, attr.Name.Local) - if newValue != lua.LNil { - if strValue, ok := newValue.(lua.LString); ok { - if string(strValue) != attr.Value { - // Create a new attribute with the changed value - for i, a := range clonedNode.Attr { - if a.Name.Local == attr.Name.Local { - clonedNode.Attr[i].Value = string(strValue) - changed = true - } - } - } - } - } - } - - if changed { - return &clonedNode, true - } - } - } - - // No changes detected - return originalNode, false -} - -// SetupXMLHelpers adds XML-specific helper functions to Lua -func (p *XMLProcessor) SetupXMLHelpers(L *lua.LState) { - // Helper function to create a new XML node - L.SetGlobal("new_node", L.NewFunction(func(L *lua.LState) int { - nodeName := L.CheckString(1) - nodeTable := L.NewTable() - L.SetField(nodeTable, "name", lua.LString(nodeName)) - L.SetField(nodeTable, "type", lua.LString("element")) - L.SetField(nodeTable, "attributes", L.NewTable()) - L.SetField(nodeTable, "children", L.NewTable()) - L.Push(nodeTable) - return 1 - })) - - // Helper function to set an attribute - L.SetGlobal("set_attr", L.NewFunction(func(L *lua.LState) int { - nodeTable := L.CheckTable(1) - attrName := L.CheckString(2) - attrValue := L.CheckString(3) - - attrsTable := L.GetField(nodeTable, "attributes") - if attrsTable == lua.LNil { - attrsTable = L.NewTable() - L.SetField(nodeTable, "attributes", attrsTable) - } - - L.SetField(attrsTable.(*lua.LTable), attrName, lua.LString(attrValue)) - return 0 - })) - - // Helper function to add a child node - L.SetGlobal("add_child", L.NewFunction(func(L *lua.LState) int { - parentTable := L.CheckTable(1) - childTable := L.CheckTable(2) - - childrenTable := L.GetField(parentTable, "children") - if childrenTable == lua.LNil { - childrenTable = L.NewTable() - L.SetField(parentTable, "children", childrenTable) - } - - childrenTbl := childrenTable.(*lua.LTable) - childrenTbl.RawSetInt(childrenTbl.Len()+1, childTable) - return 0 - })) -} - // ProcessContent implements the Processor interface for XMLProcessor -// It processes XML content directly without file I/O -func (p *XMLProcessor) ProcessContent(content string, pattern string, luaExpr string, originalExpr string) (string, int, int, error) { - // Parse the XML document +func (p *XMLProcessor) ProcessContent(content string, pattern string, luaExpr string) (string, int, int, error) { + // Parse XML document doc, err := xmlquery.Parse(strings.NewReader(content)) if err != nil { - return "", 0, 0, fmt.Errorf("error parsing XML: %v", err) + return content, 0, 0, fmt.Errorf("error parsing XML: %v", err) } - // Find nodes matching XPath expression + // Find nodes matching the XPath pattern nodes, err := xmlquery.QueryAll(doc, pattern) if err != nil { - return "", 0, 0, fmt.Errorf("invalid XPath expression: %v", err) + return content, 0, 0, fmt.Errorf("error executing XPath: %v", err) } - // Log what we found - if p.Logger != nil { - p.Logger.Printf("XML mode selected with XPath expression: %s (found %d matching nodes)", - pattern, len(nodes)) - } - - if len(nodes) == 0 { - if p.Logger != nil { - p.Logger.Printf("No XML nodes matched XPath expression: %s", pattern) - } + matchCount := len(nodes) + if matchCount == 0 { return content, 0, 0, nil } - // Initialize Lua state + // Initialize Lua L := lua.NewState() defer L.Close() - // Setup Lua helper functions - if err := InitLuaHelpers(L); err != nil { - return "", 0, 0, err + // Load math library + L.Push(L.GetGlobal("require")) + L.Push(lua.LString("math")) + if err := L.PCall(1, 1, nil); err != nil { + return content, 0, 0, fmt.Errorf("error loading Lua math library: %v", err) } - // Register XML-specific helper functions - p.SetupXMLHelpers(L) + // Load helper functions + if err := InitLuaHelpers(L); err != nil { + return content, 0, 0, err + } - // Track modifications - matchCount := len(nodes) - modificationCount := 0 - modifiedContent := content - modifications := []ModificationRecord{} + // Apply modifications to each node + modCount := 0 + for _, node := range nodes { + // Reset Lua state for each node + L.SetGlobal("v1", lua.LNil) + L.SetGlobal("s1", lua.LNil) - // Process each matching node - for i, node := range nodes { - // Get the original text representation of this node - originalNodeText := p.XMLNodeToString(node) - if p.Logger != nil { - p.Logger.Printf("Found node #%d: %s", i+1, LimitString(originalNodeText, 100)) + // Get the node value + var originalValue string + if node.Type == xmlquery.AttributeNode { + originalValue = node.InnerText() + } else if node.Type == xmlquery.TextNode { + originalValue = node.Data + } else { + originalValue = node.InnerText() } - // For text nodes, we'll handle them directly - if node.Type == xmlquery.TextNode && node.Parent != nil { - // If this is a text node, we'll use its value directly - // Get the node's text content - textContent := node.Data - - // Set up Lua environment - L.SetGlobal("v1", lua.LNumber(0)) // Default to 0 if not numeric - L.SetGlobal("s1", lua.LString(textContent)) - - // Try to convert to number if possible - if floatVal, err := strconv.ParseFloat(textContent, 64); err == nil { - L.SetGlobal("v1", lua.LNumber(floatVal)) - } - - // Execute user's Lua script - if err := L.DoString(luaExpr); err != nil { - if p.Logger != nil { - p.Logger.Printf("Lua execution failed for node #%d: %v", i+1, err) - } - continue // Skip this node on error - } - - // Check for modifications - modVal := L.GetGlobal("v1") - if v, ok := modVal.(lua.LNumber); ok { - // If we have a numeric result, convert it to string - newValue := strconv.FormatFloat(float64(v), 'f', -1, 64) - if newValue != textContent { - // Replace the node content in the document - parentStr := p.XMLNodeToString(node.Parent) - newParentStr := strings.Replace(parentStr, textContent, newValue, 1) - modifiedContent = strings.Replace(modifiedContent, parentStr, newParentStr, 1) - modificationCount++ - - // Record the modification - modifications = append(modifications, ModificationRecord{ - File: "", - OldValue: textContent, - NewValue: newValue, - Operation: originalExpr, - Context: fmt.Sprintf("(XPath: %s)", pattern), - }) - - if p.Logger != nil { - p.Logger.Printf("Modified text node #%d: '%s' -> '%s'", - i+1, LimitString(textContent, 30), LimitString(newValue, 30)) - } - } - } - continue // Move to next node + // Convert to Lua variables + err = p.ToLua(L, originalValue) + if err != nil { + return content, modCount, matchCount, fmt.Errorf("error converting to Lua: %v", err) } - // Convert the node to a Lua table - nodeTable := p.NodeToLuaTable(L, node) - - // Set the node in Lua global variable for user script - L.SetGlobal("node", nodeTable) - - // Execute user's Lua script + // Execute Lua script if err := L.DoString(luaExpr); err != nil { - if p.Logger != nil { - p.Logger.Printf("Lua execution failed for node #%d: %v", i+1, err) - } - continue // Skip this node on error + return content, modCount, matchCount, fmt.Errorf("error executing Lua: %v", err) } - // Get modified node from Lua - modifiedNode, changed := p.GetModifiedNode(L, node) - if !changed { - if p.Logger != nil { - p.Logger.Printf("Node #%d was not modified by script", i+1) - } + // Get modified value + result, err := p.FromLua(L) + if err != nil { + return content, modCount, matchCount, fmt.Errorf("error getting result from Lua: %v", err) + } + + newValue, ok := result.(string) + if !ok { + return content, modCount, matchCount, fmt.Errorf("expected string result from Lua, got %T", result) + } + + // Skip if no change + if newValue == originalValue { continue } - // Render the modified node back to XML - modifiedNodeText := p.XMLNodeToString(modifiedNode) - - // Replace just this node in the document - if originalNodeText != modifiedNodeText { - modifiedContent = strings.Replace( - modifiedContent, - originalNodeText, - modifiedNodeText, - 1) - modificationCount++ - - // Record the modification for reporting - modifications = append(modifications, ModificationRecord{ - File: "", - OldValue: LimitString(originalNodeText, 30), - NewValue: LimitString(modifiedNodeText, 30), - Operation: originalExpr, - Context: fmt.Sprintf("(XPath: %s)", pattern), - }) - - if p.Logger != nil { - p.Logger.Printf("Modified node #%d", i+1) + // Apply modification + if node.Type == xmlquery.AttributeNode { + // For attribute nodes, update the attribute value + node.Parent.Attr = append([]xmlquery.Attr{}, node.Parent.Attr...) + for i, attr := range node.Parent.Attr { + if attr.Name.Local == node.Data { + node.Parent.Attr[i].Value = newValue + break + } } + } else if node.Type == xmlquery.TextNode { + // For text nodes, update the text content + node.Data = newValue + } else { + // For element nodes, replace inner text + // Simple approach: set the InnerText directly if there are no child elements + if node.FirstChild == nil || (node.FirstChild != nil && node.FirstChild.Type == xmlquery.TextNode && node.FirstChild.NextSibling == nil) { + if node.FirstChild != nil { + node.FirstChild.Data = newValue + } else { + // Create a new text node and add it as the first child + textNode := &xmlquery.Node{ + Type: xmlquery.TextNode, + Data: newValue, + } + node.FirstChild = textNode + } + } else { + // Complex case: node has mixed content or child elements + // Replace just the text content while preserving child elements + // This is a simplified approach - more complex XML may need more robust handling + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == xmlquery.TextNode { + child.Data = newValue + break // Update only the first text node + } + } + } + } + + 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 declaration + doc.OutputXML(true), modCount, matchCount, nil + } + + return doc.OutputXML(true), modCount, matchCount, nil +} + +// ToLua converts XML node values to Lua variables +func (p *XMLProcessor) ToLua(L *lua.LState, data interface{}) error { + value, ok := data.(string) + if !ok { + return fmt.Errorf("expected string value, got %T", data) + } + + // Set as string variable + L.SetGlobal("s1", lua.LString(value)) + + // Try to convert to number if possible + L.SetGlobal("v1", lua.LNumber(0)) // Default to 0 + if err := L.DoString(fmt.Sprintf("v1 = tonumber(%q) or 0", value)); err != nil { + return fmt.Errorf("error converting value to number: %v", err) + } + + return nil +} + +// FromLua gets modified values from Lua +func (p *XMLProcessor) FromLua(L *lua.LState) (interface{}, error) { + // Check if string variable was modified + s1 := L.GetGlobal("s1") + if s1 != lua.LNil { + if s1Str, ok := s1.(lua.LString); ok { + return string(s1Str), nil } } - if p.Logger != nil && modificationCount > 0 { - p.Logger.Printf("Made %d XML node modifications", modificationCount) + // Check if numeric variable was modified + v1 := L.GetGlobal("v1") + if v1 != lua.LNil { + if v1Num, ok := v1.(lua.LNumber); ok { + return fmt.Sprintf("%v", v1Num), nil + } } - return modifiedContent, modificationCount, matchCount, nil + // Default return empty string + return "", nil } diff --git a/processor/xml_test.go b/processor/xml_test.go index 1a7ddb9..484a77e 100644 --- a/processor/xml_test.go +++ b/processor/xml_test.go @@ -4,12 +4,18 @@ import ( "strings" "testing" - "github.com/antchfx/xmlquery" + "regexp" ) -func TestXMLProcessor_Process_TextNodes(t *testing.T) { - // Test XML file with price tags that we want to modify - testXML := ` +// Helper function to normalize whitespace for comparison +func normalizeXMLWhitespace(s string) string { + // Replace all whitespace sequences with a single space + re := regexp.MustCompile(`\s+`) + return re.ReplaceAllString(strings.TrimSpace(s), " ") +} + +func TestXMLProcessor_Process_NodeValues(t *testing.T) { + content := ` Gambardella, Matthew @@ -17,6 +23,7 @@ func TestXMLProcessor_Process_TextNodes(t *testing.T) { Computer 44.95 2000-10-01 + An in-depth look at creating applications with XML. Ralls, Kim @@ -24,322 +31,1502 @@ func TestXMLProcessor_Process_TextNodes(t *testing.T) { Fantasy 5.95 2000-12-16 + A former architect battles corporate zombies. ` - // Create an XML processor - processor := NewXMLProcessor(&TestLogger{}) + expected := ` + + + Gambardella, Matthew + XML Developer's Guide + Computer + 89.9 + 2000-10-01 + An in-depth look at creating applications with XML. + + + Ralls, Kim + Midnight Rain + Fantasy + 11.9 + 2000-12-16 + A former architect battles corporate zombies. + +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//price", "v = v * 2") - // Process the XML content directly to double all prices - xpathExpr := "//price/text()" - modifiedXML, modCount, matchCount, err := processor.ProcessContent(testXML, xpathExpr, "v1 = v1 * 2", "*2") if err != nil { - t.Fatalf("Failed to process XML content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check that we found and modified the correct number of nodes if matchCount != 2 { - t.Errorf("Expected to match 2 nodes, got %d", matchCount) + t.Errorf("Expected 2 matches, got %d", matchCount) } + if modCount != 2 { - t.Errorf("Expected to modify 2 nodes, got %d", modCount) + t.Errorf("Expected 2 modifications, got %d", modCount) } - // Check that prices were doubled - if !strings.Contains(modifiedXML, "89.9") { - t.Errorf("Modified content does not contain doubled price 89.9") - } - if !strings.Contains(modifiedXML, "11.9") { - t.Errorf("Modified content does not contain doubled price 11.9") - } + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) - // Verify we can parse the XML after modification - _, err = xmlquery.Parse(strings.NewReader(modifiedXML)) - if err != nil { - t.Errorf("Modified XML is not valid: %v", err) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -func TestXMLProcessor_Process_Elements(t *testing.T) { - // Test XML file with elements that we want to modify attributes of - testXML := ` +func TestXMLProcessor_Process_Attributes(t *testing.T) { + content := ` - - - + Widget A + Widget B ` - // Create an XML processor - processor := NewXMLProcessor(&TestLogger{}) + expected := ` + + Widget A + Widget B +` - // Process the file to modify the value attribute - // We'll create a more complex Lua script that deals with the node table - luaScript := ` - -- Get the current value attribute - local valueAttr = node.attributes.value - if valueAttr then - -- Convert to number and add 50 - local numValue = tonumber(valueAttr) - if numValue then - -- Update the value in the attributes table - node.attributes.value = tostring(numValue + 50) - end - end - ` + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//item/@price", "v = v * 2") - // Process the XML content directly - xpathExpr := "//item" - modifiedXML, modCount, matchCount, err := processor.ProcessContent(testXML, xpathExpr, luaScript, "Add 50 to values") if err != nil { - t.Fatalf("Failed to process XML content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check that we found and modified the correct number of nodes - if matchCount != 3 { - t.Errorf("Expected to match 3 item nodes, got %d", matchCount) - } - if modCount != 3 { - t.Errorf("Expected to modify 3 nodes, got %d", modCount) - } - - // Check that values were increased by 50 - if !strings.Contains(modifiedXML, `value="150"`) { - t.Errorf("Modified content does not contain updated value 150") - } - if !strings.Contains(modifiedXML, `value="250"`) { - t.Errorf("Modified content does not contain updated value 250") - } - if !strings.Contains(modifiedXML, `value="350"`) { - t.Errorf("Modified content does not contain updated value 350") - } - - // Verify we can parse the XML after modification - _, err = xmlquery.Parse(strings.NewReader(modifiedXML)) - if err != nil { - t.Errorf("Modified XML is not valid: %v", err) - } -} - -// New test for adding attributes to XML elements -func TestXMLProcessor_AddAttributes(t *testing.T) { - testXML := ` - - Content - Another -` - - processor := NewXMLProcessor(&TestLogger{}) - - // Add a new attribute to each element - luaScript := ` - -- Add a new attribute - node.attributes.status = "active" - -- Also add another attribute with a sequential number - node.attributes.index = tostring(_POSITION) - ` - - xpathExpr := "//element" - modifiedXML, modCount, matchCount, err := processor.ProcessContent(testXML, xpathExpr, luaScript, "Add attributes") - if err != nil { - t.Fatalf("Failed to process XML content: %v", err) - } - - // Check counts if matchCount != 2 { - t.Errorf("Expected to match 2 nodes, got %d", matchCount) + t.Errorf("Expected 2 matches, got %d", matchCount) } + if modCount != 2 { - t.Errorf("Expected to modify 2 nodes, got %d", modCount) + t.Errorf("Expected 2 modifications, got %d", modCount) } - // Verify the new attributes - if !strings.Contains(modifiedXML, `status="active"`) { - t.Errorf("Modified content does not contain added status attribute") - } + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) - if !strings.Contains(modifiedXML, `index="1"`) && !strings.Contains(modifiedXML, `index="2"`) { - t.Errorf("Modified content does not contain added index attributes") - } - - // Verify the XML is valid - _, err = xmlquery.Parse(strings.NewReader(modifiedXML)) - if err != nil { - t.Errorf("Modified XML is not valid: %v", err) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -// Test for adding new child elements -func TestXMLProcessor_AddChildElements(t *testing.T) { - testXML := ` - - - Product One - 10.99 - - - Product Two - 20.99 - -` +func TestXMLProcessor_Process_ElementText(t *testing.T) { + content := ` + + john + mary +` - processor := NewXMLProcessor(&TestLogger{}) + expected := ` + + JOHN + MARY +` - // Add a new child element to each product - luaScript := ` - -- Create a new "discount" child element - local discount = create_node("discount") - -- Calculate discount as 10% of price - local priceText = "" - for _, child in ipairs(node.children) do - if child.name == "price" and child.children[1] then - priceText = child.children[1].data - break - end - end - - local price = tonumber(priceText) or 0 - local discountValue = price * 0.1 - - -- Add text content to the discount element - discount.children[1] = {type="text", data=string.format("%.2f", discountValue)} - - -- Add the new element as a child - add_child(node, discount) - ` + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//n/text()", "v = string.upper(v)") - xpathExpr := "//product" - modifiedXML, modCount, matchCount, err := processor.ProcessContent(testXML, xpathExpr, luaScript, "Add discount elements") if err != nil { - t.Fatalf("Failed to process XML content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check counts if matchCount != 2 { - t.Errorf("Expected to match 2 nodes, got %d", matchCount) + t.Errorf("Expected 2 matches, got %d", matchCount) } + if modCount != 2 { - t.Errorf("Expected to modify 2 nodes, got %d", modCount) + t.Errorf("Expected 2 modifications, got %d", modCount) } - // Verify the new elements - if !strings.Contains(modifiedXML, "1.10") { - t.Errorf("Modified content does not contain first discount element") - } + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) - if !strings.Contains(modifiedXML, "2.10") { - t.Errorf("Modified content does not contain second discount element") - } - - // Verify the XML is valid - _, err = xmlquery.Parse(strings.NewReader(modifiedXML)) - if err != nil { - t.Errorf("Modified XML is not valid: %v", err) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -// Test for complex XML transformations -func TestXMLProcessor_ComplexTransformation(t *testing.T) { - testXML := ` +func TestXMLProcessor_Process_ElementAddition(t *testing.T) { + content := ` - - - + 30 + 100 ` - processor := NewXMLProcessor(&TestLogger{}) + expected := ` + + + 60 + 200 + +` - // Complex transformation that changes attributes based on name - luaScript := ` - local name = node.attributes.name - local value = node.attributes.value + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//settings/*", "v = v * 2") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_ComplexXML(t *testing.T) { + content := ` + + + + Laptop + 999.99 + + + Smartphone + 499.99 + + + + + T-Shirt + 19.99 + + +` + + expected := ` + + + + Laptop + 1199.988 + + + Smartphone + 599.988 + + + + + T-Shirt + 23.988 + + +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//price", "v = v * 1.2") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 3 { + t.Errorf("Expected 3 matches, got %d", matchCount) + } + + if modCount != 3 { + t.Errorf("Expected 3 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// New tests added below + +func TestXMLProcessor_ConditionalModification(t *testing.T) { + content := ` + + + + +` + + expected := ` + + + + +` + + p := &XMLProcessor{} + // Apply 20% discount but only for items with stock > 0 + luaExpr := ` + -- In the table-based approach, attributes are accessible directly + if v.stock and tonumber(v.stock) > 0 then + v.price = tonumber(v.price) * 0.8 + -- Format to 2 decimal places + v.price = string.format("%.2f", v.price) + end + ` + result, modCount, matchCount, err := p.ProcessContent(content, "//item", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 3 { + t.Errorf("Expected 3 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_SpecialCharacters(t *testing.T) { + content := ` + + This & that + a < b + c > d + Quote: "Hello" +` + + expected := ` + + THIS & THAT + A < B + C > D + QUOTE: "HELLO" +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//entry", "v = string.upper(v)") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 4 { + t.Errorf("Expected 4 matches, got %d", matchCount) + } + + if modCount != 4 { + t.Errorf("Expected 4 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_ChainedOperations(t *testing.T) { + content := ` + + + Widget + 100 + 20 + +` + + // Apply multiple operations to the price: add tax, apply discount, round + luaExpr := ` + -- When v is a numeric string, we can perform math operations directly + local price = v + -- Add 15% tax + price = price * 1.15 + -- Apply 10% discount + price = price * 0.9 + -- Round to 2 decimal places + price = math.floor(price * 100 + 0.5) / 100 + v = price + ` + + expected := ` + + + Widget + 103.5 + 20 + +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//price", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_MathFunctions(t *testing.T) { + content := ` + + 3.14159 + 2.71828 + 1.41421 +` + + expected := ` + + 3 + 3 + 1 +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//measurement", "v = round(v)") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 3 { + t.Errorf("Expected 3 matches, got %d", matchCount) + } + + if modCount != 3 { + t.Errorf("Expected 3 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_StringOperations(t *testing.T) { + content := ` + + + John Doe + john.doe@example.com + 123-456-7890 + +` + + expected := ` + + + John Doe + johndoe@anon.com + 123-XXX-XXXX + +` + + // Test email anonymization + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//email", ` + -- With the table approach, v contains the text content directly + v = string.gsub(v, "@.+", "@anon.com") + local username = string.match(v, "(.+)@") + v = string.gsub(username, "%.", "") .. "@anon.com" + `) + + if err != nil { + t.Fatalf("Error processing email content: %v", err) + } + + // Test phone number masking + result, modCount2, matchCount2, err := p.ProcessContent(result, "//phone", ` + v = string.gsub(v, "%d%d%d%-%d%d%d%-%d%d%d%d", function(match) + return string.sub(match, 1, 3) .. "-XXX-XXXX" + end) + `) + + if err != nil { + t.Fatalf("Error processing phone content: %v", err) + } + + // Total counts from both operations + matchCount += matchCount2 + modCount += modCount2 + + if matchCount != 2 { + t.Errorf("Expected 2 total matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 total modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_DateManipulation(t *testing.T) { + content := ` + + + Conference + 2023-06-15 + + + Workshop + 2023-06-20 + +` + + expected := ` + + + Conference + 2023-07-15 + + + Workshop + 2023-07-20 + +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//date", ` + local year, month, day = string.match(v, "(%d%d%d%d)-(%d%d)-(%d%d)") + -- Postpone events by 1 month + month = tonumber(month) + 1 + if month > 12 then + month = 1 + year = tonumber(year) + 1 + end + v = string.format("%04d-%02d-%s", tonumber(year), month, day) + `) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_Error_InvalidXML(t *testing.T) { + content := ` + + +` + + p := &XMLProcessor{} + _, _, _, err := p.ProcessContent(content, "//unclosed", "v1=v1") + + if err == nil { + t.Errorf("Expected an error for invalid XML, but got none") + } +} + +func TestXMLProcessor_Process_Error_InvalidXPath(t *testing.T) { + content := ` + + value +` + + p := &XMLProcessor{} + _, _, _, err := p.ProcessContent(content, "[invalid xpath]", "v1=v1") + + if err == nil { + t.Errorf("Expected an error for invalid XPath, but got none") + } +} + +func TestXMLProcessor_Process_Error_InvalidLua(t *testing.T) { + content := ` + + 123 +` + + p := &XMLProcessor{} + _, _, _, err := p.ProcessContent(content, "//element", "v1 = invalid_function()") + + if err == nil { + t.Errorf("Expected an error for invalid Lua, but got none") + } +} + +func TestXMLProcessor_Process_NoChanges(t *testing.T) { + content := ` + + 123 +` + + p := &XMLProcessor{} + result, modCount, matchCount, err := p.ProcessContent(content, "//element", "v1 = v1") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 0 { + t.Errorf("Expected 0 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedContent := normalizeXMLWhitespace(content) + + if normalizedResult != normalizedContent { + t.Errorf("Expected content to be unchanged") + } +} + +func TestXMLProcessor_Process_ComplexXPathSelectors(t *testing.T) { + content := ` + + + + The Imaginary World + Alice Johnson + 19.99 + + + History of Science + Bob Smith + 29.99 + + + Future Tales + Charlie Adams + 24.99 + + +` + + expected := ` + + + + The Imaginary World + Alice Johnson + 15.99 + + + History of Science + Bob Smith + 29.99 + + + Future Tales + Charlie Adams + 19.99 + + +` + + p := &XMLProcessor{} + // Target only fiction books and apply 20% discount to price + result, modCount, matchCount, err := p.ProcessContent(content, "//book[@category='fiction']/price", "v = v * 0.8") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_NestedStructureModification(t *testing.T) { + content := ` + + + + + 10 + 15 + 12 + + + Sword + Leather + + + + + 14 + 10 + 16 + + + Axe + Chain Mail + + + +` + + expected := ` + + + + + 12 + 18 + 14 + + + Sword + Leather + + + + + 14 + 10 + 16 + + + Axe + Chain Mail + + + +` + + p := &XMLProcessor{} + + // Boost hero stats by 20% + result, modCount, matchCount, err := p.ProcessContent(content, "//character[@id='hero']/stats/*", "v = math.floor(v * 1.2)") + if err != nil { + t.Fatalf("Error processing stats content: %v", err) + } + + // Also upgrade hero equipment + result, modCount2, matchCount2, err := p.ProcessContent(result, "//character[@id='hero']/equipment/*/@damage|//character[@id='hero']/equipment/*/@defense", "v = v + 2") + if err != nil { + t.Fatalf("Error processing equipment content: %v", err) + } + + totalMatches := matchCount + matchCount2 + totalMods := modCount + modCount2 + + if totalMatches != 5 { // 3 stats + 2 equipment attributes + t.Errorf("Expected 5 total matches, got %d", totalMatches) + } + + if totalMods != 5 { // 3 stats + 2 equipment attributes + t.Errorf("Expected 5 total modifications, got %d", totalMods) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_ElementReplacement(t *testing.T) { + content := ` + + + Apple + 1.99 + 10 + + + Carrot + 0.99 + 5 + +` + + expected := ` + + + Apple + 1.99 + 10 + 19.90 + + + Carrot + 0.99 + 5 + 4.95 + +` + + // This test demonstrates using variables from multiple elements to calculate a new value + // With the table approach, we can directly access child elements + p := &XMLProcessor{} + + luaExpr := ` + -- With a proper table approach, this becomes much simpler + local price = tonumber(v.price) + local quantity = tonumber(v.quantity) - if name == "timeout" then - -- Double the timeout - node.attributes.value = tostring(tonumber(value) * 2) - -- Add a unit attribute - node.attributes.unit = "seconds" - elseif name == "retries" then - -- Increase retries by 2 - node.attributes.value = tostring(tonumber(value) + 2) - -- Add a comment element as sibling - local comment = create_node("comment") - comment.children[1] = {type="text", data="Increased for reliability"} - - -- We can't directly add siblings in this implementation - -- But this would be the place to do it if supported - elseif name == "enabled" and value == "true" then - -- Add a priority attribute for enabled settings - node.attributes.priority = "high" + -- Add a new total element + v.total = string.format("%.2f", price * quantity) + ` + + result, modCount, matchCount, err := p.ProcessContent(content, "//item", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_AttributeAddition(t *testing.T) { + content := ` + + + Laptop + 999.99 + true + + + Phone + 499.99 + false + +` + + expected := ` + + + Laptop + 999.99 + true + + + Phone + 499.99 + false + +` + + // This test demonstrates adding a new attribute based on element content + p := &XMLProcessor{} + + luaExpr := ` + -- With table approach, this becomes much cleaner + -- We can access the "inStock" element directly + if v.inStock == "true" then + -- Add a new attribute directly + v._attr = v._attr or {} + v._attr.status = "available" + else + v._attr = v._attr or {} + v._attr.status = "out-of-stock" end ` - xpathExpr := "//setting" - modifiedXML, _, matchCount, err := processor.ProcessContent(testXML, xpathExpr, luaScript, "Transform settings") + result, modCount, matchCount, err := p.ProcessContent(content, "//product", luaExpr) + if err != nil { - t.Fatalf("Failed to process XML content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // Check counts - if matchCount != 3 { - t.Errorf("Expected to match 3 nodes, got %d", matchCount) + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) } - // Verify the transformed attributes - if !strings.Contains(modifiedXML, `value="60"`) { - t.Errorf("Modified content does not have doubled timeout value") + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) } - if !strings.Contains(modifiedXML, `unit="seconds"`) { - t.Errorf("Modified content does not have added unit attribute") - } + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) - if !strings.Contains(modifiedXML, `value="5"`) { - t.Errorf("Modified content does not have increased retries value") - } - - if !strings.Contains(modifiedXML, `priority="high"`) { - t.Errorf("Modified content does not have added priority attribute") - } - - // Verify the XML is valid - _, err = xmlquery.Parse(strings.NewReader(modifiedXML)) - if err != nil { - t.Errorf("Modified XML is not valid: %v", err) + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) } } -// Test for handling special XML characters -func TestXMLProcessor_SpecialCharacters(t *testing.T) { - testXML := ` - - "here"]]> - Regular & text with markup -` +func TestXMLProcessor_Process_ElementRemoval(t *testing.T) { + content := ` + + + John Smith + john@example.com + secret123 + admin + + + Jane Doe + jane@example.com + pass456 + user + +` - processor := NewXMLProcessor(&TestLogger{}) + expected := ` + + + John Smith + john@example.com + admin + + + Jane Doe + jane@example.com + user + +` - // Process text nodes, being careful with special characters - luaScript := ` - -- For text nodes, replace & with & - s1 = string.gsub(s1, "&([^;])", "&%1") + // This test demonstrates removing sensitive data elements + p := &XMLProcessor{} + + luaExpr := ` + -- With table approach, element removal is trivial + -- Just set the element to nil to remove it + v.password = nil ` - xpathExpr := "//item/text()" - modifiedXML, _, _, err := processor.ProcessContent(testXML, xpathExpr, luaScript, "Handle special chars") + result, modCount, matchCount, err := p.ProcessContent(content, "//user", luaExpr) + if err != nil { - t.Fatalf("Failed to process XML content: %v", err) + t.Fatalf("Error processing content: %v", err) } - // CDATA sections should be preserved - if !strings.Contains(modifiedXML, " + + + Bob Dylan + Blowin' in the Wind + 1963 + + + The Beatles + Hey Jude + 1968 + +` + + expected := ` + + + Blowin' in the Wind + Bob Dylan + 1963 + + + Hey Jude + The Beatles + 1968 + +` + + // This test demonstrates reordering elements + p := &XMLProcessor{} + + luaExpr := ` + -- With table approach, we can reorder elements by redefining the table + -- Store the values + local artist = v.artist + local title = v.title + local year = v.year + + -- Clear the table + for k in pairs(v) do + v[k] = nil + end + + -- Add elements in the desired order + v.title = title + v.artist = artist + v.year = year + ` + + result, modCount, matchCount, err := p.ProcessContent(content, "//song", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_ComplexStructuralChange(t *testing.T) { + content := ` + + + The Great Gatsby + F. Scott Fitzgerald + 1925 + 10.99 + + + A Brief History of Time + Stephen Hawking + 1988 + 15.99 + +` + + expected := ` + + +
+ The Great Gatsby + F. Scott Fitzgerald + 1925 +
+ + 10.99 + 0 + + + fiction + +
+ +
+ A Brief History of Time + Stephen Hawking + 1988 +
+ + 15.99 + 0 + + + non-fiction + +
+
` + + // This test demonstrates a complete restructuring of the XML using table approach + p := &XMLProcessor{} + + luaExpr := ` + -- Store the original values + local category = v._attr and v._attr.category + local title = v.title + local author = v.author + local year = v.year + local price = v.price + + -- Clear the original structure + for k in pairs(v) do + v[k] = nil + end + + -- Create a new nested structure + v.details = { + title = title, + author = author, + year = year + } + + v.pricing = { + price = { + _attr = { currency = "USD" }, + _text = price + }, + discount = "0" + } + + v.metadata = { + category = category + } + ` + + result, modCount, matchCount, err := p.ProcessContent(content, "//book", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_DynamicXPath(t *testing.T) { + content := ` + + + + + + + + + + +` + + expected := ` + + + + + + + + + + +` + + // This test demonstrates using specific XPath queries to select precise nodes + p := &XMLProcessor{} + + // Double all timeout values in the configuration + result, modCount, matchCount, err := p.ProcessContent(content, "//setting[@name='timeout']/@value", "v = v * 2") + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 2 { + t.Errorf("Expected 2 matches, got %d", matchCount) + } + + if modCount != 2 { + t.Errorf("Expected 2 modifications, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_TableBasedStructureCreation(t *testing.T) { + content := ` + + + +` + + expected := ` + + + + + + 2 + Debug: OFF, Logging: info + + +` + + // This test demonstrates adding a completely new section with nested structure + p := &XMLProcessor{} + + luaExpr := ` + -- Count all options + local count = 0 + local summary = "" + + -- Process each child option + if v.settings and v.settings.option then + local options = v.settings.option + -- If there's just one option, wrap it in a table + if options._attr then + options = {options} + end + + for i, opt in ipairs(options) do + count = count + 1 + if opt._attr.name == "debug" then + summary = summary .. "Debug: " .. (opt._attr.value == "true" and "ON" or "OFF") + elseif opt._attr.name == "log_level" then + summary = summary .. "Logging: " .. opt._attr.value + end + + if i < #options then + summary = summary .. ", " + end + end + end + + -- Create a new calculated section + v.calculated = { + stats = { + count = tostring(count), + summary = summary + } + } + ` + + result, modCount, matchCount, err := p.ProcessContent(content, "/data", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_ArrayManipulation(t *testing.T) { + content := ` + + + + Book 1 + 200 + + + Book 2 + 150 + + + Book 3 + 300 + + +` + + expected := ` + + + + Book 3 + 300 + + + Book 1 + 200 + + + + 2 + 500 + 250 + +` + + // This test demonstrates advanced manipulation including: + // 1. Sorting and filtering arrays of elements + // 2. Calculating aggregates + // 3. Generating summaries + p := &XMLProcessor{} + + luaExpr := ` + -- Get the books array + local books = v.books.book + + -- If only one book, wrap it in a table + if books and not books[1] then + books = {books} + end + + -- Filter and sort books + local filtered_books = {} + local total_pages = 0 + + for _, book in ipairs(books) do + local pages = tonumber(book.pages) or 0 + + -- Filter: only keep books with pages >= 200 + if pages >= 200 then + total_pages = total_pages + pages + table.insert(filtered_books, book) + end + end + + -- Sort books by number of pages (descending) + table.sort(filtered_books, function(a, b) + return tonumber(a.pages) > tonumber(b.pages) + end) + + -- Replace the books array with our filtered and sorted one + v.books.book = filtered_books + + -- Add summary information + local count = #filtered_books + local average_pages = count > 0 and math.floor(total_pages / count) or 0 + + v.summary = { + count = tostring(count), + total_pages = tostring(total_pages), + average_pages = tostring(average_pages) + } + ` + + result, modCount, matchCount, err := p.ProcessContent(content, "/library", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +func TestXMLProcessor_Process_DeepPathNavigation(t *testing.T) { + content := ` + + + + + localhost + 3306 + + admin + secret + + + + 10 + 30 + + + + info + /var/log/app.log + + +` + + expected := ` + + + + + db.example.com + 5432 + + admin + REDACTED + + + + 20 + 60 + + + + debug + /var/log/app.log + + + + production + true + true + +` + + // This test demonstrates navigating deeply nested elements in a complex XML structure + p := &XMLProcessor{} + + luaExpr := ` + -- Update database connection settings + v.config.database.connection.host = "db.example.com" + v.config.database.connection.port = "5432" + + -- Redact sensitive information + v.config.database.connection.credentials.password = "REDACTED" + + -- Double pool size and timeout + v.config.database.pool.size = tostring(tonumber(v.config.database.pool.size) * 2) + v.config.database.pool.timeout = tostring(tonumber(v.config.database.pool.timeout) * 2) + + -- Change logging level + v.config.logging.level = "debug" + + -- Add a new status section + v.status = { + environment = "production", + updated = "true", + secure = tostring(v.config.database.connection.credentials.password == "REDACTED") + } + ` + + result, modCount, matchCount, err := p.ProcessContent(content, "/application", luaExpr) + + if err != nil { + t.Fatalf("Error processing content: %v", err) + } + + if matchCount != 1 { + t.Errorf("Expected 1 match, got %d", matchCount) + } + + if modCount != 1 { + t.Errorf("Expected 1 modification, got %d", modCount) + } + + // Normalize whitespace for comparison + normalizedResult := normalizeXMLWhitespace(result) + normalizedExpected := normalizeXMLWhitespace(expected) + + if normalizedResult != normalizedExpected { + t.Errorf("Expected content to be:\n%s\n\nGot:\n%s", expected, result) + } +} + +// Add more test cases for specific XML manipulation scenarios below +// These tests would cover additional functionality as the implementation progresses