diff --git a/sourcecode-parser/dsl/call_matcher.go b/sourcecode-parser/dsl/call_matcher.go index 53b6a5a2..d000355e 100644 --- a/sourcecode-parser/dsl/call_matcher.go +++ b/sourcecode-parser/dsl/call_matcher.go @@ -1,6 +1,7 @@ package dsl import ( + "strconv" "strings" "github.com/shivasurya/code-pathfinder/sourcecode-parser/graph/callgraph/core" @@ -48,17 +49,36 @@ func (e *CallMatcherExecutor) Execute() []core.CallSite { return matches } -// matchesCallSite checks if a call site matches any pattern. +// matchesCallSite checks if a call site matches the pattern AND argument constraints. +// +// Algorithm: +// 1. Check if function name matches any pattern +// 2. Check if arguments satisfy constraints +// 3. Return true only if both match +// +// Performance: O(P + A) where P=patterns, A=arguments. func (e *CallMatcherExecutor) matchesCallSite(cs *core.CallSite) bool { target := cs.Target + // Step 1: Check if function name matches + matchesTarget := false for _, pattern := range e.IR.Patterns { if e.matchesPattern(target, pattern) { - return true // match_mode="any" (default) + matchesTarget = true + break } } - return false + if !matchesTarget { + return false // Function name doesn't match + } + + // Step 2: Check argument constraints + if !e.matchesArguments(cs) { + return false // Arguments don't match constraints + } + + return true // Both function name and arguments match! } // matchesPattern checks if target matches pattern (with wildcard support) @@ -179,3 +199,160 @@ func (e *CallMatcherExecutor) parseKeywordArguments(args []core.Argument) map[st return kwargs } + +// matchesArguments checks if CallSite arguments satisfy all constraints. +// +// Algorithm: +// 1. If no constraints, return true (backward compatibility) +// 2. Parse keyword arguments from CallSite +// 3. Check each constraint against actual values +// 4. Return true only if all constraints satisfied +// +// Performance: O(K) where K = number of keyword constraints (~1-3 typically). +func (e *CallMatcherExecutor) matchesArguments(cs *core.CallSite) bool { + // No constraints = always match (backward compatibility) + if len(e.IR.KeywordArgs) == 0 { + return true + } + + // Parse keyword arguments from CallSite + keywordArgs := e.parseKeywordArguments(cs.Arguments) + + // Check each keyword argument constraint + for name, constraint := range e.IR.KeywordArgs { + actualValue, exists := keywordArgs[name] + if !exists { + // Required keyword argument not present in call + return false + } + + if !e.matchesArgumentValue(actualValue, constraint) { + // Argument value doesn't match constraint + return false + } + } + + return true // All constraints satisfied! +} + +// matchesArgumentValue checks if actual value matches constraint. +// +// Handles: +// - Exact string match: "0.0.0.0" == "0.0.0.0" +// - Boolean match: "True" == true, "False" == false +// - Number match: "777" == 777, "0o777" == 0o777 (octal) +// - Case-insensitive for booleans: "true" == "True" == "TRUE" +// +// Performance: O(1) for single values. +func (e *CallMatcherExecutor) matchesArgumentValue(actual string, constraint ArgumentConstraint) bool { + // Clean actual value (remove quotes, trim whitespace) + actual = e.cleanValue(actual) + + // Get expected value from constraint + expected := constraint.Value + + // Type-specific matching + switch v := expected.(type) { + case string: + // String comparison + return e.normalizeValue(actual) == e.normalizeValue(v) + + case bool: + // Boolean comparison + return e.matchesBoolean(actual, v) + + case float64: + // Number comparison (JSON numbers are float64) + return e.matchesNumber(actual, v) + + case nil: + // Null/None comparison + return actual == "None" || actual == "nil" || actual == "null" + + default: + // Unknown type + return false + } +} + +// cleanValue removes surrounding quotes and whitespace from argument values. +// +// Examples: +// "\"0.0.0.0\"" → "0.0.0.0" +// "'localhost'" → "localhost" +// " True " → "True" +func (e *CallMatcherExecutor) cleanValue(value string) string { + value = strings.TrimSpace(value) + + // Remove surrounding quotes (single or double) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + + return value +} + +// normalizeValue normalizes string values for comparison. +// +// Handles: +// - Case-insensitive for specific values: "true" == "True" == "TRUE" +// - Preserves case for general strings: "MyValue" != "myvalue" +func (e *CallMatcherExecutor) normalizeValue(value string) string { + lower := strings.ToLower(value) + + // Normalize boolean strings (case-insensitive) + if lower == "true" || lower == "false" { + return lower + } + + // Normalize None/null (case-insensitive) + if lower == "none" || lower == "null" || lower == "nil" { + return "none" + } + + // For everything else, return as-is (case-sensitive) + return value +} + +// matchesBoolean checks if string represents a boolean value. +// +// Matches: +// - Python: True, False, true, false, TRUE, FALSE +// - JSON: true, false +// - Numeric: 1 (true), 0 (false) +func (e *CallMatcherExecutor) matchesBoolean(actual string, expected bool) bool { + actual = strings.ToLower(strings.TrimSpace(actual)) + + if expected { + // Match truthy values + return actual == "true" || actual == "1" + } + // Match falsy values + return actual == "false" || actual == "0" +} + +// matchesNumber checks if string represents a numeric value. +// +// Handles: +// - Integers: "777", "42" +// - Octal: "0o777", "0777" (Go format) +// - Hex: "0xFF", "0xff" +// - Floats: "3.14" +func (e *CallMatcherExecutor) matchesNumber(actual string, expected float64) bool { + actual = strings.TrimSpace(actual) + + // Try parsing as integer (supports decimal, octal, hex) + if i, err := strconv.ParseInt(actual, 0, 64); err == nil { + return float64(i) == expected + } + + // Try parsing as float + if f, err := strconv.ParseFloat(actual, 64); err == nil { + return f == expected + } + + return false +} diff --git a/sourcecode-parser/dsl/call_matcher_test.go b/sourcecode-parser/dsl/call_matcher_test.go index 8286e243..04acf0f8 100644 --- a/sourcecode-parser/dsl/call_matcher_test.go +++ b/sourcecode-parser/dsl/call_matcher_test.go @@ -345,3 +345,299 @@ func TestCallMatcherIR_BackwardCompatibility(t *testing.T) { assert.Len(t, matches, 1, "Old IR should still work") assert.Equal(t, "eval", matches[0].Target, "Old IR should match correctly") } + +// TestMatchesArguments_NoConstraints tests backward compatibility with no constraints. +func TestMatchesArguments_NoConstraints(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + // No KeywordArgs = should always match + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "debug=True", Position: 0}, + }, + } + + assert.True(t, executor.matchesArguments(&callSite), "Expected to match when no constraints present (backward compatibility)") +} + +// TestMatchesArguments_KeywordMatch_True tests matching debug=True. +func TestMatchesArguments_KeywordMatch_True(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "debug": {Value: true, Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "debug=True", Position: 0}, + }, + } + + assert.True(t, executor.matchesArguments(&callSite), "Expected to match debug=True") +} + +// TestMatchesArguments_KeywordMatch_False tests not matching when debug=False. +func TestMatchesArguments_KeywordMatch_False(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "debug": {Value: true, Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "debug=False", Position: 0}, + }, + } + + assert.False(t, executor.matchesArguments(&callSite), "Expected NOT to match debug=False when expecting True") +} + +// TestMatchesArguments_StringMatch tests string value matching. +func TestMatchesArguments_StringMatch(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "host": {Value: "0.0.0.0", Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "host=\"0.0.0.0\"", Position: 0}, + }, + } + + assert.True(t, executor.matchesArguments(&callSite), "Expected to match host=\"0.0.0.0\"") +} + +// TestMatchesArguments_StringNoMatch tests string mismatch. +func TestMatchesArguments_StringNoMatch(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "host": {Value: "0.0.0.0", Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "host=\"127.0.0.1\"", Position: 0}, + }, + } + + assert.False(t, executor.matchesArguments(&callSite), "Expected NOT to match host=\"127.0.0.1\" when expecting \"0.0.0.0\"") +} + +// TestMatchesArguments_MultipleConstraints tests AND logic with multiple constraints. +func TestMatchesArguments_MultipleConstraints(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "host": {Value: "0.0.0.0", Wildcard: false}, + "debug": {Value: true, Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "host=\"0.0.0.0\"", Position: 0}, + {Value: "debug=True", Position: 1}, + }, + } + + assert.True(t, executor.matchesArguments(&callSite), "Expected to match when all constraints satisfied") +} + +// TestMatchesArguments_PartialMatch tests that all constraints must match. +func TestMatchesArguments_PartialMatch(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "host": {Value: "0.0.0.0", Wildcard: false}, + "debug": {Value: true, Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "host=\"0.0.0.0\"", Position: 0}, // Matches + {Value: "debug=False", Position: 1}, // Doesn't match + }, + } + + assert.False(t, executor.matchesArguments(&callSite), "Expected NOT to match when one constraint fails (AND logic)") +} + +// TestMatchesArguments_MissingArgument tests handling of missing required argument. +func TestMatchesArguments_MissingArgument(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "debug": {Value: true, Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + callSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "host=\"0.0.0.0\"", Position: 0}, // Different argument + }, + } + + assert.False(t, executor.matchesArguments(&callSite), "Expected NOT to match when required argument missing") +} + +// TestCleanValue tests quote removal and whitespace trimming. +func TestCleanValue(t *testing.T) { + executor := &CallMatcherExecutor{IR: &CallMatcherIR{}} + + tests := []struct { + input string + expected string + }{ + {"\"0.0.0.0\"", "0.0.0.0"}, + {"'localhost'", "localhost"}, + {" True ", "True"}, + {"\"quoted string\"", "quoted string"}, + {"'single quoted'", "single quoted"}, + {"no quotes", "no quotes"}, + {" spaces ", "spaces"}, + } + + for _, test := range tests { + result := executor.cleanValue(test.input) + assert.Equal(t, test.expected, result, "cleanValue(%q) should equal %q", test.input, test.expected) + } +} + +// TestMatchesBoolean tests boolean value matching. +func TestMatchesBoolean(t *testing.T) { + executor := &CallMatcherExecutor{IR: &CallMatcherIR{}} + + tests := []struct { + actual string + expected bool + should bool + }{ + {"True", true, true}, + {"true", true, true}, + {"TRUE", true, true}, + {"1", true, true}, + {"False", false, true}, + {"false", false, true}, + {"FALSE", false, true}, + {"0", false, true}, + {"True", false, false}, + {"False", true, false}, + } + + for _, test := range tests { + result := executor.matchesBoolean(test.actual, test.expected) + assert.Equal(t, test.should, result, "matchesBoolean(%q, %v) should equal %v", test.actual, test.expected, test.should) + } +} + +// TestMatchesNumber tests numeric value matching. +func TestMatchesNumber(t *testing.T) { + executor := &CallMatcherExecutor{IR: &CallMatcherIR{}} + + tests := []struct { + actual string + expected float64 + should bool + }{ + {"777", 777.0, true}, + {"0o777", 511.0, true}, // Octal 777 = decimal 511 + {"0777", 511.0, true}, // Octal + {"0xFF", 255.0, true}, // Hex + {"3.14", 3.14, true}, + {"42", 42.0, true}, + {"777", 778.0, false}, + {"not a number", 777.0, false}, + } + + for _, test := range tests { + result := executor.matchesNumber(test.actual, test.expected) + assert.Equal(t, test.should, result, "matchesNumber(%q, %v) should equal %v", test.actual, test.expected, test.should) + } +} + +// TestMatchesCallSite_Integration tests end-to-end matching with arguments. +func TestMatchesCallSite_Integration(t *testing.T) { + executor := &CallMatcherExecutor{ + IR: &CallMatcherIR{ + Patterns: []string{"app.run"}, + KeywordArgs: map[string]ArgumentConstraint{ + "debug": {Value: true, Wildcard: false}, + }, + }, + CallGraph: &core.CallGraph{}, + } + + // Should match: correct function AND correct argument + matchCallSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "debug=True", Position: 0}, + }, + } + + assert.True(t, executor.matchesCallSite(&matchCallSite), "Expected to match app.run(debug=True)") + + // Should NOT match: correct function but wrong argument + noMatchCallSite := core.CallSite{ + Target: "app.run", + Arguments: []core.Argument{ + {Value: "debug=False", Position: 0}, + }, + } + + assert.False(t, executor.matchesCallSite(&noMatchCallSite), "Expected NOT to match app.run(debug=False)") + + // Should NOT match: wrong function (even with matching argument) + wrongFunctionCallSite := core.CallSite{ + Target: "other.run", + Arguments: []core.Argument{ + {Value: "debug=True", Position: 0}, + }, + } + + assert.False(t, executor.matchesCallSite(&wrongFunctionCallSite), "Expected NOT to match other.run() even with correct argument") +}