From f9325a0ea3149279dc0ad0e7eec4d1ac85950ec1 Mon Sep 17 00:00:00 2001 From: Ferran Borreguero Date: Mon, 9 Feb 2026 13:39:51 +0800 Subject: [PATCH 1/3] Fix lint --- crates/pine-ast/src/lib.rs | 33 +++- crates/pine-interpreter/src/lib.rs | 162 +++++++++++++++--- crates/pine-lexer/src/lib.rs | 2 + crates/pine-parser/src/lib.rs | 86 +++++++++- .../functions/const_function_param.pine | 1 + .../functions/const_function_param_ast.json | 37 ++++ .../testdata/import_export/library_ast.json | 12 +- .../testdata/methods/const_method_param.pine | 5 + .../methods/const_method_param_ast.json | 53 ++++++ .../testdata/types/const_array_type.pine | 2 + .../testdata/types/const_array_type_ast.json | 81 +++++++++ .../testdata/types/const_type_field.pine | 3 + .../testdata/types/const_type_field_ast.json | 29 ++++ .../testdata/types/const_variable.pine | 2 + .../testdata/types/const_variable_ast.json | 28 +++ crates/pine/src/lib.rs | 3 +- .../constants/builtin_color_constants.pine | 19 ++ tests/testdata/constants/const_basic.pine | 10 ++ tests/testdata/constants/const_variable.pine | 14 ++ .../error_color_constant_reassignment.pine | 5 + .../constants/error_const_reassignment.pine | 8 + .../error_namespace_reassignment.pine | 5 + 22 files changed, 564 insertions(+), 36 deletions(-) create mode 100644 crates/pine-parser/testdata/functions/const_function_param.pine create mode 100644 crates/pine-parser/testdata/functions/const_function_param_ast.json create mode 100644 crates/pine-parser/testdata/methods/const_method_param.pine create mode 100644 crates/pine-parser/testdata/methods/const_method_param_ast.json create mode 100644 crates/pine-parser/testdata/types/const_array_type.pine create mode 100644 crates/pine-parser/testdata/types/const_array_type_ast.json create mode 100644 crates/pine-parser/testdata/types/const_type_field.pine create mode 100644 crates/pine-parser/testdata/types/const_type_field_ast.json create mode 100644 crates/pine-parser/testdata/types/const_variable.pine create mode 100644 crates/pine-parser/testdata/types/const_variable_ast.json create mode 100644 tests/testdata/constants/builtin_color_constants.pine create mode 100644 tests/testdata/constants/const_basic.pine create mode 100644 tests/testdata/constants/const_variable.pine create mode 100644 tests/testdata/constants/error_color_constant_reassignment.pine create mode 100644 tests/testdata/constants/error_const_reassignment.pine create mode 100644 tests/testdata/constants/error_namespace_reassignment.pine diff --git a/crates/pine-ast/src/lib.rs b/crates/pine-ast/src/lib.rs index 2683493..423fca0 100644 --- a/crates/pine-ast/src/lib.rs +++ b/crates/pine-ast/src/lib.rs @@ -5,6 +5,21 @@ fn is_false(b: &bool) -> bool { !b } +// Helper function for serde to skip None values +fn skip_none(opt: &Option) -> bool { + opt.is_none() +} + +/// Type qualifier for variables and parameters +/// Hierarchy: const < input < simple < series (const is the weakest) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum TypeQualifier { + Const, + Input, + Simple, + Series, +} + /// Function argument - can be positional or named #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Argument { @@ -98,6 +113,8 @@ pub enum UnOp { pub enum Stmt { VarDecl { name: String, + #[serde(skip_serializing_if = "skip_none")] + type_qualifier: Option, type_annotation: Option, initializer: Option, is_varip: bool, // true for varip, false for var @@ -158,7 +175,7 @@ pub enum Stmt { }, FunctionDecl { name: String, - params: Vec, + params: Vec, body: Vec, #[serde(default, skip_serializing_if = "is_false")] export: bool, @@ -189,15 +206,29 @@ pub struct EnumField { /// A parameter in a method declaration #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct MethodParam { + #[serde(skip_serializing_if = "skip_none")] + pub type_qualifier: Option, pub type_annotation: Option, // e.g., "InfoLabel" pub name: String, pub default_value: Option, } +/// A parameter in a function declaration +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FunctionParam { + #[serde(skip_serializing_if = "skip_none")] + pub type_qualifier: Option, + pub type_annotation: Option, + pub name: String, + pub default_value: Option, +} + /// A field in a user-defined type #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TypeField { pub name: String, + #[serde(skip_serializing_if = "skip_none")] + pub type_qualifier: Option, pub type_annotation: String, pub default_value: Option, } diff --git a/crates/pine-interpreter/src/lib.rs b/crates/pine-interpreter/src/lib.rs index 12c4f33..acdf771 100644 --- a/crates/pine-interpreter/src/lib.rs +++ b/crates/pine-interpreter/src/lib.rs @@ -41,6 +41,9 @@ pub enum RuntimeError { #[error("Library error: {0}")] LibraryError(String), + + #[error("Cannot reassign const variable '{0}'")] + ConstReassignment(String), } /// Control flow signals for loops @@ -51,6 +54,13 @@ enum LoopControl { Continue, } +/// Variable storage with const qualifier tracking +#[derive(Clone)] +struct Variable { + value: Value, + is_const: bool, +} + /// Represents a single bar/candle of market data #[derive(Debug, Clone, Default)] pub struct Bar { @@ -290,7 +300,7 @@ struct MethodDef { /// The interpreter executes a program with a given bar pub struct Interpreter { /// Local variables in the current scope - variables: HashMap, + variables: HashMap, /// Builtin function registry builtins: HashMap, /// Method registry (method_name -> Vec) - can have multiple methods with same name for different types @@ -379,12 +389,29 @@ impl Interpreter { /// Get a variable value pub fn get_variable(&self, name: &str) -> Option<&Value> { - self.variables.get(name) + self.variables.get(name).map(|var| &var.value) } /// Set a variable value (useful for loading objects and test setup) pub fn set_variable(&mut self, name: &str, value: Value) { - self.variables.insert(name.to_string(), value); + self.variables.insert( + name.to_string(), + Variable { + value, + is_const: false, + }, + ); + } + + /// Set a const variable (cannot be reassigned) + pub fn set_const_variable(&mut self, name: &str, value: Value) { + self.variables.insert( + name.to_string(), + Variable { + value, + is_const: true, + }, + ); } /// Helper to get series values as a Vec for the given length @@ -465,6 +492,7 @@ impl Interpreter { match stmt { Stmt::VarDecl { name, + type_qualifier, type_annotation: _, initializer, is_varip: _, // TODO: implement varip behavior (requires stateful execution) @@ -474,7 +502,9 @@ impl Interpreter { } else { Value::Na }; - self.variables.insert(name.clone(), value); + let is_const = matches!(type_qualifier, Some(pine_ast::TypeQualifier::Const)); + self.variables + .insert(name.clone(), Variable { value, is_const }); Ok(None) } @@ -483,10 +513,35 @@ impl Interpreter { match target { Expr::Variable(name) => { - self.variables.insert(name.clone(), val); + // Check if variable is const + if let Some(var) = self.variables.get(name) { + if var.is_const { + return Err(RuntimeError::ConstReassignment(name.clone())); + } + } + + self.variables.insert( + name.clone(), + Variable { + value: val, + is_const: false, + }, + ); Ok(None) } Expr::MemberAccess { object, member } => { + // Check if we're trying to modify a member of a const variable + if let Expr::Variable(var_name) = object.as_ref() { + if let Some(var) = self.variables.get(var_name) { + if var.is_const { + return Err(RuntimeError::ConstReassignment(format!( + "{}.{}", + var_name, member + ))); + } + } + } + // Get the object let obj_value = self.eval_expr(object)?; @@ -512,7 +567,13 @@ impl Interpreter { let arr = arr_ref.borrow(); for (i, name) in names.iter().enumerate() { let element_val = arr.get(i).cloned().unwrap_or(Value::Na); - self.variables.insert(name.clone(), element_val); + self.variables.insert( + name.clone(), + Variable { + value: element_val, + is_const: false, + }, + ); } Ok(None) } else { @@ -581,8 +642,13 @@ impl Interpreter { let end = to_val as i64; while i <= end { - self.variables - .insert(var_name.clone(), Value::Number(i as f64)); + self.variables.insert( + var_name.clone(), + Variable { + value: Value::Number(i as f64), + is_const: false, + }, + ); let control = self.execute_loop_body(body)?; if control == LoopControl::Break { @@ -608,12 +674,23 @@ impl Interpreter { for (index, item) in arr_borrowed.iter().enumerate() { // Set index variable if tuple form if let Some(idx_var) = index_var { - self.variables - .insert(idx_var.clone(), Value::Number(index as f64)); + self.variables.insert( + idx_var.clone(), + Variable { + value: Value::Number(index as f64), + is_const: false, + }, + ); } // Set item variable - self.variables.insert(item_var.clone(), item.clone()); + self.variables.insert( + item_var.clone(), + Variable { + value: item.clone(), + is_const: false, + }, + ); let control = self.execute_loop_body(body)?; if control == LoopControl::Break { @@ -652,7 +729,13 @@ impl Interpreter { name: name.clone(), fields: fields.clone(), }; - self.variables.insert(name.clone(), type_value.clone()); + self.variables.insert( + name.clone(), + Variable { + value: type_value.clone(), + is_const: false, + }, + ); // If exported, also store in exports if *export { @@ -683,7 +766,13 @@ impl Interpreter { type_name: name.clone(), fields: Rc::new(RefCell::new(enum_fields)), }; - self.variables.insert(name.clone(), enum_object.clone()); + self.variables.insert( + name.clone(), + Variable { + value: enum_object.clone(), + is_const: false, + }, + ); // If exported, also store in exports if *export { @@ -697,14 +786,14 @@ impl Interpreter { match item { pine_ast::ExportItem::Type(type_name) => { // Export the type - it should already be in variables - if let Some(value) = self.variables.get(type_name) { - self.exports.insert(type_name.clone(), value.clone()); + if let Some(var) = self.variables.get(type_name) { + self.exports.insert(type_name.clone(), var.value.clone()); } } pine_ast::ExportItem::Function(func_name) => { // Export the function - it should already be in variables - if let Some(value) = self.variables.get(func_name) { - self.exports.insert(func_name.clone(), value.clone()); + if let Some(var) = self.variables.get(func_name) { + self.exports.insert(func_name.clone(), var.value.clone()); } } } @@ -740,7 +829,13 @@ impl Interpreter { type_name: alias.clone(), fields: Rc::new(RefCell::new(library_exports.clone())), }; - self.variables.insert(alias.clone(), namespace); + self.variables.insert( + alias.clone(), + Variable { + value: namespace, + is_const: false, + }, + ); } Err(e) => { return Err(RuntimeError::LibraryError(format!( @@ -804,12 +899,21 @@ impl Interpreter { body, export, } => { + // Extract parameter names from FunctionParam structs + let param_names: Vec = params.iter().map(|p| p.name.clone()).collect(); + // Create a function value let func_value = Value::Function { - params: params.clone(), + params: param_names, body: body.clone(), }; - self.variables.insert(name.clone(), func_value.clone()); + self.variables.insert( + name.clone(), + Variable { + value: func_value.clone(), + is_const: false, + }, + ); // If exported, also store in exports if *export { @@ -884,7 +988,7 @@ impl Interpreter { } else { self.variables .get(name) - .cloned() + .map(|var| var.value.clone()) .ok_or_else(|| RuntimeError::UndefinedVariable(name.clone())) } } @@ -1226,7 +1330,13 @@ impl Interpreter { // Bind parameters to arguments for (param, value) in params.iter().zip(positional_values.iter()) { - self.variables.insert(param.clone(), value.clone()); + self.variables.insert( + param.clone(), + Variable { + value: value.clone(), + is_const: false, + }, + ); } // Execute function body @@ -1293,7 +1403,13 @@ impl Interpreter { Value::Na }; - self.variables.insert(param.name.clone(), param_value); + self.variables.insert( + param.name.clone(), + Variable { + value: param_value, + is_const: false, + }, + ); } // Execute method body diff --git a/crates/pine-lexer/src/lib.rs b/crates/pine-lexer/src/lib.rs index 82aee8b..ebd2a43 100644 --- a/crates/pine-lexer/src/lib.rs +++ b/crates/pine-lexer/src/lib.rs @@ -43,6 +43,7 @@ pub enum TokenType { Ident(String), Var, Varip, + Const, Type, Enum, Method, @@ -220,6 +221,7 @@ impl Lexer { let typ = match ident.as_str() { "var" => TokenType::Var, "varip" => TokenType::Varip, + "const" => TokenType::Const, "type" => TokenType::Type, "enum" => TokenType::Enum, "method" => TokenType::Method, diff --git a/crates/pine-parser/src/lib.rs b/crates/pine-parser/src/lib.rs index 8866f02..9aa142d 100644 --- a/crates/pine-parser/src/lib.rs +++ b/crates/pine-parser/src/lib.rs @@ -297,6 +297,32 @@ impl Parser { } } + /// Helper to parse optional type qualifier (const, input, simple, series) + fn parse_optional_type_qualifier(&mut self) -> Option { + use pine_ast::TypeQualifier; + if self.match_token(&[TokenType::Const]) { + Some(TypeQualifier::Const) + } else if let TokenType::Ident(name) = &self.peek().typ { + match name.as_str() { + "input" => { + self.advance(); + Some(TypeQualifier::Input) + } + "simple" => { + self.advance(); + Some(TypeQualifier::Simple) + } + "series" => { + self.advance(); + Some(TypeQualifier::Series) + } + _ => None, + } + } else { + None + } + } + /// Helper to parse optional type annotation with array suffix /// Returns None if no type annotation is found /// Supports: int, float, or custom identifier types with optional [] suffix @@ -377,11 +403,18 @@ impl Parser { // Declarations (var declarations, assignments, etc.) fn declaration(&mut self) -> Result { + // Check for type qualifier first (const, input, simple, series) + let type_qualifier = self.parse_optional_type_qualifier(); + // Check for var or varip keyword (can be followed by type annotation) let is_varip = if self.match_token(&[TokenType::Varip]) { true } else if self.match_token(&[TokenType::Var]) { false + } else if type_qualifier.is_some() { + // If we have a type qualifier but no var/varip, it's still a variable declaration + // e.g., const int x = 5 + false } else { // Not a var/varip declaration, continue to other statement types return self.check_type_annotated_declaration(); @@ -389,7 +422,7 @@ impl Parser { // Check if followed by type annotation: var int x = ..., var float y = ..., var label l = ... let type_annotation = self.parse_optional_type_annotation(); - self.typed_var_declaration(type_annotation, is_varip) + self.typed_var_declaration_with_qualifier(type_qualifier, type_annotation, is_varip) } fn check_type_annotated_declaration(&mut self) -> Result { @@ -436,6 +469,9 @@ impl Parser { // Parse fields using generic helper let fields = self.parse_indented_fields(|p| { + // Parse optional type qualifier (const, input, simple, series) + let type_qualifier = p.parse_optional_type_qualifier(); + // Parse field: type_annotation field_name [= default_value] // First, get the type annotation (int, float, or identifier) let field_type = if p.match_token(&[TokenType::Int, TokenType::Float]) { @@ -463,6 +499,7 @@ impl Parser { Ok(pine_ast::TypeField { name: field_name, + type_qualifier, type_annotation: field_type, default_value, }) @@ -633,6 +670,9 @@ impl Parser { if !self.check(&TokenType::RParen) { loop { + // Parse optional type qualifier (const, input, simple, series) + let type_qualifier = self.parse_optional_type_qualifier(); + // Parse optional type annotation let type_annotation = self.parse_optional_type_annotation(); @@ -647,6 +687,7 @@ impl Parser { }; params.push(pine_ast::MethodParam { + type_qualifier, type_annotation, name: param_name, default_value, @@ -681,6 +722,15 @@ impl Parser { &mut self, type_annotation: Option, is_varip: bool, + ) -> Result { + self.typed_var_declaration_with_qualifier(None, type_annotation, is_varip) + } + + fn typed_var_declaration_with_qualifier( + &mut self, + type_qualifier: Option, + type_annotation: Option, + is_varip: bool, ) -> Result { let name = self.expect_identifier()?; @@ -692,6 +742,7 @@ impl Parser { Ok(Stmt::VarDecl { name, + type_qualifier, type_annotation, initializer, is_varip, @@ -781,7 +832,7 @@ impl Parser { let name = name.clone(); // Check for function definition: name(params) => - if let Some((params, body)) = self.try_parse(|p| { + if let Some((param_structs, body)) = self.try_parse(|p| { p.advance(); // consume identifier p.consume(TokenType::LParen, "Expected '('")?; @@ -797,9 +848,12 @@ impl Parser { Ok((params, body)) }) { + // Extract just the parameter names for Expr::Function + let params: Vec = param_structs.iter().map(|p| p.name.clone()).collect(); let initializer = Some(Expr::Function { params, body }); return Ok(Stmt::VarDecl { name, + type_qualifier: None, type_annotation: None, initializer, is_varip: false, @@ -816,6 +870,7 @@ impl Parser { Ok(Stmt::VarDecl { name: name.clone(), + type_qualifier: None, type_annotation: None, initializer, is_varip: false, @@ -866,18 +921,29 @@ impl Parser { self.expression_statement() } - fn function_params(&mut self) -> Result, ParserError> { + fn function_params(&mut self) -> Result, ParserError> { self.parse_comma_separated(&TokenType::RParen, |p| { + // Parse optional type qualifier (const, input, simple, series) + let type_qualifier = p.parse_optional_type_qualifier(); + + // Parse optional type annotation + let type_annotation = p.parse_optional_type_annotation(); + let name = p.expect_identifier()?; // Check for default value: param = value - if p.match_token(&[TokenType::Assign]) { - // Skip the default value expression (we're not storing it in our simple AST) - // Just parse and discard it - p.expression()?; - } + let default_value = if p.match_token(&[TokenType::Assign]) { + Some(p.expression()?) + } else { + None + }; - Ok(name) + Ok(pine_ast::FunctionParam { + type_qualifier, + type_annotation, + name, + default_value, + }) }) } @@ -1838,12 +1904,14 @@ mod tests { assert_eq!(stmts.len(), 1); if let Stmt::VarDecl { name, + type_qualifier, type_annotation, initializer, is_varip, } = &stmts[0] { assert_eq!(name, "x"); + assert_eq!(*type_qualifier, None); assert_eq!(*type_annotation, None); assert_eq!( initializer.as_ref().unwrap(), diff --git a/crates/pine-parser/testdata/functions/const_function_param.pine b/crates/pine-parser/testdata/functions/const_function_param.pine new file mode 100644 index 0000000..03b8bfa --- /dev/null +++ b/crates/pine-parser/testdata/functions/const_function_param.pine @@ -0,0 +1 @@ +export add(const int x, const int y) => x + y diff --git a/crates/pine-parser/testdata/functions/const_function_param_ast.json b/crates/pine-parser/testdata/functions/const_function_param_ast.json new file mode 100644 index 0000000..6f876ae --- /dev/null +++ b/crates/pine-parser/testdata/functions/const_function_param_ast.json @@ -0,0 +1,37 @@ +[ + { + "FunctionDecl": { + "name": "add", + "params": [ + { + "type_qualifier": "Const", + "type_annotation": "int", + "name": "x", + "default_value": null + }, + { + "type_qualifier": "Const", + "type_annotation": "int", + "name": "y", + "default_value": null + } + ], + "body": [ + { + "Expression": { + "Binary": { + "left": { + "Variable": "x" + }, + "op": "Add", + "right": { + "Variable": "y" + } + } + } + } + ], + "export": true + } + } +] \ No newline at end of file diff --git a/crates/pine-parser/testdata/import_export/library_ast.json b/crates/pine-parser/testdata/import_export/library_ast.json index cb818f7..e05b761 100644 --- a/crates/pine-parser/testdata/import_export/library_ast.json +++ b/crates/pine-parser/testdata/import_export/library_ast.json @@ -71,8 +71,16 @@ "FunctionDecl": { "name": "add", "params": [ - "x", - "y" + { + "type_annotation": null, + "name": "x", + "default_value": null + }, + { + "type_annotation": null, + "name": "y", + "default_value": null + } ], "body": [ { diff --git a/crates/pine-parser/testdata/methods/const_method_param.pine b/crates/pine-parser/testdata/methods/const_method_param.pine new file mode 100644 index 0000000..bb5c919 --- /dev/null +++ b/crates/pine-parser/testdata/methods/const_method_param.pine @@ -0,0 +1,5 @@ +type InfoLabel + int x = 0 + +method set(InfoLabel this, const int newX) => + this.x := newX diff --git a/crates/pine-parser/testdata/methods/const_method_param_ast.json b/crates/pine-parser/testdata/methods/const_method_param_ast.json new file mode 100644 index 0000000..40b7bb6 --- /dev/null +++ b/crates/pine-parser/testdata/methods/const_method_param_ast.json @@ -0,0 +1,53 @@ +[ + { + "TypeDecl": { + "name": "InfoLabel", + "fields": [ + { + "name": "x", + "type_annotation": "int", + "default_value": { + "Literal": { + "Number": 0.0 + } + } + } + ] + } + }, + { + "MethodDecl": { + "name": "set", + "params": [ + { + "type_annotation": "InfoLabel", + "name": "this", + "default_value": null + }, + { + "type_qualifier": "Const", + "type_annotation": "int", + "name": "newX", + "default_value": null + } + ], + "body": [ + { + "Assignment": { + "target": { + "MemberAccess": { + "object": { + "Variable": "this" + }, + "member": "x" + } + }, + "value": { + "Variable": "newX" + } + } + } + ] + } + } +] \ No newline at end of file diff --git a/crates/pine-parser/testdata/types/const_array_type.pine b/crates/pine-parser/testdata/types/const_array_type.pine new file mode 100644 index 0000000..d0ddb4a --- /dev/null +++ b/crates/pine-parser/testdata/types/const_array_type.pine @@ -0,0 +1,2 @@ +const int[] myArray = array.from(1, 2, 3) +const string[] names = array.from("Alice", "Bob") diff --git a/crates/pine-parser/testdata/types/const_array_type_ast.json b/crates/pine-parser/testdata/types/const_array_type_ast.json new file mode 100644 index 0000000..e8383c1 --- /dev/null +++ b/crates/pine-parser/testdata/types/const_array_type_ast.json @@ -0,0 +1,81 @@ +[ + { + "VarDecl": { + "name": "myArray", + "type_qualifier": "Const", + "type_annotation": "int[]", + "initializer": { + "Call": { + "callee": { + "MemberAccess": { + "object": { + "Variable": "array" + }, + "member": "from" + } + }, + "args": [ + { + "Positional": { + "Literal": { + "Number": 1.0 + } + } + }, + { + "Positional": { + "Literal": { + "Number": 2.0 + } + } + }, + { + "Positional": { + "Literal": { + "Number": 3.0 + } + } + } + ] + } + }, + "is_varip": false + } + }, + { + "VarDecl": { + "name": "names", + "type_qualifier": "Const", + "type_annotation": "string[]", + "initializer": { + "Call": { + "callee": { + "MemberAccess": { + "object": { + "Variable": "array" + }, + "member": "from" + } + }, + "args": [ + { + "Positional": { + "Literal": { + "String": "Alice" + } + } + }, + { + "Positional": { + "Literal": { + "String": "Bob" + } + } + } + ] + } + }, + "is_varip": false + } + } +] \ No newline at end of file diff --git a/crates/pine-parser/testdata/types/const_type_field.pine b/crates/pine-parser/testdata/types/const_type_field.pine new file mode 100644 index 0000000..22fc67a --- /dev/null +++ b/crates/pine-parser/testdata/types/const_type_field.pine @@ -0,0 +1,3 @@ +type Point + const int x = 0 + const int y = 0 diff --git a/crates/pine-parser/testdata/types/const_type_field_ast.json b/crates/pine-parser/testdata/types/const_type_field_ast.json new file mode 100644 index 0000000..3fabce1 --- /dev/null +++ b/crates/pine-parser/testdata/types/const_type_field_ast.json @@ -0,0 +1,29 @@ +[ + { + "TypeDecl": { + "name": "Point", + "fields": [ + { + "name": "x", + "type_qualifier": "Const", + "type_annotation": "int", + "default_value": { + "Literal": { + "Number": 0.0 + } + } + }, + { + "name": "y", + "type_qualifier": "Const", + "type_annotation": "int", + "default_value": { + "Literal": { + "Number": 0.0 + } + } + } + ] + } + } +] \ No newline at end of file diff --git a/crates/pine-parser/testdata/types/const_variable.pine b/crates/pine-parser/testdata/types/const_variable.pine new file mode 100644 index 0000000..d148524 --- /dev/null +++ b/crates/pine-parser/testdata/types/const_variable.pine @@ -0,0 +1,2 @@ +const int x = 10 +const string y = "hello" diff --git a/crates/pine-parser/testdata/types/const_variable_ast.json b/crates/pine-parser/testdata/types/const_variable_ast.json new file mode 100644 index 0000000..2754480 --- /dev/null +++ b/crates/pine-parser/testdata/types/const_variable_ast.json @@ -0,0 +1,28 @@ +[ + { + "VarDecl": { + "name": "x", + "type_qualifier": "Const", + "type_annotation": "int", + "initializer": { + "Literal": { + "Number": 10.0 + } + }, + "is_varip": false + } + }, + { + "VarDecl": { + "name": "y", + "type_qualifier": "Const", + "type_annotation": "string", + "initializer": { + "Literal": { + "String": "hello" + } + }, + "is_varip": false + } + } +] \ No newline at end of file diff --git a/crates/pine/src/lib.rs b/crates/pine/src/lib.rs index 2fd9401..0da8660 100644 --- a/crates/pine/src/lib.rs +++ b/crates/pine/src/lib.rs @@ -80,8 +80,9 @@ impl Script { namespaces.insert("log".to_string(), log_namespace); } + // Register namespace objects as const variables for (name, value) in namespaces { - interpreter.set_variable(&name, value); + interpreter.set_const_variable(&name, value); } Ok(Self { diff --git a/tests/testdata/constants/builtin_color_constants.pine b/tests/testdata/constants/builtin_color_constants.pine new file mode 100644 index 0000000..1c941d1 --- /dev/null +++ b/tests/testdata/constants/builtin_color_constants.pine @@ -0,0 +1,19 @@ +// Test builtin color constants + +red_val = color.red +blue_val = color.blue +green_val = color.green + +// Test color component extraction +r = color.r(red_val) +g = color.g(green_val) +b = color.b(blue_val) + +log.info(r) +log.info(g) +log.info(b) + +// Expected output: +// 255 +// 128 +// 255 diff --git a/tests/testdata/constants/const_basic.pine b/tests/testdata/constants/const_basic.pine new file mode 100644 index 0000000..42b128a --- /dev/null +++ b/tests/testdata/constants/const_basic.pine @@ -0,0 +1,10 @@ +// Test basic const variable usage + +const int x = 10 +const string name = "test" +log.info(x) +log.info(name) + +// Expected output: +// 10 +// test diff --git a/tests/testdata/constants/const_variable.pine b/tests/testdata/constants/const_variable.pine new file mode 100644 index 0000000..2d5a236 --- /dev/null +++ b/tests/testdata/constants/const_variable.pine @@ -0,0 +1,14 @@ +// Test const variable declarations + +const int x = 10 +const string message = "Hello" +const float PI = 3.14159 + +log.info(x) +log.info(message) +log.info(PI) + +// Expected output: +// 10 +// Hello +// 3.14159 diff --git a/tests/testdata/constants/error_color_constant_reassignment.pine b/tests/testdata/constants/error_color_constant_reassignment.pine new file mode 100644 index 0000000..fb78d3b --- /dev/null +++ b/tests/testdata/constants/error_color_constant_reassignment.pine @@ -0,0 +1,5 @@ +// Test that color constants cannot be reassigned + +color.red := color.blue // This should fail + +// Expected error: Cannot reassign const variable diff --git a/tests/testdata/constants/error_const_reassignment.pine b/tests/testdata/constants/error_const_reassignment.pine new file mode 100644 index 0000000..24714cf --- /dev/null +++ b/tests/testdata/constants/error_const_reassignment.pine @@ -0,0 +1,8 @@ +// Test that const variables cannot be reassigned + +const int x = 10 +x := 20 // This should fail + +log.info(x) + +// Expected error: Cannot reassign const variable diff --git a/tests/testdata/constants/error_namespace_reassignment.pine b/tests/testdata/constants/error_namespace_reassignment.pine new file mode 100644 index 0000000..9534e53 --- /dev/null +++ b/tests/testdata/constants/error_namespace_reassignment.pine @@ -0,0 +1,5 @@ +// Test that namespace objects (color, math, etc.) cannot be reassigned + +color := "test" // This should fail + +// Expected error: Cannot reassign const variable 'color' From 8df61ee209f4f6ec920e9b63c699648eb3ba61e5 Mon Sep 17 00:00:00 2001 From: Ferran Borreguero Date: Mon, 9 Feb 2026 14:09:03 +0800 Subject: [PATCH 2/3] More tests --- crates/pine-ast/src/lib.rs | 4 +- crates/pine-interpreter/src/lib.rs | 64 +++++++++++++++---- crates/pine-parser/src/lib.rs | 8 ++- .../functions/const_function_param_ast.json | 6 +- .../testdata/functions/function_def_ast.json | 12 +++- .../function_default_params_ast.json | 35 ++++++++-- .../functions/function_underscore_ast.json | 4 +- .../functions/inline_const_params.pine | 1 + .../functions/inline_const_params_ast.json | 42 ++++++++++++ .../functions/inline_mixed_params.pine | 1 + .../functions/inline_mixed_params_ast.json | 44 +++++++++++++ .../testdata/import_export/library_ast.json | 8 +-- .../constants/const_function_param_valid.pine | 18 ++++++ .../error_const_param_non_const_arg.pine | 10 +++ .../error_const_param_reassignment.pine | 9 +++ .../constants/error_const_param_simple.pine | 8 +++ .../constants/non_const_reassignment.pine | 8 +++ 17 files changed, 246 insertions(+), 36 deletions(-) create mode 100644 crates/pine-parser/testdata/functions/inline_const_params.pine create mode 100644 crates/pine-parser/testdata/functions/inline_const_params_ast.json create mode 100644 crates/pine-parser/testdata/functions/inline_mixed_params.pine create mode 100644 crates/pine-parser/testdata/functions/inline_mixed_params_ast.json create mode 100644 tests/testdata/constants/const_function_param_valid.pine create mode 100644 tests/testdata/constants/error_const_param_non_const_arg.pine create mode 100644 tests/testdata/constants/error_const_param_reassignment.pine create mode 100644 tests/testdata/constants/error_const_param_simple.pine create mode 100644 tests/testdata/constants/non_const_reassignment.pine diff --git a/crates/pine-ast/src/lib.rs b/crates/pine-ast/src/lib.rs index 423fca0..b20635a 100644 --- a/crates/pine-ast/src/lib.rs +++ b/crates/pine-ast/src/lib.rs @@ -61,7 +61,7 @@ pub enum Expr { else_expr: Box, }, Function { - params: Vec, + params: Vec, body: Vec, }, Array(Vec), @@ -218,8 +218,10 @@ pub struct MethodParam { pub struct FunctionParam { #[serde(skip_serializing_if = "skip_none")] pub type_qualifier: Option, + #[serde(skip_serializing_if = "skip_none")] pub type_annotation: Option, pub name: String, + #[serde(skip_serializing_if = "skip_none")] pub default_value: Option, } diff --git a/crates/pine-interpreter/src/lib.rs b/crates/pine-interpreter/src/lib.rs index acdf771..5b603d9 100644 --- a/crates/pine-interpreter/src/lib.rs +++ b/crates/pine-interpreter/src/lib.rs @@ -92,7 +92,7 @@ pub enum Value { fields: Rc>>, // Dictionary/Object with string keys }, Function { - params: Vec, + params: Vec, body: Vec, }, BuiltinFunction(BuiltinFn), // Builtin function pointer @@ -899,12 +899,9 @@ impl Interpreter { body, export, } => { - // Extract parameter names from FunctionParam structs - let param_names: Vec = params.iter().map(|p| p.name.clone()).collect(); - // Create a function value let func_value = Value::Function { - params: param_names, + params: params.clone(), body: body.clone(), }; self.variables.insert( @@ -1144,7 +1141,7 @@ impl Interpreter { // Call the function based on its type match callee_value { Value::Function { params, body } => { - self.call_user_function(¶ms, &body, evaluated_args) + self.call_user_function(¶ms, &body, args, evaluated_args) } Value::BuiltinFunction(builtin_fn) => { // Pass type_args from the parsed call expression @@ -1191,7 +1188,7 @@ impl Interpreter { } Expr::Function { params, body } => { - // Create a function value + // params is already Vec from the AST Ok(Value::Function { params: params.clone(), body: body.clone(), @@ -1297,17 +1294,43 @@ impl Interpreter { } } + /// Check if an expression evaluates to a const value + fn is_const_expr(&self, expr: &Expr) -> bool { + match expr { + // Literals are always const + Expr::Literal(_) => true, + // Variable is const if it's stored as const + Expr::Variable(name) => self + .variables + .get(name) + .map(|var| var.is_const) + .unwrap_or(false), + // Member access is const if the base object is const + Expr::MemberAccess { object, .. } => self.is_const_expr(object), + // All other expressions are not const + _ => false, + } + } + fn call_user_function( &mut self, - params: &[String], + params: &[pine_ast::FunctionParam], body: &[Stmt], + arg_exprs: &[Argument], args: Vec, ) -> Result { // Extract positional arguments (user functions don't support named args yet) let mut positional_values = Vec::new(); - for arg in args { + let mut positional_exprs = Vec::new(); + + for (i, arg) in args.iter().enumerate() { match arg { - EvaluatedArg::Positional(value) => positional_values.push(value), + EvaluatedArg::Positional(value) => { + positional_values.push(value.clone()); + if let Some(Argument::Positional(expr)) = arg_exprs.get(i) { + positional_exprs.push(expr); + } + }, EvaluatedArg::Named { .. } => { return Err(RuntimeError::TypeError( "User-defined functions do not support named arguments yet".to_string(), @@ -1325,16 +1348,31 @@ impl Interpreter { ))); } + // Validate const parameters receive const arguments + for (i, param) in params.iter().enumerate() { + if matches!(param.type_qualifier, Some(pine_ast::TypeQualifier::Const)) { + if let Some(arg_expr) = positional_exprs.get(i) { + if !self.is_const_expr(arg_expr) { + return Err(RuntimeError::TypeError(format!( + "Parameter '{}' requires a const argument, but received a non-const value", + param.name + ))); + } + } + } + } + // Save current variable state (for function scope) let saved_vars = self.variables.clone(); - // Bind parameters to arguments + // Bind parameters to arguments with appropriate const flag for (param, value) in params.iter().zip(positional_values.iter()) { + let is_const = matches!(param.type_qualifier, Some(pine_ast::TypeQualifier::Const)); self.variables.insert( - param.clone(), + param.name.clone(), Variable { value: value.clone(), - is_const: false, + is_const, }, ); } diff --git a/crates/pine-parser/src/lib.rs b/crates/pine-parser/src/lib.rs index 9aa142d..622e4a2 100644 --- a/crates/pine-parser/src/lib.rs +++ b/crates/pine-parser/src/lib.rs @@ -848,9 +848,11 @@ impl Parser { Ok((params, body)) }) { - // Extract just the parameter names for Expr::Function - let params: Vec = param_structs.iter().map(|p| p.name.clone()).collect(); - let initializer = Some(Expr::Function { params, body }); + // Use the full FunctionParam structs for Expr::Function + let initializer = Some(Expr::Function { + params: param_structs, + body, + }); return Ok(Stmt::VarDecl { name, type_qualifier: None, diff --git a/crates/pine-parser/testdata/functions/const_function_param_ast.json b/crates/pine-parser/testdata/functions/const_function_param_ast.json index 6f876ae..d3e63f0 100644 --- a/crates/pine-parser/testdata/functions/const_function_param_ast.json +++ b/crates/pine-parser/testdata/functions/const_function_param_ast.json @@ -6,14 +6,12 @@ { "type_qualifier": "Const", "type_annotation": "int", - "name": "x", - "default_value": null + "name": "x" }, { "type_qualifier": "Const", "type_annotation": "int", - "name": "y", - "default_value": null + "name": "y" } ], "body": [ diff --git a/crates/pine-parser/testdata/functions/function_def_ast.json b/crates/pine-parser/testdata/functions/function_def_ast.json index ba1b470..f41a6bc 100644 --- a/crates/pine-parser/testdata/functions/function_def_ast.json +++ b/crates/pine-parser/testdata/functions/function_def_ast.json @@ -6,7 +6,9 @@ "initializer": { "Function": { "params": [ - "x" + { + "name": "x" + } ], "body": [ { @@ -37,8 +39,12 @@ "initializer": { "Function": { "params": [ - "a", - "b" + { + "name": "a" + }, + { + "name": "b" + } ], "body": [ { diff --git a/crates/pine-parser/testdata/functions/function_default_params_ast.json b/crates/pine-parser/testdata/functions/function_default_params_ast.json index c978136..87f5260 100644 --- a/crates/pine-parser/testdata/functions/function_default_params_ast.json +++ b/crates/pine-parser/testdata/functions/function_default_params_ast.json @@ -6,8 +6,17 @@ "initializer": { "Function": { "params": [ - "a", - "b" + { + "name": "a" + }, + { + "name": "b", + "default_value": { + "Literal": { + "Number": 10.0 + } + } + } ], "body": [ { @@ -36,9 +45,25 @@ "initializer": { "Function": { "params": [ - "name", - "greeting", - "punctuation" + { + "name": "name" + }, + { + "name": "greeting", + "default_value": { + "Literal": { + "String": "Hello" + } + } + }, + { + "name": "punctuation", + "default_value": { + "Literal": { + "String": "!" + } + } + } ], "body": [ { diff --git a/crates/pine-parser/testdata/functions/function_underscore_ast.json b/crates/pine-parser/testdata/functions/function_underscore_ast.json index a2be285..eaaa6b3 100644 --- a/crates/pine-parser/testdata/functions/function_underscore_ast.json +++ b/crates/pine-parser/testdata/functions/function_underscore_ast.json @@ -6,7 +6,9 @@ "initializer": { "Function": { "params": [ - "_val" + { + "name": "_val" + } ], "body": [ { diff --git a/crates/pine-parser/testdata/functions/inline_const_params.pine b/crates/pine-parser/testdata/functions/inline_const_params.pine new file mode 100644 index 0000000..b4c9d93 --- /dev/null +++ b/crates/pine-parser/testdata/functions/inline_const_params.pine @@ -0,0 +1 @@ +f(const int x, const string y) => x + 1 diff --git a/crates/pine-parser/testdata/functions/inline_const_params_ast.json b/crates/pine-parser/testdata/functions/inline_const_params_ast.json new file mode 100644 index 0000000..2d2ba41 --- /dev/null +++ b/crates/pine-parser/testdata/functions/inline_const_params_ast.json @@ -0,0 +1,42 @@ +[ + { + "VarDecl": { + "name": "f", + "type_annotation": null, + "initializer": { + "Function": { + "params": [ + { + "type_qualifier": "Const", + "type_annotation": "int", + "name": "x" + }, + { + "type_qualifier": "Const", + "type_annotation": "string", + "name": "y" + } + ], + "body": [ + { + "Expression": { + "Binary": { + "left": { + "Variable": "x" + }, + "op": "Add", + "right": { + "Literal": { + "Number": 1.0 + } + } + } + } + } + ] + } + }, + "is_varip": false + } + } +] \ No newline at end of file diff --git a/crates/pine-parser/testdata/functions/inline_mixed_params.pine b/crates/pine-parser/testdata/functions/inline_mixed_params.pine new file mode 100644 index 0000000..51035a0 --- /dev/null +++ b/crates/pine-parser/testdata/functions/inline_mixed_params.pine @@ -0,0 +1 @@ +f(const int x, int y, const string z) => x + y diff --git a/crates/pine-parser/testdata/functions/inline_mixed_params_ast.json b/crates/pine-parser/testdata/functions/inline_mixed_params_ast.json new file mode 100644 index 0000000..be96df3 --- /dev/null +++ b/crates/pine-parser/testdata/functions/inline_mixed_params_ast.json @@ -0,0 +1,44 @@ +[ + { + "VarDecl": { + "name": "f", + "type_annotation": null, + "initializer": { + "Function": { + "params": [ + { + "type_qualifier": "Const", + "type_annotation": "int", + "name": "x" + }, + { + "type_annotation": "int", + "name": "y" + }, + { + "type_qualifier": "Const", + "type_annotation": "string", + "name": "z" + } + ], + "body": [ + { + "Expression": { + "Binary": { + "left": { + "Variable": "x" + }, + "op": "Add", + "right": { + "Variable": "y" + } + } + } + } + ] + } + }, + "is_varip": false + } + } +] \ No newline at end of file diff --git a/crates/pine-parser/testdata/import_export/library_ast.json b/crates/pine-parser/testdata/import_export/library_ast.json index e05b761..8c4c155 100644 --- a/crates/pine-parser/testdata/import_export/library_ast.json +++ b/crates/pine-parser/testdata/import_export/library_ast.json @@ -72,14 +72,10 @@ "name": "add", "params": [ { - "type_annotation": null, - "name": "x", - "default_value": null + "name": "x" }, { - "type_annotation": null, - "name": "y", - "default_value": null + "name": "y" } ], "body": [ diff --git a/tests/testdata/constants/const_function_param_valid.pine b/tests/testdata/constants/const_function_param_valid.pine new file mode 100644 index 0000000..4887db0 --- /dev/null +++ b/tests/testdata/constants/const_function_param_valid.pine @@ -0,0 +1,18 @@ +// Test that const function parameters work with const arguments + +add(const int x, const int y) => x + y + +const int a = 10 +const int b = 20 + +// Literals are const +result1 = add(5, 10) +log.info(result1) + +// Const variables are const +result2 = add(a, b) +log.info(result2) + +// Expected output: +// 15 +// 30 diff --git a/tests/testdata/constants/error_const_param_non_const_arg.pine b/tests/testdata/constants/error_const_param_non_const_arg.pine new file mode 100644 index 0000000..53e09df --- /dev/null +++ b/tests/testdata/constants/error_const_param_non_const_arg.pine @@ -0,0 +1,10 @@ +// Test that const function parameters reject non-const arguments + +add(const int x, const int y) => x + y + +int a = 10 // Not const +const int b = 20 + +result = add(a, b) // Error: a is not const + +// Expected error: Parameter 'x' requires a const argument diff --git a/tests/testdata/constants/error_const_param_reassignment.pine b/tests/testdata/constants/error_const_param_reassignment.pine new file mode 100644 index 0000000..a15b174 --- /dev/null +++ b/tests/testdata/constants/error_const_param_reassignment.pine @@ -0,0 +1,9 @@ +// Test that const function parameters cannot be reassigned + +modify(const int x) => + x := x + 1 // Error: cannot reassign const parameter + x + +result = modify(10) + +// Expected error: Cannot reassign const variable diff --git a/tests/testdata/constants/error_const_param_simple.pine b/tests/testdata/constants/error_const_param_simple.pine new file mode 100644 index 0000000..a5ca3ad --- /dev/null +++ b/tests/testdata/constants/error_const_param_simple.pine @@ -0,0 +1,8 @@ +// Simpler test for const parameter validation + +f(const int x) => x + 1 + +int y = 10 +f(y) // Error + +// Expected error: Parameter 'x' requires a const argument diff --git a/tests/testdata/constants/non_const_reassignment.pine b/tests/testdata/constants/non_const_reassignment.pine new file mode 100644 index 0000000..ecc1008 --- /dev/null +++ b/tests/testdata/constants/non_const_reassignment.pine @@ -0,0 +1,8 @@ +// Test that non-const variables can be reassigned + +int a = 10 +a := 20 +log.info(a) + +// Expected output: +// 20 From a2278f1647d1c4f4e701711fadf4a4a47bb7e52f Mon Sep 17 00:00:00 2001 From: Ferran Borreguero Date: Mon, 9 Feb 2026 14:09:16 +0800 Subject: [PATCH 3/3] Fix lint --- crates/pine-interpreter/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pine-interpreter/src/lib.rs b/crates/pine-interpreter/src/lib.rs index 5b603d9..9f4c81b 100644 --- a/crates/pine-interpreter/src/lib.rs +++ b/crates/pine-interpreter/src/lib.rs @@ -1330,7 +1330,7 @@ impl Interpreter { if let Some(Argument::Positional(expr)) = arg_exprs.get(i) { positional_exprs.push(expr); } - }, + } EvaluatedArg::Named { .. } => { return Err(RuntimeError::TypeError( "User-defined functions do not support named arguments yet".to_string(),