diff --git a/front/hir/tests/named_proto_boundary.rs b/front/hir/tests/named_proto_boundary.rs new file mode 100644 index 00000000..c24f2e22 --- /dev/null +++ b/front/hir/tests/named_proto_boundary.rs @@ -0,0 +1,31 @@ +use hir::TypedProgram; +use lexer::Lexer; +use parser::parse_syntax_with_spans; + +fn syntax(source: &str) -> Vec { + let tokens = Lexer::new_with_file(source, "named-proto.wave").tokenize().unwrap(); + parse_syntax_with_spans(&tokens).unwrap() +} + +#[test] +fn named_contracts_are_rejected_at_the_semantic_boundary_with_a_span() { + for source in [ + "struct Buffer {} proto MissingContract: Buffer { fun value(self) -> i32 { return 7; } } fun main() {}", + "struct Buffer {} proto First: Buffer { fun value(self) -> i32 { return 7; } } proto Second: Buffer { fun value(self) -> i32 { return 8; } } fun main() {}", + ] { + let error = TypedProgram::lower(syntax(source)).unwrap_err(); + assert!( + error.diagnostic().message.contains("named proto contract"), + "{}", + error.diagnostic().message + ); + assert!(error.diagnostic().span.is_some(), "missing source span"); + } +} + +#[test] +fn inherent_proto_methods_still_lower_normally() { + let source = + "struct Buffer {} proto Buffer { fun value(self: ptr) -> i32 { return 7; } } fun main() {}"; + TypedProgram::lower(syntax(source)).unwrap(); +} diff --git a/front/parser/src/ast.rs b/front/parser/src/ast.rs index 60814b81..82636a49 100644 --- a/front/parser/src/ast.rs +++ b/front/parser/src/ast.rs @@ -135,8 +135,12 @@ pub struct StructNode { #[derive(Debug, Clone)] pub struct ProtoImplNode { + /// Named contract, or None for the existing inherent proto form. + pub name: Option, + /// The target name includes generic arguments, e.g. Buffer. pub target: String, pub methods: Vec, + pub visibility: Visibility, } #[derive(Debug, Clone)] diff --git a/front/parser/src/ast/cloning.rs b/front/parser/src/ast/cloning.rs index 4d1cde5b..f92dd928 100644 --- a/front/parser/src/ast/cloning.rs +++ b/front/parser/src/ast/cloning.rs @@ -134,8 +134,10 @@ impl Clone for ASTNode { visibility: n.visibility, }), ASTNode::ProtoImpl(n) => ASTNode::ProtoImpl(ProtoImplNode { + name: n.name.clone(), target: n.target.clone(), methods: n.methods.iter().map(|n| function(n, &mut nodes)).collect(), + visibility: n.visibility, }), ASTNode::Statement(n) => ASTNode::Statement(statement(n, &mut nodes)), ASTNode::ExternFunction(n) => ASTNode::ExternFunction(n.clone()), diff --git a/front/parser/src/ast_output.rs b/front/parser/src/ast_output.rs index 2eb955b2..5c194a0a 100644 --- a/front/parser/src/ast_output.rs +++ b/front/parser/src/ast_output.rs @@ -323,7 +323,12 @@ impl Data for ProtoImplNode { fn data(&self, cx: &Context) -> Value { record( "proto_impl", - vec![("target", self.target.data(cx)), ("methods", self.methods.data(cx))], + vec![ + ("name", self.name.data(cx)), + ("target", self.target.data(cx)), + ("methods", self.methods.data(cx)), + ("visibility", self.visibility.data(cx)), + ], ) } } diff --git a/front/parser/src/parser/functions.rs b/front/parser/src/parser/functions.rs index 590bb27e..40d61014 100644 --- a/front/parser/src/parser/functions.rs +++ b/front/parser/src/parser/functions.rs @@ -16,7 +16,9 @@ //! within generic parameter lists are rejected here; program-wide symbol and //! body type checks remain the semantic verifier's responsibility. -use crate::ast::{ASTNode, ExportAttribute, Expression, FunctionNode, ParameterNode, Visibility}; +use crate::ast::{ + ASTNode, ExportAttribute, Expression, FunctionNode, ParameterNode, Visibility, WaveType, +}; use crate::parser::decl::parse_ffi_header; use crate::parser::ParseError; use lexer::token::TokenType; @@ -72,6 +74,13 @@ pub fn parse_generic_param_names( pub fn parse_parameters( tokens: &mut Peekable>, +) -> Result, ParseError> { + parse_parameters_with_receiver(tokens, None) +} + +fn parse_parameters_with_receiver( + tokens: &mut Peekable>, + receiver_type: Option<&WaveType>, ) -> Result, ParseError> { let anchor = tokens.peek().copied(); let context = "function parameters"; @@ -90,9 +99,21 @@ pub fn parse_parameters( return Err(ParseError::syntax_at(at, format!("duplicate parameter '{name}'")) .with_context(context)); } - crate::expr::expect_token(tokens, anchor, TokenType::Colon, "':'", context)?; skip_ws(tokens); - let param_type = crate::types::parse_type_checked(tokens, "parameter type")?; + let receiver = receiver_type.filter(|_| { + name == "self" + && matches!( + tokens.peek().map(|t| &t.token_type), + Some(TokenType::Comma | TokenType::Rparen) + ) + }); + let param_type = if let Some(receiver) = receiver { + receiver.clone() + } else { + crate::expr::expect_token(tokens, anchor, TokenType::Colon, "':'", context)?; + skip_ws(tokens); + crate::types::parse_type_checked(tokens, "parameter type")? + }; skip_ws(tokens); let initial_value = if tokens.peek().is_some_and(|t| t.token_type == TokenType::Equal) { tokens.next(); @@ -130,6 +151,22 @@ pub fn parse_function(tokens: &mut Peekable>) -> Result>, export: Option, +) -> Result { + parse_function_with_export_and_receiver(tokens, export, None) +} + +/// A named proto may use an untyped `self` receiver, whose type is its target. +pub fn parse_function_with_receiver( + tokens: &mut Peekable>, + receiver_type: WaveType, +) -> Result { + parse_function_with_export_and_receiver(tokens, None, Some(receiver_type)) +} + +fn parse_function_with_export_and_receiver( + tokens: &mut Peekable>, + export: Option, + receiver_type: Option, ) -> Result { let anchor = tokens.peek().copied(); let invalid = |token: Option<&Token>| { @@ -175,7 +212,7 @@ pub fn parse_function_with_export( } tokens.next(); // consume '(' - let parameters = parse_parameters(tokens)?; + let parameters = parse_parameters_with_receiver(tokens, receiver_type.as_ref())?; skip_ws(tokens); let mut return_type_span = None; diff --git a/front/parser/src/parser/items.rs b/front/parser/src/parser/items.rs index ec8c905b..edcfa692 100644 --- a/front/parser/src/parser/items.rs +++ b/front/parser/src/parser/items.rs @@ -19,7 +19,9 @@ use crate::ast::{ ASTNode, ImportNode, ProtoImplNode, StatementNode, StructNode, Visibility, WaveType, }; -use crate::parser::functions::{parse_function, parse_generic_param_names}; +use crate::parser::functions::{ + parse_function, parse_function_with_receiver, parse_generic_param_names, +}; use crate::parser::ParseError; use lexer::token::TokenType; use lexer::Token; @@ -101,18 +103,48 @@ pub fn parse_import(tokens: &mut Peekable>) -> Result>) -> Result { let anchor = tokens.peek().copied(); - let context = "proto method attachment"; - let target_struct = crate::expr::identifier(tokens, anchor, context)?; - crate::expr::expect_token(tokens, anchor, TokenType::Lbrace, "'{'", context)?; + let context = "proto declaration"; + skip_ws(tokens); + let first = crate::expr::identifier(tokens, anchor, "proto name")?; + skip_ws(tokens); + + // The colon distinguishes a named contract from the existing inherent + // method-attachment syntax, without changing how inherent targets lower. + let (name, target) = if tokens.peek().is_some_and(|t| t.token_type == TokenType::Colon) { + tokens.next(); + skip_ws(tokens); + let target_anchor = tokens.peek().copied(); + let target_type = crate::types::parse_type_checked(tokens, "proto target type")?; + let WaveType::Struct(target) = target_type else { + return Err(ParseError::expected_at( + target_anchor, + anchor, + "named target type", + "proto target type", + )); + }; + (Some(first), target) + } else { + (None, first) + }; + skip_ws(tokens); + let opener = if name.is_some() { "'{'" } else { "'{' or ':'" }; + crate::expr::expect_token(tokens, anchor, TokenType::Lbrace, opener, context)?; let mut methods = Vec::new(); loop { + skip_ws(tokens); match tokens.peek().map(|token| &token.token_type) { Some(TokenType::Rbrace) => { tokens.next(); break; }, Some(TokenType::Fun | TokenType::Async) => { - let ASTNode::Function(mut method) = parse_function(tokens)? else { + let parsed = if name.is_some() { + parse_function_with_receiver(tokens, WaveType::Struct(target.clone()))? + } else { + parse_function(tokens)? + }; + let ASTNode::Function(mut method) = parsed else { unreachable!("function parser returns a function node"); }; if method.return_type.is_none() { @@ -120,6 +152,14 @@ pub fn parse_proto(tokens: &mut Peekable>) -> Result { + return Err(ParseError::expected_at( + tokens.peek().copied(), + anchor, + "'}'", + context, + )); + }, _ => { return Err(ParseError::expected_at( tokens.peek().copied(), @@ -130,7 +170,7 @@ pub fn parse_proto(tokens: &mut Peekable>) -> Result>) -> Result { diff --git a/front/parser/src/parser/parse.rs b/front/parser/src/parser/parse.rs index 64ffe6c7..f4a405c6 100644 --- a/front/parser/src/parser/parse.rs +++ b/front/parser/src/parser/parse.rs @@ -364,6 +364,10 @@ fn parse_syntax_impl(tokens: &[Token]) -> Result, ParseError> { iter.next(); Some(parse_struct(&mut iter)?) }, + Some(TokenType::Proto) => { + iter.next(); + Some(parse_proto(&mut iter)?) + }, Some(TokenType::Type) => { iter.next(); Some(parse_type_alias(&mut iter)?) @@ -398,6 +402,7 @@ fn parse_syntax_impl(tokens: &[Token]) -> Result, ParseError> { "pub import", "pub export(c) fun", "pub struct", + "pub proto", "pub enum", "pub variant", "pub type", @@ -434,6 +439,17 @@ fn parse_syntax_impl(tokens: &[Token]) -> Result, ParseError> { function.visibility = Visibility::Public; }, ASTNode::Struct(structure) => structure.visibility = Visibility::Public, + ASTNode::ProtoImpl(implementation) => { + if implementation.name.is_none() { + return Err(ParseError::syntax_at( + Some(&anchor), + "only named proto contracts may be public", + ) + .with_context("public declaration") + .with_help("use `proto Target { ... }` for inherent methods")); + } + implementation.visibility = Visibility::Public; + }, ASTNode::TypeAlias(alias) => alias.visibility = Visibility::Public, ASTNode::Enum(enumeration) => enumeration.visibility = Visibility::Public, ASTNode::Variant(variant) => variant.visibility = Visibility::Public, diff --git a/front/parser/src/verification/program.rs b/front/parser/src/verification/program.rs index 1353c34e..c462714e 100644 --- a/front/parser/src/verification/program.rs +++ b/front/parser/src/verification/program.rs @@ -186,6 +186,14 @@ impl ProgramTypes { } }, ASTNode::ProtoImpl(implementation) => { + if let Some(name) = &implementation.name { + return Err(failure( + format!( + "named proto contract `{name}` is not supported by semantic lowering yet" + ), + Some(top_level_span_hint(node)), + )); + } for method in &implementation.methods { let signature = function_type(method); insert_unique_method( diff --git a/front/parser/tests/proto_contracts.rs b/front/parser/tests/proto_contracts.rs new file mode 100644 index 00000000..6e97af81 --- /dev/null +++ b/front/parser/tests/proto_contracts.rs @@ -0,0 +1,90 @@ +//! Named proto syntax is parsed without changing inherent method attachments. +use lexer::Lexer; +use parser::{ + ast::{ASTNode, Visibility, WaveType}, + parse_syntax_with_spans, +}; + +fn parse(source: &str) -> Vec { + let tokens = Lexer::new_with_file(source, "proto.wave").tokenize().unwrap(); + parse_syntax_with_spans(&tokens).unwrap() +} + +fn failure(source: &str, expected: &str) { + let tokens = Lexer::new_with_file(source, "proto.wave").tokenize().unwrap(); + let error = parse_syntax_with_spans(&tokens).unwrap_err(); + assert!( + error.expected().iter().any(|item| item.contains(expected)) + || error.message().contains(expected), + "{source}: {error:?}" + ); + assert!(error.span().is_some(), "{source}: missing source span"); +} + +#[test] +fn named_and_inherent_proto_forms_remain_distinct() { + let source = "proto Printable : Buffer { fun render(self) { return; } } + proto Buffer { fun clear(self: ptr) {} }"; + let nodes = parse(source); + assert_eq!(nodes.len(), 2); + let ASTNode::ProtoImpl(named) = nodes[0].unspanned() else { panic!("named proto") }; + assert_eq!(named.name.as_deref(), Some("Printable")); + assert_eq!(named.target, "Buffer"); + assert_eq!(named.visibility, Visibility::Private); + assert_eq!(named.methods.len(), 1); + assert_eq!(named.methods[0].parameters[0].name, "self"); + assert_eq!(named.methods[0].parameters[0].param_type, WaveType::Struct("Buffer".into())); + assert!(!named.methods[0].body.is_empty()); + + let ASTNode::ProtoImpl(inherent) = nodes[1].unspanned() else { panic!("inherent proto") }; + assert_eq!(inherent.name, None); + assert_eq!(inherent.target, "Buffer"); + assert_eq!( + inherent.methods[0].parameters[0].param_type, + WaveType::Pointer(Box::new(WaveType::Struct("Buffer".into()))) + ); + assert!(nodes[0].clone().span().is_some()); +} + +#[test] +fn public_named_contract_preserves_target_generic_arguments_and_visibility() { + let nodes = parse( + "pub proto Printable: Buffer { + fun render(self) {} + fun copy(self: ptr>) {} + }", + ); + let ASTNode::ProtoImpl(contract) = nodes[0].unspanned() else { panic!("proto") }; + assert_eq!(contract.name.as_deref(), Some("Printable")); + assert_eq!(contract.target, "Buffer"); + assert_eq!(contract.visibility, Visibility::Public); + assert_eq!(contract.methods.len(), 2); + assert_eq!( + contract.methods[0].parameters[0].param_type, + WaveType::Struct("Buffer".into()) + ); + let cloned = nodes[0].clone(); + let ASTNode::ProtoImpl(copy) = cloned.unspanned() else { panic!("clone") }; + assert_eq!(copy.name, contract.name); + assert_eq!(copy.visibility, Visibility::Public); +} + +#[test] +fn malformed_named_proto_syntax_has_located_diagnostics() { + for (source, expected) in [ + ("proto : Buffer {}", "identifier"), + ("proto Printable Buffer {}", "'{' or ':'"), + ("proto Printable: {}", "type"), + ("proto Printable: Buffer", "'{'"), + ("proto Printable: Buffer {", "'}'"), + ("proto Printable: Buffer { fun render(self) }", "function"), + ("pub proto Buffer {}", "only named proto"), + ] { + failure(source, expected); + } +} + +#[test] +fn incomplete_inherent_proto_preserves_the_existing_diagnostic() { + failure("proto Buffer {", "method declaration or '}'"); +}