瀏覽代碼

feat(expressions): add global functions and fix nested property access

- Add GlobalFunctionAccessor singleton for implicitly available functions
- Add GlobalFunctionCallExpression for parsing standalone function calls
- Implement format() global function with printf-style formatting
- Fix GObject property lookup to convert underscores to dashes
- Add support for Enumerable<T> properties in ObjectPropertyAccessor
- Add comprehensive tests for format function and nested property access
Billy Barrow 1 周之前
父節點
當前提交
4427901b4d

+ 38 - 1
src/lib/Expressions/EvaluationContext.vala

@@ -30,6 +30,11 @@ namespace Invercargill.Expressions {
          */
         private Element? _parameter_value;
 
+        /**
+         * Singleton global function accessor for implicitly available functions.
+         */
+        private static GlobalFunctionAccessor? _global_functions = null;
+
         /**
          * Creates a new root evaluation context.
          * 
@@ -80,6 +85,11 @@ namespace Invercargill.Expressions {
                 return element;
             }
 
+            // Check for global functions
+            if (is_global_function(name)) {
+                return new GlobalFunctionsElement(get_global_functions());
+            }
+
             throw new ExpressionError.NON_EXISTANT_PROPERTY(
                 @"Variable '$name' not found in current scope"
             );
@@ -103,7 +113,34 @@ namespace Invercargill.Expressions {
             }
 
             // Check root values
-            return root_values.has(name);
+            if (root_values.has(name)) {
+                return true;
+            }
+
+            // Check for global functions
+            return is_global_function(name);
+        }
+
+        /**
+         * Checks if a name corresponds to a global function.
+         * 
+         * @param name The name to check
+         * @return true if it's a global function name
+         */
+        public static bool is_global_function(string name) {
+            return get_global_functions().has_function(name);
+        }
+
+        /**
+         * Gets the singleton global function accessor.
+         * 
+         * @return The GlobalFunctionAccessor instance
+         */
+        public static GlobalFunctionAccessor get_global_functions() {
+            if (_global_functions == null) {
+                _global_functions = new GlobalFunctionAccessor();
+            }
+            return _global_functions;
         }
 
         /**

+ 11 - 1
src/lib/Expressions/ExpressionParser.vala

@@ -356,9 +356,19 @@ namespace Invercargill.Expressions {
                 return new LiteralExpression(new NullElement());
             }
 
-            // Variable
+            // Variable or standalone function call
             if (match(TokenType.IDENTIFIER)) {
                 var token = previous();
+                
+                // Check if this is a standalone function call: func(args)
+                if (match(TokenType.LPAREN)) {
+                    // It's a global function call
+                    var args = parse_arguments();
+                    expect(TokenType.RPAREN, "Expected ')' after function arguments");
+                    
+                    return new GlobalFunctionCallExpression(token.value, args);
+                }
+                
                 return new VariableExpression(token.value);
             }
 

+ 5 - 0
src/lib/Expressions/ExpressionVisitor.vala

@@ -65,6 +65,11 @@ namespace Invercargill.Expressions {
          */
         public virtual void visit_lot_literal(LotLiteralExpression expr) {}
 
+        /**
+         * Visit a global function call expression.
+         */
+        public virtual void visit_global_function_call(GlobalFunctionCallExpression expr) {}
+
     }
 
 }

+ 85 - 0
src/lib/Expressions/Expressions/GlobalFunctionCallExpression.vala

@@ -0,0 +1,85 @@
+using Invercargill.DataStructures;
+
+namespace Invercargill.Expressions {
+
+    /**
+     * Expression that calls a global function.
+     * 
+     * Global functions are implicitly available without requiring a target object.
+     * They are resolved through the EvaluationContext's global function accessor.
+     * 
+     * Example: `format("Hello, %s!", name)`
+     */
+    public class GlobalFunctionCallExpression : Object, Expression {
+
+        public ExpressionType expression_type { get { return ExpressionType.FUNCTION_CALL; } }
+
+        /**
+         * The name of the global function to call.
+         */
+        public string function_name { get; construct set; }
+
+        /**
+         * The argument expressions to pass to the function.
+         */
+        public Series<Expression> arguments { get; construct set; }
+
+        /**
+         * Creates a new global function call expression.
+         * 
+         * @param function_name The name of the global function
+         * @param arguments Optional Series of argument expressions
+         */
+        public GlobalFunctionCallExpression(
+            string function_name,
+            Series<Expression>? arguments = null
+        ) {
+            Object(
+                function_name: function_name,
+                arguments: arguments ?? new Series<Expression>()
+            );
+        }
+
+        public Element evaluate(EvaluationContext context) throws ExpressionError {
+            // Get the global function accessor
+            var accessor = EvaluationContext.get_global_functions();
+
+            // Check if function exists
+            if (!accessor.has_function(function_name)) {
+                throw new ExpressionError.NON_EXISTANT_PROPERTY(
+                    @"Global function '$function_name' not found"
+                );
+            }
+
+            // Evaluate arguments
+            var evaluated_args = new Series<Element>();
+            foreach (var arg in arguments) {
+                evaluated_args.add(arg.evaluate(context));
+            }
+
+            return accessor.call_function(function_name, evaluated_args, context);
+        }
+
+        public void accept(ExpressionVisitor visitor) {
+            visitor.visit_global_function_call(this);
+            foreach (var arg in arguments) {
+                arg.accept(visitor);
+            }
+        }
+
+        public override string to_expression_string() {
+            var args_str = new StringBuilder();
+            bool first = true;
+            foreach (var arg in arguments) {
+                if (!first) {
+                    args_str.append(", ");
+                }
+                args_str.append(arg.to_expression_string());
+                first = false;
+            }
+            return @"$function_name($(args_str.str))";
+        }
+
+    }
+
+}

+ 317 - 0
src/lib/Expressions/GlobalFunctionAccessor.vala

@@ -0,0 +1,317 @@
+using Invercargill.DataStructures;
+
+namespace Invercargill.Expressions {
+
+    /**
+     * Function accessor for implicitly available global functions.
+     * 
+     * These functions are always available in expressions without needing
+     * to be explicitly registered. Currently supports:
+     * 
+     * - `format(template, ...args)` - Printf-style string formatting
+     * 
+     * Examples:
+     * - `format("Hello, %s!", name)` - String substitution
+     * - `format("You have %i items", count)` - Integer substitution
+     * - `format("Value: %.2f", price)` - Float formatting with precision
+     * - `format("%s #%i: %s", category, id, title)` - Multiple arguments
+     */
+    public class GlobalFunctionAccessor : Object, FunctionAccessor {
+
+        private static Series<string>? _function_names = null;
+
+        /**
+         * Gets the names of all available global functions.
+         */
+        public Enumerable<string> get_function_names() {
+            if (_function_names == null) {
+                _function_names = new Series<string>();
+                _function_names.add("format");
+            }
+            return _function_names;
+        }
+
+        /**
+         * Checks if a global function exists.
+         */
+        public bool has_function(string name) {
+            return name == "format";
+        }
+
+        /**
+         * Calls a global function with the given arguments.
+         * 
+         * @param function_name The function name
+         * @param arguments The function arguments as a Series
+         * @param context The evaluation context
+         * @return The result of the function call
+         * @throws ExpressionError if the function doesn't exist or arguments are invalid
+         */
+        public Element call_function(string function_name, Series<Element> arguments, EvaluationContext context) throws ExpressionError {
+            if (function_name == "format") {
+                return call_format(arguments);
+            }
+            
+            throw new ExpressionError.FUNCTION_NOT_FOUND(
+                @"Unknown global function '$function_name'"
+            );
+        }
+
+        /**
+         * Calls the format function.
+         * 
+         * Signature: format(template: string, ...args: any) -> string
+         * 
+         * Applies printf-style formatting to the template string using
+         * the provided arguments. Supports:
+         * - %s - String
+         * - %i, %d - Integer
+         * - %f - Float/double
+         * - %x - Hexadecimal (lowercase)
+         * - %X - Hexadecimal (uppercase)
+         * - %o - Octal
+         * - %% - Literal percent sign
+         * 
+         * Format specifiers can include width and precision, e.g.:
+         * - %10s - Right-aligned string in 10 characters
+         * - %-10s - Left-aligned string in 10 characters
+         * - %.2f - Float with 2 decimal places
+         * - %05i - Integer padded with zeros to 5 digits
+         */
+        private Element call_format(Series<Element> arguments) throws ExpressionError {
+            var args = arguments.to_array();
+            
+            if (args.length == 0) {
+                throw new ExpressionError.INVALID_ARGUMENTS(
+                    "format() requires at least 1 argument (the format template)"
+                );
+            }
+
+            // Get the format template
+            string? template;
+            if (!args[0].try_get_as<string?>(out template)) {
+                throw new ExpressionError.TYPE_MISMATCH(
+                    "format() first argument must be a string template"
+                );
+            }
+
+            if (template == null) {
+                return new NativeElement<string?>((string?)null);
+            }
+
+            // Build the formatted string by processing format specifiers
+            // (even with no args, we need to handle %% escape sequences)
+            var result = new StringBuilder();
+            int arg_index = 1;
+            int i = 0;
+            
+            while (i < template.length) {
+                if (template[i] == '%' && i + 1 < template.length) {
+                    // Parse format specifier
+                    int start = i;
+                    i++; // Skip '%'
+                    
+                    // Handle literal %
+                    if (template[i] == '%') {
+                        result.append_c('%');
+                        i++;
+                        continue;
+                    }
+                    
+                    // Parse flags, width, precision
+                    var spec = new StringBuilder();
+                    spec.append_c('%');
+                    
+                    // Flags: -, +, space, #, 0
+                    while (i < template.length && (template[i] == '-' || template[i] == '+' || 
+                           template[i] == ' ' || template[i] == '#' || template[i] == '0')) {
+                        spec.append_c(template[i]);
+                        i++;
+                    }
+                    
+                    // Width
+                    while (i < template.length && template[i].isdigit()) {
+                        spec.append_c(template[i]);
+                        i++;
+                    }
+                    
+                    // Precision
+                    if (i < template.length && template[i] == '.') {
+                        spec.append_c(template[i]);
+                        i++;
+                        while (i < template.length && template[i].isdigit()) {
+                            spec.append_c(template[i]);
+                            i++;
+                        }
+                    }
+                    
+                    // Length modifier (l, ll, h, etc.) - skip for now
+                    while (i < template.length && (template[i] == 'l' || template[i] == 'h' || 
+                           template[i] == 'L' || template[i] == 'z' || template[i] == 'j')) {
+                        spec.append_c(template[i]);
+                        i++;
+                    }
+                    
+                    // Conversion specifier
+                    if (i < template.length) {
+                        char conv = template[i];
+                        spec.append_c(conv);
+                        i++;
+                        
+                        // Get the argument
+                        if (arg_index >= args.length) {
+                            throw new ExpressionError.INVALID_ARGUMENTS(
+                                @"format() has more format specifiers than arguments"
+                            );
+                        }
+                        
+                        var arg = args[arg_index];
+                        arg_index++;
+                        
+                        // Format based on conversion specifier
+                        string? formatted = format_arg(spec.str, conv, arg);
+                        if (formatted != null) {
+                            result.append(formatted);
+                        }
+                    }
+                } else {
+                    result.append_c(template[i]);
+                    i++;
+                }
+            }
+            
+            return new NativeElement<string>(result.str);
+        }
+
+        /**
+         * Formats a single argument using the format specifier.
+         */
+        private string? format_arg(string spec, char conv, Element arg) throws ExpressionError {
+            switch (conv) {
+                case 's': // String
+                    string? str_val;
+                    if (arg.try_get_as<string?>(out str_val)) {
+                        return spec.printf(str_val ?? "(null)");
+                    }
+                    // Convert to string representation
+                    return spec.printf(arg.to_string());
+                    
+                case 'd':
+                case 'i': // Integer
+                    // Try int64 first (common in expression system)
+                    int64? int64_val;
+                    if (arg.try_get_as<int64?>(out int64_val) && int64_val != null) {
+                        return "%lli".printf((int64)int64_val);
+                    }
+                    int? int_val;
+                    if (arg.try_get_as<int?>(out int_val) && int_val != null) {
+                        return "%i".printf((int)int_val);
+                    }
+                    long? long_val;
+                    if (arg.try_get_as<long?>(out long_val) && long_val != null) {
+                        return "%li".printf((long)long_val);
+                    }
+                    // Try to convert double to int
+                    double? dbl_val;
+                    if (arg.try_get_as<double?>(out dbl_val) && dbl_val != null) {
+                        return "%i".printf((int)(double)dbl_val);
+                    }
+                    throw new ExpressionError.TYPE_MISMATCH(
+                        @"format() specifier '%%i' requires an integer, got $(arg.type_name())"
+                    );
+                    
+                case 'f':
+                case 'e':
+                case 'E':
+                case 'g':
+                case 'G': // Float/double
+                    double? dbl_val;
+                    if (arg.try_get_as<double?>(out dbl_val) && dbl_val != null) {
+                        return spec.printf((double)dbl_val);
+                    }
+                    // Try integer as double
+                    int? int_val;
+                    if (arg.try_get_as<int?>(out int_val) && int_val != null) {
+                        return spec.printf((double)int_val);
+                    }
+                    int64? int64_val;
+                    if (arg.try_get_as<int64?>(out int64_val) && int64_val != null) {
+                        return spec.printf((double)int64_val);
+                    }
+                    throw new ExpressionError.TYPE_MISMATCH(
+                        @"format() specifier '%%f' requires a number, got $(arg.type_name())"
+                    );
+                    
+                case 'x':
+                case 'X': // Hexadecimal
+                    int64? hex_int64;
+                    if (arg.try_get_as<int64?>(out hex_int64) && hex_int64 != null) {
+                        if (conv == 'x') {
+                            return "%llx".printf((int64)hex_int64);
+                        } else {
+                            return "%llX".printf((int64)hex_int64);
+                        }
+                    }
+                    int? hex_int;
+                    if (arg.try_get_as<int?>(out hex_int) && hex_int != null) {
+                        if (conv == 'x') {
+                            return "%x".printf((int)hex_int);
+                        } else {
+                            return "%X".printf((int)hex_int);
+                        }
+                    }
+                    long? hex_long;
+                    if (arg.try_get_as<long?>(out hex_long) && hex_long != null) {
+                        if (conv == 'x') {
+                            return "%lx".printf((long)hex_long);
+                        } else {
+                            return "%lX".printf((long)hex_long);
+                        }
+                    }
+                    throw new ExpressionError.TYPE_MISMATCH(
+                        @"format() specifier '%%x' requires an integer, got $(arg.type_name())"
+                    );
+                    
+                case 'o': // Octal
+                    int64? oct_int64;
+                    if (arg.try_get_as<int64?>(out oct_int64) && oct_int64 != null) {
+                        return "%llo".printf((int64)oct_int64);
+                    }
+                    int? oct_int;
+                    if (arg.try_get_as<int?>(out oct_int) && oct_int != null) {
+                        return "%o".printf((int)oct_int);
+                    }
+                    long? oct_long;
+                    if (arg.try_get_as<long?>(out oct_long) && oct_long != null) {
+                        return "%lo".printf((long)oct_long);
+                    }
+                    throw new ExpressionError.TYPE_MISMATCH(
+                        @"format() specifier '%%o' requires an integer, got $(arg.type_name())"
+                    );
+                    
+                case 'c': // Character
+                    int char_int;
+                    if (arg.try_get_as<int>(out char_int)) {
+                        return spec.printf(char_int);
+                    }
+                    string? char_str;
+                    if (arg.try_get_as<string?>(out char_str) && char_str != null && char_str.length > 0) {
+                        return spec.printf(char_str[0]);
+                    }
+                    throw new ExpressionError.TYPE_MISMATCH(
+                        "format() specifier '%%c' requires an integer or single-character string"
+                    );
+                    
+                case 'p': // Pointer (not really useful, but supported)
+                    return spec.printf((void*)null);
+                    
+                default:
+                    throw new ExpressionError.INVALID_ARGUMENTS(
+                        @"format() unsupported format specifier '%%$conv'"
+                    );
+            }
+        }
+
+    }
+
+}

+ 16 - 3
src/lib/Expressions/ObjectPropertyAccessor.vala

@@ -23,9 +23,12 @@ namespace Invercargill.Expressions {
         }
 
         public Element read_property (string property) throws ExpressionError {
-            var prop_type = get_property_type(property);
+            // Convert underscores to dashes for GObject property lookup
+            // e.g., "min_rank" becomes "min-rank"
+            var gobject_property = property.replace("_", "-");
+            var prop_type = get_property_type(gobject_property);
             var value = Value(prop_type);
-            target.get_property (property, ref value);
+            target.get_property (gobject_property, ref value);
             
             // Handle common types with NativeElement for better type conversion
             if (prop_type == typeof(string)) {
@@ -49,6 +52,12 @@ namespace Invercargill.Expressions {
             if (prop_type == typeof(bool)) {
                 return new NativeElement<bool?>(value.get_boolean());
             }
+            // Check if the type implements Enumerable interface
+            // This handles Enumerable<T> properties properly
+            if (prop_type.is_a(typeof(Enumerable<Object?>))) {
+                // Return as ValueElement which can hold the enumerable
+                return new ValueElement(value);
+            }
             if (prop_type.is_a(typeof(Object))) {
                 return new NativeElement<Object?>(value.get_object());
             }
@@ -58,7 +67,10 @@ namespace Invercargill.Expressions {
         }
 
         public bool has_property (string property) {
-            return properties.has(property);
+            // Convert underscores to dashes for GObject property lookup
+            // e.g., "min_rank" becomes "min-rank"
+            var gobject_property = property.replace("_", "-");
+            return properties.has(gobject_property);
         }
 
         public Enumerable<string> get_property_names () {
@@ -66,6 +78,7 @@ namespace Invercargill.Expressions {
         }
 
         public override Type get_property_type(string property) throws ExpressionError {
+            // Property names are already in dashed form in the dictionary
             Type prop_type;
             if(!properties.try_get(property, out prop_type)) {
                 throw new ExpressionError.NON_EXISTANT_PROPERTY(@"Object type $type_name does not have a public property called '$property'.");

+ 3 - 0
src/lib/meson.build

@@ -153,6 +153,7 @@ sources += files('Expressions/BinaryOperator.vala')
 sources += files('Expressions/UnaryOperator.vala')
 sources += files('Expressions/EnumerableFunctionAccessor.vala')
 sources += files('Expressions/IterateFunctionAccessor.vala')
+sources += files('Expressions/GlobalFunctionAccessor.vala')
 sources += files('Expressions/ExpressionTokenizer.vala')
 sources += files('Expressions/ExpressionParser.vala')
 
@@ -166,6 +167,8 @@ sources += files('Expressions/Expressions/FunctionCallExpression.vala')
 sources += files('Expressions/Expressions/LambdaExpression.vala')
 sources += files('Expressions/Expressions/BracketedExpression.vala')
 sources += files('Expressions/Expressions/LotLiteralExpression.vala')
+sources += files('Expressions/Expressions/GlobalFunctionCallExpression.vala')
+sources += files('Expressions/Expressions/GlobalFunctionCallExpression.vala')
 
 sources += files('Expressions/Elements/LambdaElement.vala')
 sources += files('Expressions/Elements/GlobalFunctionsElement.vala')

+ 297 - 0
src/tests/Integration/Expressions.vala

@@ -1145,4 +1145,301 @@ void expression_tests() {
         assert(retrieved != null);
         assert(retrieved.accessor is IterateFunctionAccessor);
     });
+
+    // ==================== Global Format Function Tests ====================
+
+    Test.add_func("/invercargill/expressions/format_string", () => {
+        // format("Hello, %s!", "World")
+        var expr = ExpressionParser.parse("format(\"Hello, %s!\", name)");
+        var props = new PropertyDictionary();
+        props.set("name", new NativeElement<string?>("World"));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Hello, World!");
+    });
+
+    Test.add_func("/invercargill/expressions/format_integer", () => {
+        // format("Count: %i", 42)
+        var expr = ExpressionParser.parse("format(\"Count: %i\", count)");
+        var props = new PropertyDictionary();
+        props.set("count", new NativeElement<int?>(42));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Count: 42");
+    });
+
+    Test.add_func("/invercargill/expressions/format_multiple", () => {
+        // format("%s #%i: %s", category, id, title)
+        var expr = ExpressionParser.parse("format(\"%s #%i: %s\", category, id, title)");
+        var props = new PropertyDictionary();
+        props.set("category", new NativeElement<string?>("Item"));
+        props.set("id", new NativeElement<int?>(123));
+        props.set("title", new NativeElement<string?>("Test Product"));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Item #123: Test Product");
+    });
+
+    Test.add_func("/invercargill/expressions/format_float", () => {
+        // format("Price: %.2f", 19.99)
+        var expr = ExpressionParser.parse("format(\"Price: %.2f\", price)");
+        var props = new PropertyDictionary();
+        props.set("price", new NativeElement<double?>(19.99));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Price: 19.99");
+    });
+
+    Test.add_func("/invercargill/expressions/format_hex", () => {
+        // format("Hex: 0x%x", 255)
+        var expr = ExpressionParser.parse("format(\"Hex: 0x%x\", value)");
+        var props = new PropertyDictionary();
+        props.set("value", new NativeElement<int?>(255));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Hex: 0xff");
+    });
+
+    Test.add_func("/invercargill/expressions/format_percent", () => {
+        // Test that %% in format string becomes single %
+        var expr = ExpressionParser.parse("format(\"100%% complete\")");
+        var props = new PropertyDictionary();
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "100% complete");
+    });
+
+    Test.add_func("/invercargill/expressions/format_no_args", () => {
+        // format("Just a string")
+        var expr = ExpressionParser.parse("format(\"Just a string\")");
+        var props = new PropertyDictionary();
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Just a string");
+    });
+
+    // ==================== Nested Property Access Parser Tests ====================
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_simple", () => {
+        // Test: p.name (simple property access via parser - same as existing test but confirms it works)
+        var person = new ExprTestPerson("Alice", 30);
+        var expr = ExpressionParser.parse("p.name");
+        var props = new PropertyDictionary();
+        props.set("p", new NativeElement<ExprTestPerson?>(person));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Alice");
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_deep", () => {
+        // Test: container.min_rank (accessing property on a container object)
+        var persons = new Series<ExprTestPerson>();
+        persons.add(new ExprTestPerson("Alice", 30, 5));
+        var container = new ExprTestContainer(3, persons);
+        
+        var expr = ExpressionParser.parse("container.min_rank");
+        var props = new PropertyDictionary();
+        props.set("container", new NativeElement<ExprTestContainer?>(container));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        int? value;
+        assert(result.try_get_as<int?>(out value));
+        assert(value == 3);
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_with_comparison", () => {
+        // Test: p.age > 18 (property access in comparison)
+        var person = new ExprTestPerson("Bob", 25);
+        var expr = ExpressionParser.parse("p.age > 18");
+        var props = new PropertyDictionary();
+        props.set("p", new NativeElement<ExprTestPerson?>(person));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        bool? value;
+        assert(result.try_get_as<bool?>(out value));
+        assert(value == true);
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_in_lambda", () => {
+        // Test: items.where(i => i.rank > 2) - nested property in lambda
+        var persons = new Series<ExprTestPerson>();
+        persons.add(new ExprTestPerson("Alice", 30, 5));
+        persons.add(new ExprTestPerson("Bob", 25, 1));
+        persons.add(new ExprTestPerson("Charlie", 35, 3));
+        
+        var expr = ExpressionParser.parse("items.where(i => i.rank > 2)");
+        var props = new PropertyDictionary();
+        props.set("items", new NativeElement<Enumerable<ExprTestPerson>?>(persons));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        Elements elements;
+        assert(result.try_get_as<Elements>(out elements));
+        
+        var count = 0;
+        elements.iterate(e => count++);
+        assert(count == 2); // Alice (rank 5) and Charlie (rank 3)
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_chained_functions", () => {
+        // Test: items.where(i => i.rank > 2).first().name
+        var persons = new Series<ExprTestPerson>();
+        persons.add(new ExprTestPerson("Alice", 30, 5));
+        persons.add(new ExprTestPerson("Bob", 25, 1));
+        persons.add(new ExprTestPerson("Charlie", 35, 3));
+        
+        var expr = ExpressionParser.parse("items.where(i => i.rank > 2).first().name");
+        var props = new PropertyDictionary();
+        props.set("items", new NativeElement<Enumerable<ExprTestPerson>?>(persons));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Alice"); // First item with rank > 2
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_closure", () => {
+        // Test: items.where(i => i.rank > container.min_rank) - closure with nested property
+        var persons = new Series<ExprTestPerson>();
+        persons.add(new ExprTestPerson("Alice", 30, 5));
+        persons.add(new ExprTestPerson("Bob", 25, 1));
+        persons.add(new ExprTestPerson("Charlie", 35, 3));
+        var container = new ExprTestContainer(2, persons);
+        
+        var expr = ExpressionParser.parse("container.values.where(i => i.rank > container.min_rank)");
+        var props = new PropertyDictionary();
+        props.set("container", new NativeElement<ExprTestContainer?>(container));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        Elements elements;
+        assert(result.try_get_as<Elements>(out elements));
+        
+        var count = 0;
+        elements.iterate(e => count++);
+        assert(count == 2); // Alice (rank 5) and Charlie (rank 3), both > min_rank (2)
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_ternary", () => {
+        // Test: p.age >= 18 ? p.name + " is adult" : p.name + " is minor"
+        var person = new ExprTestPerson("Bob", 25);
+        var expr = ExpressionParser.parse("p.age >= 18 ? p.name + \" is adult\" : p.name + \" is minor\"");
+        var props = new PropertyDictionary();
+        props.set("p", new NativeElement<ExprTestPerson?>(person));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        string? value;
+        assert(result.try_get_as<string?>(out value));
+        assert(value == "Bob is adult");
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_arithmetic", () => {
+        // Test: p.age + 5 (property in arithmetic)
+        var person = new ExprTestPerson("Alice", 30);
+        var expr = ExpressionParser.parse("p.age + 5");
+        var props = new PropertyDictionary();
+        props.set("p", new NativeElement<ExprTestPerson?>(person));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        int64? value;
+        assert(result.try_get_as<int64?>(out value));
+        assert(value == 35);
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_boolean", () => {
+        // Test: p.is_important (boolean property)
+        var person = new ExprTestPerson("Important", 30, 5, true);
+        var expr = ExpressionParser.parse("p.is_important");
+        var props = new PropertyDictionary();
+        props.set("p", new NativeElement<ExprTestPerson?>(person));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        bool? value;
+        assert(result.try_get_as<bool?>(out value));
+        assert(value == true);
+    });
+
+    Test.add_func("/invercargill/expressions/parser_nested_property_complex_expression", () => {
+        // Complex expression from the requirements:
+        // a.values.where(s => s.rank > a.min_rank).first().is_important
+        var persons = new Series<ExprTestPerson>();
+        persons.add(new ExprTestPerson("Alice", 30, 1, false));
+        persons.add(new ExprTestPerson("Bob", 25, 5, true));
+        persons.add(new ExprTestPerson("Charlie", 35, 3, false));
+        var container = new ExprTestContainer(2, persons);
+        
+        var expr = ExpressionParser.parse("a.values.where(s => s.rank > a.min_rank).first().is_important");
+        var props = new PropertyDictionary();
+        props.set("a", new NativeElement<ExprTestContainer?>(container));
+        var context = new EvaluationContext(props);
+        
+        var result = expr.evaluate(context);
+        assert(result != null);
+        
+        bool? value;
+        assert(result.try_get_as<bool?>(out value));
+        assert(value == true); // Bob has rank 5 > min_rank 2, and is_important = true
+    });
 }