From 4991197656971fae563d9448280a98e1447fe36b Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 09:06:50 +0200 Subject: [PATCH 01/58] release - prepare v0.3.0 rc0 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a138f40a..cfff47353 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-dev.51" +version = "0.3.0-rc0" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 775d7f1cd..bd2c826a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-dev.51" +version = "0.3.0-rc0" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" From 7e32392157b08d10e60f21f196a307ba0a2cb4de Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 13:11:58 +0200 Subject: [PATCH 02/58] bugfix - use Rust trait metadata for decode argument shape (#612) (#614) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/emit/expressions/methods.rs | 22 +- src/backend/ir/emit/expressions/mod.rs | 196 ++++++++++++++++++ src/frontend/typechecker/check_expr/access.rs | 50 ++++- .../check_expr/calls/rust_boundary.rs | 50 +++++ src/frontend/typechecker/mod.rs | 50 ++++- src/frontend/typechecker/tests.rs | 109 +++++++++- tests/cli_integration.rs | 111 +++++++++- tests/integration_tests.rs | 27 ++- 10 files changed, 589 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfff47353..2c8225dd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc0" +version = "0.3.0-rc1" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index bd2c826a5..0cea4481e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc0" +version = "0.3.0-rc1" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index 45d0aec30..3ead3ac3f 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -233,6 +233,11 @@ impl<'a> IrEmitter<'a> { ) } + /// Return whether an argument expression already has Rust reference shape in IR. + fn method_arg_already_has_reference_shape(arg: &TypedExpr) -> bool { + Self::method_arg_already_borrowed_for_ref_param(&arg.ty) + } + /// Emit method-call arguments with Rust-boundary borrowing and union wrapping applied from callable metadata. fn emit_method_call_args( &self, @@ -395,6 +400,7 @@ impl<'a> IrEmitter<'a> { } else if external_method_shape && idx == 0 && Self::method_arg_needs_fallback_borrow(method, &arg.ty) + && !Self::method_arg_already_has_reference_shape(arg) { emitted = quote! { &#emitted }; } @@ -409,25 +415,11 @@ impl<'a> IrEmitter<'a> { { emitted = coerced; } - if external_method_shape - && !external_param_planned - && idx == 0 - && Self::method_arg_needs_fallback_mut_borrow(method, &arg.ty) - { - return Ok(quote! { &mut #emitted }); - } - if external_method_shape - && !external_param_planned - && idx == 0 - && Self::method_arg_needs_fallback_borrow(method, &arg.ty) - { - return Ok(quote! { &#emitted }); - } if !external_param_planned { match ¶m.ty { IrType::Ref(_) if matches!(base_use_site, ValueUseSite::MethodArg) => {} IrType::Ref(_) => match &arg.ty { - _ if Self::method_arg_already_borrowed_for_ref_param(&arg.ty) => {} + _ if Self::method_arg_already_has_reference_shape(arg) => {} _ => emitted = quote! { &#emitted }, }, IrType::RefMut(_) => match &arg.ty { diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 4972c6f85..102b63ccb 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -1104,6 +1104,20 @@ mod tests { use crate::backend::ir::{FunctionParam, FunctionRegistry, FunctionSignature, Mutability}; use incan_core::lang::traits::{self as core_traits, TraitId}; + fn prost_decode_signature(return_type: IrType) -> FunctionSignature { + FunctionSignature { + params: vec![FunctionParam { + name: "buf".to_string(), + ty: IrType::Generic("Buf".to_string()), + mutability: Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }], + return_type, + } + } + #[test] fn type_name_associated_call_does_not_borrow_string_arguments() -> Result<(), String> { let registry = FunctionRegistry::new(); @@ -1294,6 +1308,188 @@ mod tests { Ok(()) } + #[test] + fn external_decode_metadata_keeps_explicit_slice_argument_shape() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let descriptor_set = IrType::Struct("prost_types::FileDescriptorSet".to_string()); + let result_ty = IrType::Result( + Box::new(descriptor_set.clone()), + Box::new(IrType::Struct("prost::DecodeError".to_string())), + ); + let expr = TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "FileDescriptorSet".to_string(), + access: VarAccess::Copy, + ref_kind: VarRefKind::ExternalRustName, + }, + descriptor_set.clone(), + )), + method: "decode".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "data".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Bytes, + )), + method: "as_slice".to_string(), + dispatch: None, + type_args: Vec::new(), + args: Vec::new(), + callable_signature: None, + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Bytes, + ), + }], + callable_signature: Some(prost_decode_signature(result_ty.clone())), + arg_policy: MethodCallArgPolicy::Default, + }, + result_ty, + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("FileDescriptorSet :: decode (data . as_slice ())"), + "explicit slice arguments should be passed through, got `{rendered}`" + ); + assert!( + !rendered.contains("FileDescriptorSet :: decode (& data . as_slice ())"), + "decode metadata must not add a fallback borrow to explicit slice arguments, got `{rendered}`" + ); + Ok(()) + } + + #[test] + fn external_decode_metadata_keeps_explicit_rust_vec_slice_argument_shape() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let descriptor_set = IrType::Struct("prost_types::FileDescriptorSet".to_string()); + let result_ty = IrType::Result( + Box::new(descriptor_set.clone()), + Box::new(IrType::Struct("prost::DecodeError".to_string())), + ); + let expr = TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "FileDescriptorSet".to_string(), + access: VarAccess::Copy, + ref_kind: VarRefKind::ExternalRustName, + }, + descriptor_set.clone(), + )), + method: "decode".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "encoded".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("alloc::vec::Vec".to_string()), + )), + method: "as_slice".to_string(), + dispatch: None, + type_args: Vec::new(), + args: Vec::new(), + callable_signature: None, + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Bytes, + ), + }], + callable_signature: Some(prost_decode_signature(result_ty.clone())), + arg_policy: MethodCallArgPolicy::Default, + }, + result_ty, + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("FileDescriptorSet :: decode (encoded . as_slice ())"), + "explicit Rust Vec slice arguments should be passed through, got `{rendered}`" + ); + assert!( + !rendered.contains("FileDescriptorSet :: decode (& encoded . as_slice ())"), + "decode metadata must not add a fallback borrow to explicit Rust Vec slice arguments, got `{rendered}`" + ); + Ok(()) + } + + #[test] + fn external_decode_fallback_still_borrows_owned_bytes_argument() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let descriptor_set = IrType::Struct("prost_types::FileDescriptorSet".to_string()); + let expr = TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "FileDescriptorSet".to_string(), + access: VarAccess::Copy, + ref_kind: VarRefKind::ExternalRustName, + }, + descriptor_set.clone(), + )), + method: "decode".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: "data".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Bytes, + ), + }], + callable_signature: None, + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Result( + Box::new(descriptor_set), + Box::new(IrType::Struct("prost::DecodeError".to_string())), + ), + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("FileDescriptorSet :: decode (& data)"), + "owned bytes should still use the decode fallback borrow, got `{rendered}`" + ); + Ok(()) + } + #[test] fn interop_try_adapter_emits_question_mark() -> Result<(), String> { let registry = FunctionRegistry::new(); diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index b41eb0351..79c5a19f9 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -1342,7 +1342,23 @@ impl TypeChecker { if preserves_lookup_arg_shape { self.type_info.record_regular_method_arg_shape(receiver_span, method); } - let metadata = self.rust_item_metadata_for_path(rust_path)?; + let Some(metadata) = self.rust_item_metadata_for_path(rust_path) else { + if let Some(import_use) = self.record_unique_rust_trait_import_for_unresolved_receiver_call(method, span) + && let Some(sig) = import_use.signature.as_ref() + { + let callable_display = format!("rust::{rust_path}.{method}"); + let ret = self.validate_rust_method_call( + callable_display.as_str(), + sig, + args, + arg_types, + preserves_lookup_arg_shape, + span, + ); + return Some(Self::substitute_rust_self_type(ret, rust_path)); + } + return None; + }; match &metadata.kind { RustItemKind::Type(_) => { let Some(sig) = self.rust_method_signature(rust_path, method) else { @@ -1434,6 +1450,38 @@ impl TypeChecker { Some(import_use.clone()) } + /// Record a unique imported Rust trait method when receiver metadata is unavailable. + /// + /// rust-inspect can miss generated or re-export-heavy concrete types while still extracting the imported trait's + /// signature. In that case the trait signature is enough for call-site parameter shape metadata; rustc remains the + /// authority on whether the receiver type actually implements the trait. + fn record_unique_rust_trait_import_for_unresolved_receiver_call( + &mut self, + method: &str, + span: Span, + ) -> Option { + let matches = self + .type_info + .rust + .trait_imports + .iter() + .filter(|(_, import)| import.methods.contains(method)) + .map(|(binding, import)| RustMethodTraitImportUse { + binding: binding.clone(), + trait_path: import.trait_path.clone(), + method: method.to_string(), + signature: Self::rust_trait_method_signature(import, method), + }) + .collect::>(); + let [import_use] = matches.as_slice() else { + return None; + }; + import_use.signature.as_ref()?; + self.type_info + .record_rust_method_trait_import_use(span, import_use.clone()); + Some(import_use.clone()) + } + /// Return the trait method signature when `import` is implemented by `type_info` and declares `method`. fn rust_trait_import_matches_receiver( type_info: &incan_core::interop::RustTypeInfo, diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index e54bd61a7..b1cd35d32 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -999,6 +999,8 @@ mod validate_rust_function_call_tests { let checker = TypeChecker::new(); assert!(checker.rust_arg_matches_boundary(&ResolvedType::Named("Payload".to_string()), "T",)); + assert!(checker.rust_arg_matches_boundary(&ResolvedType::Bytes, "impl Buf")); + assert!(checker.rust_arg_matches_boundary(&ResolvedType::Bytes, "implBuf")); assert!(checker.rust_arg_matches_boundary(&ResolvedType::Named("Payload".to_string()), "&T",)); assert!(checker.rust_arg_matches_boundary(&ResolvedType::Named("Payload".to_string()), "&TValue",)); } @@ -1046,6 +1048,54 @@ mod validate_rust_function_call_tests { ); } + #[test] + fn validate_rust_method_call_records_by_value_impl_trait_param_shape() { + let mut checker = TypeChecker::new(); + let span = Span::new(30, 40); + let arg_expr = Spanned::new(Expr::Ident("encoded".to_string()), span); + let args = [CallArg::Positional(arg_expr)]; + let arg_types = [ResolvedType::Bytes]; + let sig = RustFunctionSig { + params: vec![RustParam { + name: Some("buf".to_string()), + type_display: "implBuf".to_string(), + }], + return_type: "demo::FileDescriptorSet".to_string(), + is_async: false, + is_unsafe: false, + }; + + let _ = checker.validate_rust_method_call( + "rust::demo::FileDescriptorSet.decode", + &sig, + &args, + &arg_types, + false, + span, + ); + + assert!( + checker.errors.is_empty(), + "expected by-value impl Trait Rust param to accept bytes without borrow coercion, got {:?}", + checker.errors + ); + assert!( + checker.type_info.rust.arg_coercions.is_empty(), + "expected by-value impl Trait Rust param to avoid borrow coercion, got {:?}", + checker.type_info.rust.arg_coercions + ); + assert!( + checker + .type_info + .calls + .call_site_callable_params + .get(&(span.start, span.end)) + .is_some_and(|params| params.len() == 1 && params[0].ty == ResolvedType::TypeVar("implBuf".to_string())), + "expected Rust by-value impl Trait method param shape to be recorded, got {:?}", + checker.type_info.calls.call_site_callable_params + ); + } + #[test] fn validate_rust_method_call_records_interop_coercion_for_rusttype_target() { let mut checker = TypeChecker::new(); diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 2fb2a7986..265f1bd2f 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -789,6 +789,36 @@ impl TypeChecker { normalized.strip_prefix('&').map(|inner| (false, inner)) } + /// Remove Rust lifetime labels that decorate borrowed display types. + /// + /// rust-analyzer commonly prints borrowed method parameters as `&'h str` or `&'a [u8]`. The typechecker only needs + /// the ownership shape and payload type, so erase the lifetime after `&` before whitespace normalization turns it + /// into an unparseable token such as `&'hstr`. + fn strip_borrow_lifetimes(rust_ty: &str) -> String { + let mut out = String::with_capacity(rust_ty.len()); + let mut chars = rust_ty.chars().peekable(); + while let Some(ch) = chars.next() { + out.push(ch); + if ch != '&' { + continue; + } + while matches!(chars.peek(), Some(next) if next.is_whitespace()) { + out.push(chars.next().expect("peeked whitespace should exist")); + } + if !matches!(chars.peek(), Some('\'')) { + continue; + } + chars.next(); + while matches!(chars.peek(), Some(next) if next.is_ascii_alphanumeric() || *next == '_') { + chars.next(); + } + while matches!(chars.peek(), Some(next) if next.is_whitespace()) { + chars.next(); + } + } + out + } + /// Map structured rust-inspect [`RustTypeShape`] into a [`ResolvedType`] for field access and pattern typing. /// /// `Option`/`Result` become [`ResolvedType::Generic`] with constructor names `Option` and `Result`. Concrete paths @@ -860,7 +890,8 @@ impl TypeChecker { /// for one or both type arguments. Prefer precise typing from Incan surfaces over relying on this heuristic. pub(crate) fn resolved_type_from_rust_display(&self, rust_ty: &str) -> ResolvedType { let trimmed = rust_ty.trim(); - let no_lifetimes = trimmed.replace("'static ", "").replace("'_", "").replace(' ', ""); + let no_lifetimes = Self::strip_borrow_lifetimes(trimmed); + let no_lifetimes = no_lifetimes.replace("'static ", "").replace("'_", "").replace(' ', ""); let normalized = no_lifetimes.trim_start_matches("::").to_string(); if let Some(Symbol { kind: SymbolKind::RustItem(info), @@ -884,6 +915,7 @@ impl TypeChecker { }; } match normalized.as_str() { + "{unknown}" => ResolvedType::Unknown, "bool" => ResolvedType::Bool, "f64" => ResolvedType::Float, "i64" => ResolvedType::Int, @@ -949,9 +981,18 @@ impl TypeChecker { } } - /// Return a Rust generic type-parameter name when the display is the simple identifier form rust-analyzer uses - /// for params like `T` or `U`. + /// Return a Rust generic parameter display when rust-analyzer reports a by-value generic boundary. + /// + /// Plain type parameters appear as `T` or `U`. Anonymous `impl Trait` parameters can arrive with whitespace erased, + /// such as `implBuf` for `impl Buf`; those still carry by-value shape and must not be treated as borrowed Rust + /// boundary targets. pub(crate) fn rust_display_type_var_name(normalized: &str) -> Option<&str> { + if let Some(tail) = normalized.strip_prefix("impl") + && !tail.is_empty() + && (tail.contains("::") || tail.chars().next().is_some_and(|ch| ch.is_ascii_uppercase())) + { + return Some(normalized); + } if normalized.len() == 1 && normalized.chars().next().is_some_and(|ch| ch.is_ascii_uppercase()) { Some(normalized) } else { @@ -966,7 +1007,8 @@ impl TypeChecker { /// borrowed Rust boundary so emission can pass `&arg` instead of moving an owned `String` or `Vec`. pub(crate) fn resolved_param_type_from_rust_display(&self, rust_ty: &str) -> ResolvedType { let trimmed = rust_ty.trim(); - let no_lifetimes = trimmed.replace("'static ", "").replace("'_", "").replace(' ', ""); + let no_lifetimes = Self::strip_borrow_lifetimes(trimmed); + let no_lifetimes = no_lifetimes.replace("'static ", "").replace("'_", "").replace(' ', ""); let normalized = no_lifetimes.trim_start_matches("::").to_string(); if let Some(name) = Self::rust_display_type_var_name(normalized.as_str()) { return ResolvedType::TypeVar(name.to_string()); diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index 7425f8c94..fe8ecc880 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -2511,6 +2511,8 @@ fn test_resolved_type_from_builtin_borrowed_displays_stays_stable() { let checker = TypeChecker::new(); assert_eq!(checker.resolved_type_from_rust_display("&str"), ResolvedType::Str); assert_eq!(checker.resolved_type_from_rust_display("&[u8]"), ResolvedType::Bytes); + assert_eq!(checker.resolved_type_from_rust_display("&'h str"), ResolvedType::Str); + assert_eq!(checker.resolved_type_from_rust_display("&'h [u8]"), ResolvedType::Bytes); } #[test] @@ -2524,6 +2526,18 @@ fn test_resolved_param_type_from_builtin_borrowed_displays_preserves_ref_payload checker.resolved_param_type_from_rust_display("&[u8]"), ResolvedType::Ref(Box::new(ResolvedType::Bytes)), ); + assert_eq!( + checker.resolved_param_type_from_rust_display("&'h str"), + ResolvedType::Ref(Box::new(ResolvedType::Str)), + ); + assert_eq!( + checker.resolved_param_type_from_rust_display("&'h [u8]"), + ResolvedType::Ref(Box::new(ResolvedType::Bytes)), + ); + assert_eq!( + checker.resolved_param_type_from_rust_display("&'h mut demo::Thing"), + ResolvedType::RefMut(Box::new(ResolvedType::RustPath("demo::Thing".to_string()))), + ); } #[test] @@ -3011,6 +3025,15 @@ fn test_rust_metadata_lookup_path_rejects_unknown_placeholder() { assert_eq!(TypeChecker::rust_metadata_lookup_path("{unknown}"), None); } +#[test] +fn test_rust_display_unknown_placeholder_resolves_unknown() { + let checker = TypeChecker::new(); + assert_eq!( + checker.resolved_type_from_rust_display("{unknown}"), + ResolvedType::Unknown + ); +} + #[cfg(feature = "rust_inspect")] #[test] fn test_rust_item_metadata_lookup_reuses_cached_nominal_item_for_instantiated_rust_path() @@ -8057,10 +8080,10 @@ def f(w: Widget) -> None: #[test] fn test_rust_extension_trait_associated_call_records_param_shape() -> Result<(), Box> { let source = r#" -from rust::demo import Cursor, FileDescriptorSet, Message +from rust::demo import FileDescriptorSet, Message -def f(cursor: Cursor) -> None: - _ = FileDescriptorSet.decode(cursor) +def f(encoded: bytes) -> None: + _ = FileDescriptorSet.decode(encoded.as_slice()) "#; let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; @@ -8082,7 +8105,7 @@ def f(cursor: Cursor) -> None: signature: RustFunctionSig { params: vec![RustParam { name: Some("buf".to_string()), - type_display: "T".to_string(), + type_display: "implBuf".to_string(), }], return_type: "Self".to_string(), is_async: false, @@ -8093,7 +8116,7 @@ def f(cursor: Cursor) -> None: }, ) .map_err(|err| std::io::Error::other(format!("seed trait metadata: {err}")))?; - for path in ["demo::Cursor", "demo::FileDescriptorSet"] { + for path in ["demo::FileDescriptorSet"] { checker .rust_inspect_cache .insert_test_item( @@ -8134,10 +8157,84 @@ def f(cursor: Cursor) -> None: .calls .call_site_callable_params .values() - .any(|params| params.len() == 1 && params[0].ty == ResolvedType::TypeVar("T".to_string())), + .any(|params| params.len() == 1 && params[0].ty == ResolvedType::TypeVar("implBuf".to_string())), "expected trait-provided decode parameter shape to be recorded, got {:?}", checker.type_info().calls.call_site_callable_params ); + assert!( + checker.type_info().rust.arg_coercions.is_empty(), + "expected trait-provided impl Trait decode to avoid borrow coercions, got {:?}", + checker.type_info().rust.arg_coercions + ); + Ok(()) +} + +#[test] +fn test_rust_extension_trait_associated_call_records_param_shape_without_receiver_metadata() +-> Result<(), Box> { + let source = r#" +from rust::demo import Message +from rust::datafusion_substrait::substrait::proto import Plan as ConsumerPlan + +def f(encoded: bytes) -> None: + _ = ConsumerPlan.decode(encoded.as_slice()) +"#; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; + let mut checker = TypeChecker::new(); + let tmp = seeded_rust_inspect_workspace()?; + let manifest_dir = tmp.path().to_path_buf(); + checker.set_rust_inspect_manifest_dir(manifest_dir.clone()); + checker + .rust_inspect_cache + .insert_test_item( + &manifest_dir, + RustItemMetadata { + canonical_path: "demo::Message".to_string(), + definition_path: Some("demo::Message".to_string()), + visibility: RustVisibility::Public, + kind: RustItemKind::Trait(RustTraitInfo { + items: vec![RustTraitAssoc::Function { + name: "decode".to_string(), + signature: RustFunctionSig { + params: vec![RustParam { + name: Some("buf".to_string()), + type_display: "implBuf".to_string(), + }], + return_type: "Self".to_string(), + is_async: false, + is_unsafe: false, + }, + }], + }), + }, + ) + .map_err(|err| std::io::Error::other(format!("seed trait metadata: {err}")))?; + + checker + .check_program(&ast) + .map_err(|errs| std::io::Error::other(format!("typecheck failed: {errs:?}")))?; + let uses = &checker.type_info().rust.method_trait_import_uses; + assert!( + uses.values() + .any(|import_use| import_use.binding == "Message" && import_use.method == "decode"), + "expected Message import use for unresolved receiver metadata, got {uses:?}" + ); + assert!( + checker + .type_info() + .calls + .call_site_callable_params + .values() + .any(|params| params.len() == 1 && params[0].ty == ResolvedType::TypeVar("implBuf".to_string())), + "expected trait-provided decode parameter shape without receiver metadata, got {:?}", + checker.type_info().calls.call_site_callable_params + ); + assert!( + checker.type_info().rust.arg_coercions.is_empty(), + "expected unresolved receiver trait signature to avoid borrow coercions, got {:?}", + checker.type_info().rust.arg_coercions + ); Ok(()) } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 80b82d3da..fd971e836 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -780,11 +780,11 @@ pub struct DecodeError; pub struct FileDescriptorSet; pub trait Message: Sized { - fn decode(_buf: T) -> Result; + fn decode(_buf: impl DecodeBuf) -> Result; } impl Message for FileDescriptorSet { - fn decode(_buf: T) -> Result { + fn decode(_buf: impl DecodeBuf) -> Result { Ok(Self) } } @@ -808,6 +808,113 @@ impl Message for FileDescriptorSet { Ok(()) } +#[test] +fn run_accepts_cross_crate_trait_decode_slice_param_issue612() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project( + tmp.path(), + "cli_cross_crate_trait_decode_project", + r#" + +[rust-dependencies] +prost = { path = "rust/prost" } +prost-types = { path = "rust/prost-types" } +"#, + )?; + fs::write( + &main_path, + r#"from rust::prost import Message +from rust::prost_types import FileDescriptorSet, ProducerPlan + + +def main() -> None: + producer = ProducerPlan.new() + encoded = producer.encode_to_vec() + match FileDescriptorSet.decode(encoded.as_slice()): + Ok(_) => println("ok") + Err(_) => println("err") +"#, + )?; + let prost_src = tmp.path().join("rust").join("prost").join("src"); + fs::create_dir_all(&prost_src)?; + fs::write( + prost_src.parent().ok_or("prost src has no parent")?.join("Cargo.toml"), + r#"[package] +name = "prost" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + prost_src.join("lib.rs"), + r#"pub trait Buf {} + +impl Buf for &[u8] {} + +pub struct DecodeError; + +pub trait Message: Sized { + fn decode(_buf: impl Buf) -> Result; +} +"#, + )?; + let prost_types_src = tmp.path().join("rust").join("prost-types").join("src"); + fs::create_dir_all(&prost_types_src)?; + fs::write( + prost_types_src + .parent() + .ok_or("prost-types src has no parent")? + .join("Cargo.toml"), + r#"[package] +name = "prost-types" +version = "0.1.0" +edition = "2021" + +[dependencies] +prost = { path = "../prost" } +"#, + )?; + fs::write( + prost_types_src.join("lib.rs"), + r#"pub struct ProducerPlan; + +impl ProducerPlan { + pub fn new() -> Self { + Self + } + + pub fn encode_to_vec(&self) -> Vec { + b"abc".to_vec() + } +} + +pub struct FileDescriptorSet; + +impl prost::Message for FileDescriptorSet { + fn decode(_buf: impl prost::Buf) -> Result { + Ok(Self) + } +} +"#, + )?; + + let output = run_incan( + tmp.path(), + &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + + assert_success( + &output, + "incan run with cross-crate trait-provided decode over explicit slice", + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("ok"), + "expected cross-crate trait-provided decode helper output, got:\n{stdout}" + ); + Ok(()) +} + #[test] fn build_locked_rejects_stale_lockfile() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 3eaebd00c..ad1e6342e 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -46,16 +46,21 @@ fn strip_ansi_escapes(text: &str) -> String { out } -static RUNTIME_ERROR_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); +static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); -/// Create a minimal throwaway Incan project for end-to-end runtime error assertions. +/// Create a throwaway project name that does not collide under parallel nextest workers. /// -/// The generated project name includes both the current process id and a local counter so parallel nextest workers do -/// not trample each other's `target/incan/` outputs. +/// Several CLI tests rely on the default `target/incan/` output location. The generated project name includes +/// both the current process id and a local counter so those tests do not trample each other's generated Cargo projects. +fn unique_test_project_name(prefix: &str) -> String { + let unique = TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{prefix}_{}_{}", std::process::id(), unique) +} + +/// Create a minimal throwaway Incan project for end-to-end runtime error assertions. fn write_runtime_error_project(source: &str) -> Result<(tempfile::TempDir, PathBuf), Box> { let tmp = tempfile::tempdir()?; - let unique = RUNTIME_ERROR_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed); - let project_name = format!("runtime_error_contract_{}_{}", std::process::id(), unique); + let project_name = unique_test_project_name("runtime_error_contract"); let src_dir = tmp.path().join("src"); fs::create_dir_all(&src_dir)?; fs::write( @@ -12886,10 +12891,13 @@ pub def small_key_map_bytes() -> bytes: ); let consumer_root = tmp.path().join("ordinal_keys_consumer"); + let consumer_name = unique_test_project_name("ordinal_keys_consumer"); std::fs::create_dir_all(consumer_root.join("src"))?; std::fs::write( consumer_root.join("incan.toml"), - "[project]\nname = \"consumer\"\n\n[dependencies]\nordinal_keys = { path = \"../ordinal_keys_lib\" }\n", + format!( + "[project]\nname = \"{consumer_name}\"\n\n[dependencies]\nordinal_keys = {{ path = \"../ordinal_keys_lib\" }}\n" + ), )?; let consumer_main = consumer_root.join("src/main.incn"); std::fs::write( @@ -13282,9 +13290,12 @@ pub def projection(name: str, target: str) -> str: ); let consumer_root = tmp.path().join("nested_consumer"); + let consumer_name = unique_test_project_name("nested_consumer"); let consumer_main = write_project_files( &consumer_root, - "[project]\nname = \"consumer\"\n\n[dependencies]\nnested = { path = \"../nested_vocab_project\" }\n", + &format!( + "[project]\nname = \"{consumer_name}\"\n\n[dependencies]\nnested = {{ path = \"../nested_vocab_project\" }}\n" + ), r#"import pub::nested def main() -> None: From 4407bd503011dbbc3aebadaf26e63f1e78041268 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 13:27:38 +0200 Subject: [PATCH 03/58] docs - compress v0.3 release notes and patch docs deps --- .../docs-site/docs/release_notes/0_3.md | 635 ++---------------- workspaces/docs-site/requirements-docs.txt | 4 +- 2 files changed, 55 insertions(+), 584 deletions(-) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index d32bad5ec..ceca8adb1 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -1,598 +1,69 @@ # Release 0.3 -Incan 0.3 picks up after the `0.2` line, which made the language surface more explicit around stdlib imports, Rust interop, library manifests, module state, and call-site generics. +Incan 0.3 builds on the `0.2` line by making larger programs feel less improvised: richer source-level language features, a much broader standard library, a stronger test runner, better Rust interop, and fewer generated-Rust ownership surprises. -`0.3` now includes a larger numeric surface, a new control-flow surface, richer enum behavior, Rust trait adoption from Incan-owned wrappers, graph, collections, datetime, logging, encoding, hashing, compression, regex, and dynamic JSON stdlib surfaces, iterator adapter chains, Result combinators, and tighter tooling contracts. RFC 009 makes numeric annotations precise enough for Rust interop, wire formats, data schemas, and fixed-scale decimal values; RFC 016 adds `loop:` and `break ` so loops can produce values directly; RFC 030 adds `std.collections` for specialized collection semantics; RFC 101 extends that collections surface with `OrdinalMap` for deterministic immutable key-to-ordinal lookup; RFC 047 adds `std.graph` for explicit in-memory dependency and plan graphs; RFC 064 adds `std.encoding` for strict-by-default binary-text transforms; RFC 065 adds `std.hash` for stable byte, file, reader, cryptographic, compatibility, and non-cryptographic hashing workflows; RFC 061 adds `std.compression` for byte, stream, and explicit autodetected compression workflows; RFC 059 adds safe-default regular expressions with explicit match/capture/replacement contracts; RFC 051 adds `std.json.JsonValue` for dynamic parse-inspect-transform workflows and `std.math` numeric-like string classification helpers; RFC 050 lets enums declare methods and adopt traits; RFC 043 starts Rust trait implementation authoring from Incan source with `with Trait`, method-level `for Trait`, and associated type declarations on newtypes and rusttypes; RFC 088 standardizes lazy iterator adapters and terminal consumers; RFC 070 adds Rust-shaped `Result[T, E]` composition with `map`, `map_err`, `and_then`, `or_else`, `inspect`, and `inspect_err`; RFC 053 tightens the formatter contract so output is less dependent on local heuristics and more predictable across CLI, editor, and library entry points; RFC 058 adds Rust-backed runtime timing plus source-defined civil temporal values, fixed UTC offsets, Python-shaped parsing/formatting, and interval arithmetic; and RFC 072 adds source-defined structured logging. +If `0.2` was mostly about explicit namespaces, library manifests, and Rust boundary cleanup, `0.3` is about using that structure for real application code. The release adds typed numerics, expression-oriented control flow, enum behavior, protocol hooks, Rust trait adoption, graph/collection/JSON/regex/datetime/logging/encoding/hash/compression stdlib modules, lazy iterator pipelines, `Result` combinators, first-class testing workflows, and tighter lockfile/formatter/tooling contracts. -If you are looking for the shipped `0.2` story, start with [Release 0.2](0_2.md). - -For numeric guidance, start with [Choosing numeric types](../language/how-to/choosing_numeric_types.md) and [Numeric semantics](../language/reference/numeric_semantics.md). For the current control-flow guidance, start with [Control Flow](../language/explanation/control_flow.md). For the current source-layout contract, start with the [Incan Code Style Guide](../language/reference/code_style.md). Use [Formatting with `incan fmt`](../tooling/how-to/formatting.md) for the tool behavior. [RFC 009], [RFC 016], and [RFC 053] record the design snapshots behind those behaviors. +If you are looking for the previous release story, start with [Release 0.2](0_2.md). For current user docs, start with [Control Flow](../language/explanation/control_flow.md), [Choosing numeric types](../language/how-to/choosing_numeric_types.md), [Testing in Incan](../language/how-to/testing_stdlib.md), the [standard library reference](../language/reference/stdlib/index.md), and [Rust interop](../language/how-to/rust_interop.md). ## What 0.3 is about -The `0.2` line made Incan's module, stdlib, and Rust interop boundaries much clearer. `0.3` continues from that baseline with a stronger emphasis on predictable generated output, contributor ergonomics, and small but meaningful control-flow ergonomics that remove repetitive boilerplate without weakening the language's explicit pattern model. - -The release emphasizes a few concrete directions: +The main direction is not "more syntax for its own sake." `0.3` moves common project patterns into documented language and stdlib surfaces so users can write less Rust-shaped scaffolding and contributors can keep compiler behavior tied to explicit metadata. -- expression-oriented control flow should stay explicit, so infinite loops that return values use `loop:` and `break ` rather than hidden accumulator patterns -- numeric annotations should be ergonomic for ordinary code while still exact enough for Rust APIs, binary formats, database schemas, and analytics data -- formatter output should be governed by explicit contracts, not scattered newline decisions -- common `Option` / `Result` destructuring should have a concise control-flow form when the non-match case is intentionally a no-op -- enums that cross string or integer boundaries should keep enum type safety while exposing one canonical raw representation -- enum-owned behavior should live on the enum itself, and enums should be able to adopt the same trait protocols as models and classes -- Rust ecosystem trait contracts should be authored through Incan's `with` adoption model where possible, with Rust `impl` blocks treated as generated backend output -- operator overloading should present traits as nominal capability contracts while keeping dunder methods as the explicit implementation hooks -- in-memory graph-shaped data should have one small stdlib vocabulary for node ids, edge ids, directed graphs, DAGs, multigraphs, adjacency, traversal, and cycle-aware ordering -- time-shaped data should have one stdlib vocabulary for Rust-backed runtime timing, civil dates and times, fixed UTC offsets, and calendar-aware intervals -- binary-text encoding should have explicit stdlib modules for strict and lenient value plus finite source/sink transforms, with variant choices visible in API names -- specialized collection semantics should have explicit stdlib types instead of forcing every queue, multiset, ordered map, sorted set, layered map, or priority queue through bare builtin containers -- immutable key-to-ordinal lookup should have an explicit deterministic contract instead of being modeled as an ad hoc `dict[K, int]` when serialized bytes, stable scalar key encodings, exact safe lookup, and compact ordinal storage matter -- byte, file, and reader hashing should have explicit algorithm namespaces, with cryptographic and compatibility digests separated from fast non-cryptographic integer helpers -- compression should be codec-explicit by default, with stream helpers and explicit autodetection rather than hidden format guessing -- user-facing tooling behavior should match the docs closely enough that CI and editor integrations can rely on it -- testing should feel like a first-class workflow, with inline unit tests, fixtures, parametrization, selection, scheduling, and machine-readable reports owned by Incan rather than delegated to ad hoc scripts -- iterator pipelines should be lazy by default, with terminal consumers such as `.collect()`, `.count()`, `.any()`, `.all()`, `.find()`, and `.fold()` making realization or summarization explicit -- `Result` pipelines should support branch-local transforms, fallible chaining, recovery, and inspection taps without requiring repetitive nested `match` scaffolding -- ownership and generated-runtime ergonomics should improve structurally, not through one-off `.clone()` or `.as_ref()` patches -- standard-library filesystem workflows should distinguish ordinary path/file operations from temporary resource acquisition and cleanup -- regular-expression workflows should use one safe stdlib vocabulary for compiled patterns, captures, splitting, and replacement instead of pushing ordinary text processing through Rust interop -- application and library logging should use one structured stdlib vocabulary instead of pushing ordinary Incan code through Rust logging interop -- dynamic JSON payloads should have one explicit stdlib value type instead of ad hoc dictionaries or schema-shaped models for data whose shape is intentionally open +- **Language**: Numeric widths, fixed-scale decimal annotations, `loop:` expressions, `if let` / `while let`, union narrowing, value enums, enum methods, computed properties, decorators, aliases, partial callables, protocol hooks, variadics, generators, and pattern alternation make the Python-shaped surface more expressive while staying statically checked. +- **Stdlib**: Collections, `OrdinalMap`, graphs, JSON values, regex, datetime, logging, encoding, hashing, compression, filesystem, I/O, temporary files, UUIDs, iterator adapters, and `Result` helpers move ordinary application needs out of ad hoc Rust interop. +- **Interop**: Rust crate imports, `rusttype`, trait adoption, associated types, derived Rust metadata, metadata-backed call boundaries, and generated Rust retention now cooperate better with real Rust crates and protobuf-style APIs. +- **Tooling**: `incan test`, `incan fmt`, `incan lock`, lifecycle commands, doctor diagnostics, checked API metadata, LSP metadata, and generated Rust audits are more deterministic and CI-friendly. +- **Architecture**: More behavior is registry- and metadata-driven, and generated Rust relies less on scattered special cases. ## Migrating from 0.2 -There are no required source migrations for ordinary `int` and `float` code. Those spellings remain valid and keep their `i64` / `f64` representations. - -Numeric annotations can now be more specific when the representation matters. Code that used a project-local bare type name `decimal` or `numeric` should rename that type or use the new parameterized forms such as `decimal[12, 2]`; those bare names are now reserved for the numeric type family. Data-shaped aliases such as `bigint`, `hugeint`, `integer`, `smallint`, `real`, and `double` canonicalize to exact Incan types rather than introducing new nominal types. - -`loop:` and `break ` are additive control-flow features; existing `while True:` code remains valid. - -Projects that gate on `incan fmt --check` should expect one-time vertical-spacing diffs when adopting a formatter that implements RFC 053. Those diffs are intentional: top-level `def` / `model` / `type`-like declarations get exactly two blank lines around them, following body-bearing members inside type bodies get exactly one blank line, and other same-scope transitions stay in the zero-or-one bucket. - -`if let` and `while let` are additive. Existing `match` code keeps working unchanged; the new forms are available when a single successful pattern matters and the non-match path should do nothing. - -## Major additions - -### RFC 101 `std.collections.OrdinalMap` - -Incan now has `std.collections.OrdinalMap[K]` for immutable deterministic lookup from a stable key domain to integer ordinals. - -```incan -from std.collections import OrdinalMap - -columns = OrdinalMap.from_keys(["order_id", "customer_id", "status", "amount"])? - -assert columns.require("status")? == 2 -assert columns.get("missing") == None -``` - -`OrdinalMap` is for schemas, catalogs, generated metadata, dictionary-encoded scalar domains, and cached lookup tables whose bytes must be reproducible. It is not a mutable `dict` replacement. Keys implement `OrdinalKey`, which supplies deterministic canonical bytes and a stable encoding identifier for serialization. The supported scalar surface includes `str`, `bytes`, `bool`, integers, fixed-precision decimals, UUID values, date/time values, stable value enums, and user-defined adopters. Floating-point keys remain outside the contract for now. - -Safe lookup is exact through `get`, `require`, membership, indexing, and batch lookup. Unchecked lookup is explicit and non-default for callers that have already proven key presence. `from_keys` rejects duplicate keys, and `from_pairs` rejects duplicate keys, negative ordinals, and duplicate ordinals. - -Serialization is deterministic and uses the `INCAN_ORDMAP` container. The payload records format metadata, the key encoding identifier, exact-verification data, and compact ordinal cells selected from the maximum ordinal (`u8`, `u16`, `u32`, or `u64`), while public lookup returns ordinary `int`. - -See also: [`std.collections`](../language/reference/stdlib/collections.md), [Choosing collection types](../language/how-to/choosing_collections.md), [Why `OrdinalMap` exists](../language/explanation/ordinal_map.md), [RFC 101](../RFCs/closed/implemented/101_std_collections_ordinal_map.md). - -### RFC 051 `std.json.JsonValue` - -Incan now has `std.json.JsonValue` for dynamic JSON payloads whose complete shape is not known at compile time. `JsonValue` complements typed `@derive(json)` models: keep stable fields typed and use `JsonValue` for open, exploratory, or mixed-shape fields. - -```incan -from std.serde import json -from std.json import JsonValue - -@derive(json) -model Envelope: - status: int - data: JsonValue -``` - -The surface includes parsing, compact and pretty serialization, constructors for every JSON kind, `JsonKind` inspection, typed extraction helpers, object and array mutation helpers, JSON Pointer traversal, deterministic display/debug behavior, and JSON-specific errors. Direct indexing is checked and optional: `value["key"]` and `value[0]` return `Option[JsonValue]`, preserving the distinction between a missing key and a present JSON null. - -JSON number parsing follows the same JSON-compatible lexical contract exposed by shared `std.math.is_int_like(value: str)` and `std.math.is_float_like(value: str)` helpers. Integer-like JSON numbers map to Incan `int`; fractional or exponent forms map to Incan `float`. - -See also: [`std.json`](../language/reference/stdlib/json.md), [`std.math`](../language/reference/stdlib/math.md), [Derives: Serialization](../language/reference/derives/serialization.md), [RFC 051]. - -### RFC 072 `std.logging` - -Incan now has a `std.logging` module for ordinary structured logging. Code can emit through the ambient `log` surface for the current module's default logger, acquire explicit named loggers with `get_logger(...)`, and attach structured primitive fields or `std.telemetry.core.TelemetryValue` fields at each call site. - -```incan -from std.logging import Level, basic_config - -def main() -> None: - basic_config(level=Level.INFO, target="stdout") - log.info("started", fields={"component": "worker"}) -``` - -Logger values, validated `LoggerName` and `OutputTarget` values, source-level configuration, level filtering, bound context, human rendering, and JSON rendering are implemented in Incan stdlib source. Log records use the `std.telemetry.core` data model and OpenTelemetry log data model aliases for JSON output; `Level.WARN` and `Level.FATAL` are canonical, with `WARNING` and `CRITICAL` as enum variant aliases. The module uses `std.datetime` for timestamps and ordinary `rust::std::io` imports for stdout/stderr delivery without adding a logging-specific Rust backing module. Project defaults, environment overrides, CLI logging flags, and colorized terminal policy remain future host-boundary work. - -See also: [`std.logging`](../language/reference/stdlib/logging.md), [Logging](../language/how-to/logging.md), [RFC 072]. - -### RFC 059 `std.regex` - -Incan now has a `std.regex` module for compiled, reusable regular expressions over `str`. - -```incan -from std.regex import Regex, RegexError - -def main() -> Result[None, RegexError]: - release = Regex("^v(?P\\d+)\\.(?P\\d+)$")? - caps = release.full_match("v0.3") - - match caps: - Some(version) => - println(version.group("major").unwrap_or("")) - None => - println("not a release tag") - - return Ok(None) -``` - -The stdlib surface is intentionally a safe-default engine contract, aligned with the predictable Rust-regex/RE2-style family rather than a fully backtracking Python/PCRE-style engine. It supports ordinary literals, character classes, quantifiers, alternation, grouping, anchors, named captures, indexed captures, Unicode-aware matching, inline flags, and constructor flags such as `ignore_case`, `multiline`, `dotall`, and `verbose`. Lookaround and pattern backreferences are outside the `std.regex` contract. - -`Match` exposes matched text and spans. `Captures` exposes group `0` for the full match, indexed and named group lookup, capture spans, `groups()`, and `groupdict()`. Unmatched optional capture groups remain explicit `None` values instead of silently becoming empty strings. Split APIs return iterators, and replacement supports first/all/limited replacement with replacement-string interpolation (`$1`, `${name}`) or callable replacements that receive `Captures`. - -See also: [`std.regex`](../language/reference/stdlib/regex.md), [Regular expressions](../language/how-to/regular_expressions.md), [Strings and bytes](../language/reference/strings.md), [Callable objects](../language/reference/stdlib_traits/callable.md), [RFC 059](../RFCs/closed/implemented/059_std_regex.md). - -### RFC 009 numeric type system - -Incan now has a registry-backed numeric type system instead of only the broad `int` and `float` spellings. Use `int` and `float` for ordinary Incan-owned logic, then opt into exact widths when the number crosses a contract boundary. - -```incan -attempts: int = 3 -timeout_seconds: float = 2.5 - -packet_version: u8 = 1 -message_count: u32 = 4096 -rust_code: i32 = 200 -``` - -The new canonical integer surface covers `i8`, `i16`, `i32`, `i64`, `i128`, `u8`, `u16`, `u32`, `u64`, `u128`, `isize`, and `usize`. Binary floats now include `f32` and `f64`. `int` remains the ergonomic signed integer spelling and canonicalizes to `i64`; `float` remains the ergonomic binary float spelling and canonicalizes to `f64`. - -Data and analytics vocabulary is also recognized where it maps cleanly to an exact type: - -```incan -model WarehouseRow: - id: bigint - fingerprint: hugeint - category_id: integer - priority: smallint - score: double -``` - -Aliases do not create separate runtime types. `integer` is `i32`, `bigint` is `i64`, `hugeint` is `i128`, `real` is `f32`, and `double` is `f64`. - -Fixed-scale decimal annotations are now accepted with explicit precision and scale: - -```incan -unit_price: decimal[12, 2] = 19.99d -tax_rate: numeric[6, 4] = 0.0825d -``` - -The compiler validates decimal precision, scale, and literal fit. Decimal values lower through the toolchain-owned `Decimal128` representation, so they are useful for typed boundaries, literal validation, formatting, generated Rust, and display. General decimal arithmetic is not yet part of the language contract. - -Numeric conversion is intentionally explicit when values may be lost. Lossless widening is accepted, including at Rust interop boundaries, but narrowing and sign-changing conversions require a policy: - -```incan -small: i8 = 120 -wide: int = small.resize() - -incoming: int = 240 -maybe_small: Option[i8] = incoming.try_resize() -wrapped: i8 = incoming.wrapping_resize() -capped: i8 = incoming.saturating_resize() -``` - -The practical rule is simple: write ordinary business logic with `int` and `float`, match exact widths at external boundaries, use schema-shaped aliases when they make data models read like their source schema, and choose a resize policy before narrowing. - -See also: [Numeric semantics](../language/reference/numeric_semantics.md), [Choosing numeric types](../language/how-to/choosing_numeric_types.md), [Why numeric types work this way](../language/explanation/numeric_types.md), [Rust interop](../language/how-to/rust_interop.md), [RFC 009]. - -### RFC 016 loop expressions and `break ` - -Incan now has an explicit infinite-loop construct: - -- `loop:` for intentional infinite loops in statement position -- `break ` to complete a `loop:` expression with a result -- ordinary `break` and `continue` continuing to work for `for`, `while`, and statement-form `loop:` - -This makes "search until found", retry loops, and similar control-flow patterns expression-oriented without forcing a mutable accumulator outside the loop. - -See also: [Control Flow](../language/explanation/control_flow.md), [Book chapter 4](../language/tutorials/book/04_control_flow.md), [RFC 016]. - -### RFC 053 formatter vertical-spacing contract - -`incan fmt` now follows RFC 053's three-bucket vertical-spacing model: - -- **Exactly two blank lines** around top-level `def`, `class`, `model`, `trait`, `enum`, `type`, `newtype`, and `rusttype` declarations -- **Exactly one blank line** before a following body-bearing member inside a type body -- **At most one blank line** everywhere else, including import runs, adjacent constants/statics, ordinary statement blocks, and transitions involving module docstrings when no top-level spaced declaration is involved - -The formatter also normalizes docstring payload indentation while collapsing actual docstring blank-line runs to one blank line, keeps abstract trait methods tight until a following default/body-bearing method, treats stand-alone comments as leading or trailing bundles even when their target statement wraps, preserves a single authored blank line between statement groups after nested suites, keeps short single-statement `match` arms inline, normalizes blank lines after suite headers and match-arm arrows, strips trailing blank lines at EOF, and allows two consecutive blank lines only at root level. - -Long call-like expressions and signatures now participate in formatter wrapping: overflowing constructor calls, ordinary calls, function signatures, and method signatures are rewritten across multiple lines and respect the existing trailing-comma setting. - -The same spacing contract applies through the CLI and the library formatter API. `FormatConfig` still controls ordinary formatting options such as indentation and line length, but vertical-spacing buckets and comment placement are not configurable. - -See also: [Incan Code Style Guide](../language/reference/code_style.md), [Formatting with `incan fmt`](../tooling/how-to/formatting.md), [RFC 053]. - -### RFC 049 `if let` and `while let` control flow - -Incan now supports `if let PATTERN = VALUE:` and `while let PATTERN = VALUE:` in statement position. - -Use `if let` when exactly one successful pattern matters and the non-match path should do nothing. Use `while let` when a loop should keep iterating only while one pattern keeps matching. Both forms reuse the same pattern semantics as `match`, keep bindings scoped to the successful branch or loop body, and leave full `match` as the right tool when multiple arms or explicit non-match behavior matter. - -In v1, `if let` remains intentionally single-arm only and rejects `else` / `elif`. When the non-match path is semantically important, keep using `match`. - -### RFC 029 union types and narrowing - -Incan now accepts anonymous closed union annotations with both canonical `Union[A, B, ...]` and `A | B` syntax. Concrete member values can flow into union-typed returns and bindings, source unions can flow into wider target unions, and unions containing `None` canonicalize through `Option[...]`. - -Union values must be narrowed before using member-specific methods. The compiler now supports `isinstance(value, T)` narrowing for true branches, else branches, wider unions, chained `elif` branches, and `Option[Union[...]]` values; `is None` / `is not None` narrowing for `Option[...]`-canonicalized unions; and `match` type patterns such as `int(n)` and `str(s)`, with exhaustiveness checking for ordinary unions. - -### RFC 032 value enums - -Incan now supports value enums with `str` and `int` backing values: - -```incan -enum Environment(str): - Development = "development" - Production = "production" - -enum HttpStatus(int): - Ok = 200 - NotFound = 404 -``` - -Value enum variants remain enum values. They are not subtypes of the backing primitive and do not compare equal to raw primitive values. The generated `value()` helper returns the canonical raw value, while `from_value(...)` returns `Option[Enum]` for explicit handling of unknown external values. Generated display, string parsing, and serde hooks use the raw representation for value enums. - -### RFC 050 enum methods and trait adoption - -Enums can now declare methods and associated functions inside the enum body, after their variants. Use this when behavior belongs to the closed set itself, such as `Direction.opposite()` or `BuildState.describe()`, instead of pushing enum-owned logic into detached helper functions. - -Enums can also adopt traits with `with TraitName`, using the same trait adoption surface as models and classes. This makes enum-backed protocols reusable without special-case compiler support while keeping existing enum semantics additive and variant sets closed. - -### RFC 043 Rust trait adoption from Incan - -Incan can now start expressing Rust trait implementations from Incan source on newtype and rusttype declarations. Authors use the existing `with TraitName` adoption clause instead of writing Rust-shaped `impl Trait for Type` source syntax. - -```incan -from rust::std::fmt import Debug, Display, Formatter, FmtError - -type UserId = rusttype i64 with Display, Debug: - def fmt(self, f: Formatter) for Display -> Result[None, FmtError]: - return f.write_str(f"user_{self.0}") - - def fmt(self, f: Formatter) for Debug -> Result[None, FmtError]: - return f.write_str(f"UserId({self.0})") -``` - -The method-level `for TraitName` target is only needed when more than one adopted trait could claim the same method name. Associated type declarations also use Incan syntax, for example `type Output for Add[int] = UserId`. - -The compiler also validates imported Rust trait metadata for associated type requirements, rejects statically knowable Rust coherence violations, forwards supported `@rust.derive(...)` attributes to generated Rust items, accepts metadata-proven body-less `rusttype` forwarding without emitting invalid alias impls, and explicitly gates RFC 039 `Awaitable[T]` to Rust `Future` bridging until safe pin-projection and output-mapping metadata exist. - -### RFC 028 trait-based operator overloading - -`std.traits.ops` now exposes the RFC 028 operator protocol vocabulary for custom types. The basic arithmetic traits are joined by floor division, power, shifts, bitwise operators, matrix multiplication, pipe operators, unary inversion, indexing hooks, and explicit in-place compound-assignment traits for the supported `+=`, `-=`, `*=`, `/=`, `//=`, `%=`, `@=`, `&=`, `|=`, `^=`, `<<=`, and `>>=` syntax. - -Operator traits are nominal capability contracts for generic code. Dunder methods such as `__add__`, `__floordiv__`, `__rshift__`, `__matmul__`, and `__getitem__` are the implementation hooks that satisfy those contracts. Compound assignment first checks for the explicit in-place hook such as `__iadd__`; if none exists, it falls back to ordinary binary operator assignment. - -### RFC 068 protocol hooks for core syntax - -Core syntax now resolves through static protocol hooks for user-defined types. Custom types can participate in truthiness, `len(...)`, membership, iteration, indexing, indexed assignment, and callable-object invocation by defining compatible hooks such as `__bool__`, `__len__`, `__contains__`, `__iter__`, `__next__`, `__getitem__`, `__setitem__`, and `__call__`. - -The hook surface remains statically checked. Dunder methods are implementation hooks, while traits such as `Bool`, `Len`, `Contains`, `Iterable`, `Iterator`, `Index`, `IndexMut`, and fixed-arity callable traits are the nominal capability vocabulary for explicit adoption, bounds, docs, and diagnostics. `Option` and `Result` remain intentionally non-truthy; use explicit pattern checks for optionality and fallibility. - -### RFC 058 `std.datetime` - -`std.datetime` now provides temporal value types for runtime timing, civil dates and times, fixed UTC offsets, and interval arithmetic. The module includes `Duration`, `Instant`, `SystemTime`, `Date`, `Time`, `DateTime`, `FixedOffset`, `DateTimeOffset`, `TimeDelta`, `YearMonthInterval`, and `DateTimeInterval`. UTC host-clock civil factories are available as `Date.utc_today()` and `DateTime.utc_now()`; timezone-aware local `today` / `now` semantics remain package-level functionality. - -The runtime timing layer uses Rust `std::time` through ordinary Incan Rust interop for `Duration`, `Instant`, and `SystemTime`. The civil calendar layer remains source-defined Incan, with ISO-style parsing/formatting, Python-shaped `strftime` / `strptime`, nanosecond `%f`, fixed-offset `%z` / `%:z`, comparison, date arithmetic, and interval normalization. Named timezone rule lookup is intentionally left to separately versioned packages. - -See also: [Dates and times](../language/tutorials/dates_and_times.md), [Dates and times how-to](../language/how-to/dates_and_times.md), [std.datetime reference](../language/reference/stdlib/datetime.md), [RFC 058]. - -### RFC 088 iterator adapter surface - -Iterator values now expose the RFC 088 adapter surface for lazy pipelines: `.map()`, `.filter()`, `.flat_map()`, `.take()`, `.skip()`, `.chain()`, `.enumerate()`, `.zip()`, `.take_while()`, `.skip_while()`, and `.batch()`. - -Terminal consumers make realization or summarization explicit with `.collect()`, `.count()`, `.reduce()`, `.fold()`, `.any()`, `.all()`, `.find()`, `.for_each()`, and `.sum()`. Terminal methods consume the iterator, so code that needs to keep the iterator for another pass should call `.clone()` before the terminal operation. `.sum()` supports `int`, `float`, and newtypes over summable underlying types; checked newtypes go through their normal construction validation. For now, `.collect()` returns `list[T]`; it does not accept a target collection type. - -### RFC 070 Result combinators - -`Result[T, E]` now exposes the standard Rust-shaped composition surface: `.map()`, `.map_err()`, `.and_then()`, `.or_else()`, `.inspect()`, and `.inspect_err()`. - -Use `.map()` to transform an `Ok(T)` value, `.map_err()` to transform an `Err(E)` value, `.and_then()` to chain a `Result`-returning success continuation, and `.or_else()` to recover from or remap a failure with a `Result`-returning error continuation. Use `.inspect()` and `.inspect_err()` for logging, metrics, and debugging taps that observe one branch and return the original `Result` unchanged; the compiler passes the observed payload through an implicit borrow so the original branch value remains available to the pipeline. - -Callable arguments are documented with `Callable[...]` vocabulary: for example, `.map()` accepts `Callable[T, U]`, `.map_err()` accepts `Callable[E, F]`, `.and_then()` accepts `Callable[T, Result[U, E]]`, `.or_else()` accepts `Callable[E, Result[T, F]]`, `.inspect()` accepts `Callable[T, None]`, and `.inspect_err()` accepts `Callable[E, None]`. Incan intentionally keeps the Rust method names and does not add Python-style aliases. - -See also: [Fallible and infallible paths](../language/tutorials/fallible_and_infallible_paths.md), [Error handling](../language/explanation/error_handling.md), [Callable objects](../language/reference/stdlib_traits/callable.md), [RFC 070](../RFCs/closed/implemented/070_result_combinators_for_result_types.md). - -### Duckborrowing and ownership-aware codegen - -The backend now routes more generated-Rust ownership decisions through a centralized "duckborrowing" planner. This strengthens the compiler's ability to choose moves, borrows, mutable borrows, owned string materialization, `.into()`, and necessary `.clone()` calls at typed use sites instead of relying on scattered emitter-local fixes. - -Practically, this reduces the need for users and library authors to add ownership-shaping workarounds such as `.clone()`, `.as_ref()`, `str(...)`, or `.into()` in ordinary Incan code just to satisfy generated Rust. The planner now covers more call arguments, collection and tuple literals, assignments, returns, match scrutinees, string lookup probes, tuple unpacking, and Rust interop boundaries. - -### RFC 057 targeted generated-Rust lint suppression - -Incan now supports `@rust.allow(...)` for narrow suppression of specific rustc or Clippy lints on the generated Rust item for one declaration. This is Rust-emission metadata for unavoidable generated-Rust warnings, not arbitrary Rust attribute injection and not project-wide lint configuration. - -The decorator is item-only and covers functions, methods, models, classes, enums, and newtypes. Module-level `rust.allow(...)` directives are not supported. The compiler also rejects obvious broad lint groups including `warnings`, `unused`, `clippy::all`, `clippy::pedantic`, `clippy::nursery`, `clippy::restriction`, and `clippy::cargo`. - -### RFC 010 temporary files and directories - -`std.tempfile` now owns temporary resource creation while `std.fs` owns ordinary path and file operations. Use `NamedTemporaryFile.try_new()` for a named temporary file and `TemporaryDirectory.try_new()` for a temporary directory tree. Use `try_new_with(prefix, suffix, dir)` when the caller needs configured naming or a specific parent directory. Both return `Result[..., IoError]` because they reserve host filesystem entries. - -Temporary wrappers delete their live paths when dropped. Call `path()` to work with the location through `std.fs.Path`, and call `persist()` when the output should survive the wrapper leaving scope. `SpooledTemporaryFile(max_size=...)` starts in memory, rolls over to a named temporary file when it grows beyond `max_size` or `rollover()` is called, and exposes `path()` / `persist()` after rollover. Pathless `TemporaryFile` remains deferred until its cross-platform file-handle contract is settled. - -### Deterministic `incan.lock` files - -`incan.lock` no longer records the wall-clock time when the file was generated. The lock file now contains only reproducibility-relevant inputs such as the Incan lock format version, compiler version, dependency fingerprint, Cargo feature selection, and embedded `Cargo.lock` payload. Re-running `incan lock` against unchanged inputs should leave the file byte-for-byte unchanged, reducing noisy VCS churn in projects that commit lock files. - -Older lock files that still contain the previous `generated = "..."` metadata continue to load, but newly written lock files omit it. - -Default `incan build` and `incan test` also avoid rewriting an existing stale `incan.lock` during routine verification. When the fingerprint differs outside `--locked` / `--frozen`, the command warns and reuses the embedded `Cargo.lock` payload; run `incan lock` when you intentionally want to refresh the committed lock file. - -### RFC 018 testing language primitives - -The language `assert` statement is now an always-on language primitive. Use `assert expr[, msg]` directly for ordinary checks; import `std.testing` when you need helper functions such as `assert_eq`, `assert_is_some`, `fail`, fixtures, parametrization, or marker decorators. - -Testing decorators remain `std.testing` APIs rather than magic global names. `@skip`, `@xfail`, `@slow`, `@fixture`, and `@parametrize` must resolve through `std.testing`, and runner/discovery behavior remains part of RFC 019 rather than RFC 018. - -`assert call() raises ErrorType[, msg]` and compiler-recognized `std.testing.assert_raises[E](block, msg?)` calls now share runtime panic-payload matching. Error payloads match either the exact kind name, such as `ValueError`, or the canonical `Kind: message` prefix. - -### RFC 019 first-class test runner - -`incan test` now has a full runner contract instead of a thin compile-and-run path. Tests can live in conventional `tests/test_*.incn` / `tests/*_test.incn` files or inline `module tests:` blocks inside production source files. Inline tests can exercise same-file private helpers, and production `incan build` / `incan run` output still strips test-only declarations and imports. - -Discovery now supports both `def test_*()` and explicit `@test`, and every collected case has a stable id. Those ids are used consistently by `--list`, `-k`, parametrized test names, JSON Lines output, JUnit XML, and duration reporting. That makes CI logs, reruns, and editor integrations much less dependent on incidental generated-Rust names. - -The runner also picks up the testing ergonomics people expect from a modern test framework: - -- `@fixture` dependency injection, including function, module, and session scopes -- `yield` fixture teardown that can reference setup locals and fixture parameters -- `tests/**/conftest.incn` inheritance for conventional test suites -- built-in `tmp_path`, `tmp_workdir`, and `env` fixtures -- `@parametrize(...)` with stable ids, cartesian products, and `param_case(...)` for per-case ids or marks -- marker selection with `-m`, strict marker registries via `TEST_MARKERS`, and default marks via `TEST_MARKS` -- `@skip`, `@xfail`, `@slow`, `@mark`, `@timeout`, `@resource`, and `@serial` -- collection-time `@skipif` / `@xfailif` probes using `platform()` and `feature("name")` - -Parallel execution is now runner-level and resource-aware. `--jobs N` runs generated worker batches concurrently while each batch still executes through single-threaded libtest. `@resource("name")` prevents overlapping batches that share a resource key, and `@serial` forces exclusive execution. Session fixtures are cached once per worker batch, so `--jobs 1` can reuse a session fixture across compatible collected files, while higher job counts keep one session instance per worker. - -Reporting is also CI-ready. `--format json` emits JSON Lines records with `schema_version: "incan.test.v1"`, `--junit ` writes JUnit XML, `--durations N` reports slow tests, `--shuffle --seed N` gives reproducible randomized order, `--run-xfail` treats expected failures as ordinary tests, and `--nocapture` opts into printing child output for passing tests. Timeout-killed workers can still bypass teardown, so timeout teardown remains best-effort. - -See also: [Testing in Incan](../language/how-to/testing_stdlib.md), [Tooling: Testing](../tooling/how-to/testing.md), [std.testing reference](../language/reference/stdlib/testing.md), [RFC 018], [RFC 019]. - -### RFC 004 async fixtures - -`@fixture` now works on `async def` fixture functions. Async fixtures use the same decorator as synchronous fixtures, use `yield` exactly once, await setup before dependents run, and await teardown after `yield` before the runner continues through reverse dependency teardown. - -Mixed sync and async fixture graphs compose under function, module, and session scopes. Parametrized tests still expand before fixture resolution, so function-scoped async fixtures run per expanded case while module and session fixtures reuse values according to their existing scope boundaries. - -Timeout behavior stays runner-level. `incan test --timeout` and `@timeout(...)` from `std.testing` apply to generated test batches; there is no per-fixture timeout configuration. The runner awaits async fixture teardown after ordinary failures and panics while the worker remains alive, but timeout-enforced worker termination can still bypass remaining cleanup. - -See also: [Testing in Incan](../language/how-to/testing_stdlib.md), [Tooling: Testing](../tooling/how-to/testing.md), [std.testing reference](../language/reference/stdlib/testing.md), [RFC 004](../RFCs/closed/implemented/004_async_fixtures.md). - -### RFC 047 `std.graph` - -`std.graph` now provides a small graph standard-library surface for in-memory dependency, plan, pipeline, and workflow graphs. `DiGraph[T]`, `Dag[T]`, and `MultiDiGraph[T]` are constructed directly with `DiGraph[T]()`, `Dag[T]()`, and `MultiDiGraph[T]()`. `DiGraph[T]` stores typed node payloads behind stable `NodeId` values, `Dag[T]` keeps acyclicity as a data invariant, and `MultiDiGraph[T]` supports parallel directed edges with stable `EdgeId` values. The API exposes adjacency queries, roots, sinks, node and edge removal, breadth-first traversal, depth-first preorder traversal, and cycle-aware topological ordering. - -Graphs are ordinary values rather than ambient singletons. Store them on models, pass them to functions, and keep separate graph instances for separate requests, tests, or pipeline plans. - -The v1 surface is intentionally not a graph database, persistence layer, query language, or distributed graph engine. Future graph expansion remains stdlib design work rather than ad hoc growth. - -See also: [std.graph reference](../language/reference/stdlib/graph.md), [Working with graphs](../language/how-to/working_with_graphs.md), [Why `std.graph` exists](../language/explanation/graph_model.md), [RFC 047]. - -### RFC 030 `std.collections` - -`std.collections` now provides the standard-library namespace for specialized container types that are semantically distinct from builtin `list`, `dict`, and `set`. The module covers `Deque[T]`, `Counter[T]`, `DefaultDict[K, V]`, `OrderedDict[K, V]`, `OrderedSet[T]`, `SortedDict[K, V]`, `SortedSet[T]`, `ChainMap[K, V]`, and `PriorityQueue[T]`. - -These are ordinary Incan stdlib types. They import through `from std.collections import ...`, resolve through the standard stdlib registry and source loader, and do not use Rust-backed stdlib dispatch. - -Use builtin collections for ordinary values. Use `std.collections` when the collection behavior is the point: double-ended queue operations, counted membership, missing-key defaulting, insertion-order stability, sorted traversal, layered configuration, or priority scheduling. - -See also: [std.collections reference](../language/reference/stdlib/collections.md), [Choosing collection types](../language/how-to/choosing_collections.md), [RFC 030]. - -### RFC 064 `std.encoding` - -`std.encoding` now provides the standard-library namespace for binary-text representation transforms. The module covers explicit `hex`, `base32`, `base64`, `base85`, `base58`, and `bech32` families with strict decoding by default, separately named lenient decoders where interoperability needs them, and canonical `encode` / `decode` helpers that work with in-memory values, `std.io.BytesIO`, and finite `std.fs.Path` sources or sinks. - -These are ordinary Incan stdlib APIs. The public surface is source-owned under `std.encoding`, and examples compose with byte/string values and stream types instead of exposing Rust-backed public shells. - -See also: [std.encoding reference](../language/reference/stdlib/encoding.md), [Binary-text encoding](../language/how-to/binary_text_encoding.md), [RFC 064]. - -### RFC 061 `std.compression` - -`std.compression` now provides codec-based compression and decompression for `gzip`, `zlib`, raw `deflate`, `zstd`, `bz2`, XZ/LZMA-family streams, framed `snappy`, and advanced raw Snappy interop. - -```incan -from std.compression import gzip, decompress_auto, Codec - -compressed = gzip.compress(payload)? -codec, plain = decompress_auto(compressed, [Codec.Gzip])? -``` - -Every required codec exposes one-shot byte helpers and stream helpers over `std.io.BytesIO` and `std.fs.File`. Autodetection is decompression-only and opt-in through `decompress_auto(...)` or `decompress_auto_stream(...)`; it uses framing signatures, respects the caller's `allowed` filter exactly, and never guesses from file extensions or MIME types. The public error boundary is `CompressionError`, with stable categories for invalid data, truncated input, unsupported codecs/options, invalid levels, invalid chunk sizes, I/O failures, and backend failures. - -The implementation is dogfooded in Incan stdlib source using ordinary Rust crate imports for the codec boundary rather than new `@rust.extern` function or type implementation surfaces. The generated-project regression fixture covers one-shot, BytesIO stream, file stream, autodetection, option error, and chunk-size error behavior. - -See also: [compression how-to](../language/how-to/compression.md), [std.compression reference](../language/reference/stdlib/compression.md), [RFC 061]. - -## Detailed inventory - -The sections above are the release story. The feature inventory below is separate from stabilization and bugfixes so new surface area can be scanned independently from release hardening. - -### Control-flow features - -- **Language/Compiler**: Incan now supports `loop:` as an explicit infinite-loop construct in both statement and expression position, with `break ` completing the surrounding `loop:` expression and plain `break` remaining valid for `for`, `while`, and statement-form `loop:` (#327, RFC 016). - -### Compiler and code-generation features - -- **Compiler/Codegen**: Generic class type-owned factories can now construct and return `Self` from `@classmethod` and `@staticmethod` bodies. The compiler binds `cls(...)` inside classmethods, lowers `Type[T].factory(...)` as a Rust associated call instead of a value-position index expression, and the LSP surfaces `cls` hover/completion inside classmethod bodies (#388). -- **Language/Compiler/Runtime**: RFC 009 implements the numeric type registry with exact-width signed and unsigned integers, pointer-sized integers, `f32`/`f64`, analytics/database aliases including `bigint` and `hugeint`, parameterized `decimal[p, s]` / `numeric[p, s]` literals, lossless numeric widening, explicit integer resize helpers, and exact/lossless Rust interop numeric adaptation (#325, RFC 009). -- **Compiler/Codegen**: RFC 032 value enums now lower their raw-value metadata into IR and generate `value()`, `from_value(...)`, display, string parsing, and serde implementations that use the canonical raw representation while keeping `message()` variant-name based (#317, RFC 032). -- **Compiler/Codegen**: RFC 025 now preserves distinct same-generic-trait instantiations on model, class, and enum declarations, allows trait-backed same-name methods, resolves same-family calls by argument types or explicit expected return type, enforces `T with Trait[F]` generic bound arguments, and emits separate Rust trait impls (#150, RFC 025). -- **Language/Compiler/Codegen**: RFC 043 starts Rust trait implementation authoring from Incan source on newtype and rusttype declarations, using `with TraitName` for adoption, method-level `for TraitName` for same-name method collisions, associated type declarations such as `type Output for Add[int] = UserId`, checked metadata preservation, and generated Rust trait impl emission (#200, RFC 043). -- **Language/Stdlib/Codegen**: RFC 024 adds module-level derive protocols. `std.serde.json` now declares `__derives__ = [Serialize, Deserialize]`, `@derive(json)` adopts both JSON traits, module-qualified bounds such as `T with json.Serialize` typecheck and lower, generated Rust forwards the corresponding serde derives and trait impls, and user-authored derivable modules are covered for both additional Serde-backed formats and pure Incan derivable traits (#148, RFC 024). -- **Language/Compiler**: RFC 017 implements validated newtypes with constrained primitive type syntax such as `int[ge=0]`, canonical `from_underlying` hooks returning `Result[..., ValidationError]`, implicit checked coercion at function arguments, typed initializers, and model/class field construction, fail-fast validation for ordinary coercion sites, aggregated model/class field errors, and `@no_implicit_coercion` opt-outs without adding ambient primitive parsing (#75, RFC 017). -- **Compiler/Codegen**: `@rust.allow(...)` now emits targeted Rust `#[allow(...)]` metadata for specific generated Rust items when an Incan declaration intentionally accepts a narrow rustc or Clippy lint. The decorator supports functions, methods, models, classes, enums, and newtypes, rejects module-level directives, and blocks broad lint groups such as `warnings`, `unused`, and the common Clippy group lints (#337, RFC 057). -- **Language/Compiler**: Enums can now declare methods and associated functions after their variants and adopt traits with `with`, bringing enum-owned behavior and trait protocol participation into parity with models and classes (#334, RFC 050). -- **Language/Compiler**: `match` arms and `if let` patterns now support pattern alternation with `|`, so alternatives such as `Status.Pending | Status.Retrying` can share one branch while still requiring identical binding names and binding types across alternatives (#387, RFC 071). -- **Language/Compiler**: Core syntax now uses statically checked protocol hooks for user-defined truthiness, length, membership, iteration, indexing, indexed assignment, and callable-object invocation (#86, RFC 068). -- **Language/Compiler**: RFC 046 adds computed properties with `property name -> Type:` declarations on models, classes, and trait implementations. Reads use field-like `obj.name` syntax, each read executes the property body, trait properties act as abstract requirements, and property/member name collisions and `obj.name()` calls are diagnosed (#203, RFC 046). -- **Language/Stdlib**: RFC 088 standardizes lazy iterator adapters and terminal consumers on iterator values, including `.batch()` with final partial-batch preservation, `.flat_map()` over `Iterable[U]` callback results, terminal consumption semantics, and `.collect()` returning `list[T]` (#127, RFC 088). -- **Language/Stdlib**: RFC 070 adds Rust-shaped `Result[T, E]` combinators for branch-local transforms, fallible chaining, recovery, and inspection taps: `.map()`, `.map_err()`, `.and_then()`, `.or_else()`, `.inspect()`, and `.inspect_err()` (#386, RFC 070). - -### Tooling and workflow features - -- **Tooling**: RFC 020 completes the Cargo policy contract for generated builds and tests. `incan build`, `incan run`, and `incan test` now accept `--offline`, `--locked`, `--frozen`, explicit `--no-*` environment overrides, Cargo args forwarding, and matching CI environment defaults for restricted-network and reproducible workflows (#38, RFC 020). -- **Tooling**: `incan.lock` files no longer include a volatile generation timestamp. New lock files are deterministic for unchanged dependency inputs, while older lock files with `generated = "..."` metadata remain readable. -- **Tooling**: Default `incan build` and `incan test` now warn and reuse an existing stale `incan.lock` payload instead of rewriting the project lockfile as a side effect of routine verification. `incan lock` remains the explicit refresh command, while `--locked` and `--frozen` keep rejecting stale lockfiles (#446). -- **Tooling**: `incan tools doctor` now includes advisory offline-readiness diagnostics in text and JSON output, reporting Cargo availability, effective Cargo home, cache/config hints, and remediation steps before users rely on RFC 020 offline or frozen policy in restricted environments (#460). -- **Tooling/Editor**: `incan tools doctor` and the VS Code/Cursor **Incan: Doctor** command now report local `incan` / `incan-lsp` path resolution, cargo-bin symlink state, and recovery guidance for stale editor diagnostics or mismatched local binaries (#426). -- **Compiler/Tooling**: RFC 048 checked contract metadata is now compiler-visible through canonical model bundle validation, project materialization, deterministic `incan tools metadata model` emit from projects, bundle JSON, and `.incnlib` artifacts, artifact embedding for publishable bundles, strict checked API docstring validation, `incan tools metadata api` JSON extraction, and LSP hover/emit integration (#205, #438, RFC 048). -- **Compiler/Tooling**: `incan tools metadata api` emits checked public API metadata JSON for an Incan source file or project directory, including public declarations, checked signatures, stable anchors, parsed docstring sections, public import aliases with resolved targets, resolved decorator paths, safe decorator arguments, safe public const values, and model field alias/description metadata (#205, #438). -- **Tooling/Editor**: LSP hover now previews RFC 048 checked API metadata for public declarations and selected public model/class members after successful typechecking, and `workspace/executeCommand` command `incan.metadata.model.emit` emits contract-backed model source or bundle JSON from project, bundle, or artifact metadata (#205). -- **Tooling/Editor**: LSP hover and completion details now surface RFC 032 value-enum metadata. Public value-enum hovers use Incan backing spellings (`str` / `int`), public enum variant hovers show raw values, and local enum/variant completions include backing type and raw-value details (#166, RFC 032). -- **Tooling/Editor**: LSP hover and completion details now include computed property members, showing `property Owner.name -> Type` for model, class, and trait property declarations (#203, RFC 046). -- **Compiler/Tooling**: CLI compilation, LSP dependency collection, and the test runner now share the frontend's canonical source-module resolver for local module paths, logical module identity, stdlib source classification, and source-root fallback behavior (#285). -- **Compiler/Tooling**: RFC 053’s vertical-spacing contract is now reflected in `incan fmt`: top-level `def` / `model` / `type`-like declarations keep two blank lines around them, adjacent constants/statics stay grouped unless they border one of those declarations, trait abstract methods stay tight until a following body-bearing member, docstring indentation is normalized while actual blank-line runs collapse to one blank line, single readability gaps between statement groups survive nested suites, short single-statement `match` arms stay inline, blank lines after suite headers and match-arm arrows are normalized, trailing EOF blank lines are removed, two consecutive blank lines are allowed only at root level, and stand-alone comments attach as leading/trailing bundles even when the formatter wraps the target statement (#336, RFC 053). -- **Compiler/Tooling**: `incan fmt` now wraps overflowing call and constructor argument lists, plus function and method signatures, across multiple lines with trailing commas controlled by the existing formatter setting (#336, #248). -- **Tooling**: Vocab extraction helper tests now reuse the workspace lockfile when resolving helper dependencies, so focused vocab extraction coverage can run in restricted-network environments once local workspace dependencies are present (#211). -- **Tooling/CI**: Downstream Incan projects can now use the repository composite action at `dannys-code-corner/incan/.github/actions/install-incan@` to build the compiler from the pinned repository ref, cache Cargo artifacts, and add the resulting `incan` binary to `PATH` before running project-specific CI commands (#188). -- **Tooling**: Vocab WASM desugarers now get enough fuel to parse, walk, and serialize nested public AST output from real `wasm32-wasip1` companion crates. Regression coverage runs a deeply nested vocab block through `incan run` with a `let` statement whose value contains nested helper-call output, list arguments, action requirements, page interactions, and required-input constraints to guard the desugar boundary reported in #455. -- **Tooling/CI**: Stable Ubuntu, macOS, and MSRV test gates now use sccache-backed nextest slice partitions while preserving the aggregate CI check names, and the release smoke gate uses a dedicated release-profile target cache to reduce duplicated compiler work without dropping broad coverage (#451). -- **Tooling/Test runner**: RFC 019 expands `incan test` with explicit `@test` discovery, stable test ids for `-k` and `--list`, JSON Lines reports with `schema_version: "incan.test.v1"`, JUnit XML output, duration reporting, deterministic shuffle/seed support, `--run-xfail`, conftest inheritance for conventional tests, inline `module tests:` execution, parametrization, fixtures, conditional markers, timeouts, output capture controls, and worker scheduling with `--jobs`, `@resource`, and `@serial` (#77, RFC 019). -- **Tooling/Test runner**: RFC 019 fixture lifecycles now run through worker-batch harnesses, including compatible cross-file session fixture reuse with `--jobs 1`, per-worker session reuse with `--jobs N`, module/session teardown timing, and captured `yield` fixture teardown locals (#77, RFC 019). -- **Tooling/Test runner**: RFC 004 async fixtures now use the existing `@fixture` decorator on `async def`, await setup before dependents run, await post-`yield` teardown, compose with synchronous fixtures under function/module/session scopes, and resolve after parametrized test expansion while keeping timeout policy at the test-batch level (#78, RFC 004). -- **Tooling/Test runner**: Worker batches now fall back to per-file harnesses when multiple source files define colliding top-level Rust item names. Compatible files still batch together for session fixture reuse, while projects with repeated helper/model names avoid generated Rust duplicate-definition failures. -- **Tooling/Test runner**: `incan test` now preheats stale generated Cargo harnesses with `cargo test --no-run`, fingerprints successful preheat state next to each generated harness, and uses a one-writer lock so concurrent CLI/LSP-style runs do not stampede Cargo (#272). -- **Tooling/Test runner**: `incan lock` and implicit first-use lock generation now preheat non-trivial dependency graphs with `cargo test --no-run` into the same debug target domain used by generated test harnesses, then stamp the dependency preheat fingerprint so unchanged relocks stay cheap (#272). -- **Tooling**: Project-aware commands now enforce `[project].requires-incan`, env-level `requires-incan` can narrow named environment workflows, and `incan env show` / `env run --dry-run` report the effective toolchain compatibility before scripts run; RFC 073 matrix expansion remains deferred beyond `0.3` (#401, RFC 073). - -### Language, syntax, and stdlib features - -- **Language/Compiler**: Enum bodies now support same-enum variant aliases such as `WARNING = alias WARN`, letting value enums expose compatibility or readability spellings without creating duplicate raw values or extra runtime variants (#392, RFC 072). -- **Language/Stdlib**: RFC 072 introduces `std.logging` with source-defined `Level`, `Logger`, `LogFormat`, `LogStyle`, `ColorPolicy`, `LogRecord`, `basic_config(...)`, `get_logger(...)`, and the shadowable ambient `log` surface. Logger methods preserve structured primitive and `TelemetryValue` fields, support bound context and child names, infer source-module logger names where metadata exists, and implement filtering plus human/JSON rendering in Incan source. JSON records use `std.telemetry.core` values with OpenTelemetry log data model aliases, `Level.WARN` and `Level.FATAL` are canonical with `WARNING` and `CRITICAL` as aliases, timestamps flow through `std.datetime`, and stdout/stderr delivery uses ordinary `rust::std::io` imports rather than a logging-specific Rust module (#392, RFC 072). -- **Language/Stdlib**: RFC 059 introduces `std.regex` with compiled `Regex` values, `Match` spans, `Captures` groups, safe-default regex semantics, named and indexed capture lookup, explicit `None` for unmatched optional groups, split iterators, first/all/limited replacement, `$1` / `${name}` replacement interpolation, callable replacements, and constructor flags for common modifiers (#294, RFC 059). -- **Language/Stdlib**: `std.graph` adds explicit in-memory graph values with direct `DiGraph[T]()`, `Dag[T]()`, and `MultiDiGraph[T]()` construction, acyclicity-enforcing DAGs, stable `EdgeId` values for parallel multigraph edges, stable `NodeId` values, typed node payloads, node and edge removal, adjacency queries, roots and sinks, BFS/DFS traversal helpers, and cycle-reporting topological order for dependency and plan graphs (#204, RFC 047). -- **Language/Stdlib**: `std.datetime` adds Rust `std::time`-backed `Duration`, `Instant`, and `SystemTime`, plus source-defined Incan civil values for dates, times, naive datetimes, fixed UTC offsets, fixed-offset datetimes, UTC civil clock factories, day/time intervals, year/month intervals, compound datetime intervals, ISO-style parsing/formatting, Python-shaped `strftime` / `strptime` with nanosecond `%f`, deterministic calendar arithmetic, and interval normalization (#292, RFC 058). -- **Language/Stdlib**: `std.collections` adds explicit specialized collection types for double-ended queues, multisets, default-valued maps, ordered maps and sets, sorted maps and sets, layered maps, and priority queues. The namespace is registered as an ordinary source stdlib module with no feature gate, no extra Cargo dependencies, and no Rust-backed stdlib dispatch (#164, RFC 030). -- **Language/Stdlib**: `std.encoding` adds strict-by-default binary-text transform modules for hex, base32, base64, base85, base58, and Bech32/Bech32m, with explicit variant function names, separately named lenient decoders, and source/sink helpers that compose with `std.fs.Path` and `std.io.BytesIO` (#342, RFC 064). -- **Language/Stdlib**: `std.compression` adds codec namespaces for gzip, zlib, raw deflate, zstd, bzip2, XZ/LZMA, framed Snappy, and raw Snappy interop, with source-defined one-shot helpers, stream helpers over `std.fs.File` and `std.io.BytesIO`, explicit decompression autodetection, stable `Codec` and `CompressionError` vocabulary, stdlib-managed generated-project dependencies, and generated-project regression coverage for issue #548 (#339, #548, RFC 061). -- **Language/Compiler**: RFC 029 adds anonymous closed union annotations with canonical `Union[A, B, ...]` and `A | B` syntax. The compiler normalizes duplicates, nested unions, ordering, and `None`-containing unions, accepts member-to-union and union-to-union assignability, lowers ordinary unions to generated closed Rust enums, preserves `None` unions on the existing `Option[...]` path, and supports `isinstance` narrowing for true branches, else branches, wider unions, chained `elif` branches, and `Option[Union[...]]`, plus `is None` / `is not None` narrowing and exhaustive `match` type patterns (#163, RFC 029). -- **Language/Stdlib**: RFC 028 expands `std.traits.ops` into the nominal operator capability vocabulary for custom types, including `FloorDiv`, `Pow`, shifts, bitwise operators, pipe operators, `MatMul`, unary `Not`, `GetItem` / `SetItem`, and explicit in-place compound-assignment traits for `+=`, `-=`, `*=`, `/=`, `//=`, `%=`, `@=`, `&=`, `|=`, `^=`, `<<=`, and `>>=` (#162, RFC 028). -- **Language/Stdlib**: RFC 055 introduces `std.fs` as the path-centric filesystem module: `Path`, `File`, `OpenOptions`, directory entries, metadata, disk usage, structured `IoError`, whole-file byte/text helpers, chunked file handles, traversal, globbing, copy/move, recursive deletion, links, permissions, and explicit durability syncs (#286, RFC 055). -- **Language/Stdlib**: RFC 056 introduces `std.io` for in-memory binary streams with `BytesIO`, `Endian`, cursor helpers, delimiter reads/skips, truncation, buffer extraction, and trait-backed exact-width numeric `read(endian)` / `write(value, endian)` overloads over RFC 009 integer and float types (#291, RFC 056). -- **Language/Compiler**: Incan functions and methods can now declare variadic positional and keyword captures with `*args: T` and `**kwargs: T`, which bind as `List[T]` and `Dict[str, T]` inside the callable. Static call-site unpacking with `f(*xs)` and `f(**kw)` supports rest-aware callees and fixed-parameter callees when the compiler can prove the unpacked shape. Runtime list and dictionary literals now support spread entries with `[*xs]` and `{**kw}`, while invalid destinations such as `[**xs]` and `{*xs}` are rejected with targeted diagnostics (#83, RFC 038). -- **Library authoring**: `incan_vocab` is now versioned as `0.2.0`, marking the first contract bump after the initial 0.1 companion-crate API. The crate README now tracks version history and separates crate semver from the serialized `VOCAB_METADATA_VERSION` and `WASM_DESUGAR_ABI_VERSION` compatibility constants. -- **Language/Compiler**: RFC 040 adds scoped DSL surface descriptors to `incan_vocab` 0.2.0 and library manifests. Imported vocab crates can now publish descriptor metadata for operator-like glyphs, binding-like glyphs, and expression-form surfaces; the parser recognizes descriptor-enabled leading-dot paths and scoped operator glyphs inside owning vocab blocks while preserving ordinary syntax outside those blocks (#174, RFC 040). -- **Language/Compiler**: RFC 036 adds typed user-defined decorators for top-level functions, async functions, and instance methods, including `mut self` methods. Decorators are ordinary callable values applied bottom-up, method decorators receive `&Owner` or `&mut Owner` receiver callables, decorator factories are checked as callable-producing expressions, later references see the post-decoration binding type, and compiler-owned decorators such as `@route`, `@rust.extern`, `@staticmethod`, `@classmethod`, and `@requires` keep their existing special handling (#170, RFC 036). -- **Language/Compiler**: RFC 045 adds scoped DSL symbol descriptors to `incan_vocab` 0.3.0 and library manifests. Imported vocab crates can now publish identifier-call symbols such as `sum(...)` or `count(...)` that resolve as DSL-owned symbols inside eligible vocab positions, prefer innermost owning DSL scope, diagnose active-DSL misuse with descriptor-authored messages, and leave ordinary Incan resolution unchanged outside the DSL scope. Core builtin functions are now explicitly reachable through `std.builtins.` when an unqualified name is shadowed (#202, RFC 045). -- **Language/Compiler**: Incan now supports `if let PATTERN = VALUE:` and `while let PATTERN = VALUE:` for single-pattern control flow. Parsing, formatter round-trips, typechecking, scoping, lowering, and Rust emission now follow the same pattern semantics as `match`, while `if let` stays single-arm only and rejects `else` / `elif` branches in v1 ([RFC 049], #333). -- **Language/Compiler**: Incan now supports RFC 032 value enum declarations with `str` and `int` backing values. The parser and formatter preserve raw variant assignments, while declaration validation rejects missing values, duplicate raw values, mismatched literal types, payload-bearing variants, generated-helper name collisions, and generic value enums (#317, RFC 032). -- **Language/Compiler**: RFC 083 adds declaration-level symbol aliases and same-type method aliases. Top-level forms such as `mean = avg` and `pub average = alias avg` resolve to existing callable or type-like symbols, method aliases such as `mean = avg` project the target method signature without creating wrapper methods, checked API metadata records alias identity, and library manifests now export aliases as alias metadata instead of duplicated declarations (#437, RFC 083). -- **Language/Compiler**: RFC 084 implements callable preset support with RHS partial declarations such as `pub get = partial route(method="GET")`, same-type method partials, trait method partial defaults, local partial expressions, declaration-safe top-level preset values, projected callable signatures where presets display as ordinary defaults, wrapper lowering for top-level function and constructor presets, public manifest and checked-API exports for projected partial signatures, generated Markdown API references for partials, LSP hover/completion/definition/document-symbol support for partials, and diagnostics for unsupported targets, visibility leaks, cycles, rest targets, trait override conflicts, and inherited partial ambiguity (#453, RFC 084). -- **Language/Compiler**: Public classes now preserve authored field visibility. Non-`pub` class fields stay private after formatter round-trips and member access outside the owning class is rejected, while methods on the class can continue to use private backing fields (#246). -- **Compiler/Parser**: Multiline function and method parameter lists now accept a trailing comma before `)`, including receiver-only method signatures such as `def get(self,) -> int` when written across lines (#394). -- **Tooling**: `incan fmt` now wraps long parenthesized logical expression chains at `and` / `or` breakpoints when the inline form exceeds the configured line-length target (#484). -- **Language/Testing**: RFC 018's `assert expr[, msg]` language primitive is always available without importing `std.testing`. The `std.testing` helpers mirror assertion failure behavior for call-style checks, raises checks, and unwrap-style `Option` / `Result` helpers, while marker decorators remain imported `std.testing` APIs. -- **Language/Testing**: Inline `module tests:` blocks in production source files are now discovered and executed by `incan test`, while production build/run output still strips those test-only declarations and imports ([RFC 018], #76). -- **Runtime/Async**: `std.async` now documents cancellation-safety contracts and exposes channel reservation APIs so critical sends can reserve capacity before committing messages (#415, #416). -- **Runtime/Async**: `std.async.time` adds `timeout_join`, `timeout_join_ms`, and a must-use `TimeoutJoinOutcome` so spawned work can keep running after a deadline while callers retain the live `JoinHandle` for later observation or explicit abort (#417). -- **Runtime/Async**: `std.async.sync.Barrier.wait()` now uses Incan-owned generation bookkeeping so cancelling a pending wait withdraws that participant and frees its arrival slot instead of corrupting barrier progress (#418). -- **Language/Compiler**: Async semantic validation now warns when a direct async function or method call is not awaited, and the existing `await`-outside-async type error is routed through the same registry-backed async surface (#146). -- **Language/Compiler**: RFC 044 lets abstract trait methods omit the trailing `: ...` marker while keeping the explicit spelling valid; body-less methods outside traits remain invalid (#201, RFC 044). -- **Language/Runtime/Async**: RFC 039 adds `Awaitable[T]`, expression-position `race for value:` blocks, and the public `std.async.race` helper surface. `std.async.select` is removed rather than kept as a beta-era compatibility alias, `RaceArm`/`arm`/`race` cover helper-style composition, and ready ties resolve in source order (RFC 039, #173). -- **Language/Compiler**: List and dict comprehensions now accept tuple-unpack iteration targets such as `for idx, name in enumerate(xs)`, matching ordinary `for` loop binding syntax (#483). -- **Language/Compiler**: RFC 006 adds lazy `Generator[T]` values, including `yield`-based generator functions, full-clause generator expressions, iteration-protocol compatibility, and the minimum helper surface `.map()`, `.filter()`, `.take()`, and `.collect()` (#324, RFC 006). -- **Language/Stdlib**: RFC 069 adds import-free `list.repeat(value, count)` for fixed-length list initialization. The compiler infers `list[T]`, enforces clone-compatible repeated values and `count: int`, lowers recognized calls to the stdlib helper, and raises `ValueError` with the bad count for negative runtime counts (#385, RFC 069). -- **Language/Stdlib**: `std.uuid` adds source-defined UUID values with parsing, canonical formatting, `u128` and RFC/network-order byte conversion, nil/max and namespace constants, version/variant inspection, and generation helpers for UUID versions 1, 3, 4, 5, 6, 7, and 8 while keeping UUID layout semantics in Incan source (#338, RFC 060). -- **Language/Compiler**: `List[T].clone()` now typechecks when `T` satisfies `Clone`, returns `List[T]`, and emits the same element-cloning container copy that Rust `Vec::clone()` provides (#363). -- **Language/Interop**: Direct `list[T]` arguments passed to external Rust functions or methods can now satisfy `Vec` parameters by mapping elements through Rust `.into()` at the call boundary, covering APIs such as Polars constructors that accept `Vec` from `Series` values (#128). - -## Stabilization and bugfixes - -- **Compiler/Codegen**: Duckborrowing ownership planning is now centralized around typed value-use sites, covering Incan call arguments, Rust interop arguments, struct fields, collection and tuple elements, assignments, returns, match scrutinees, mutable aggregate parameters, collection lookup probes, loop/comprehension traversal, and backend-inserted generic `Clone` bounds. This removes several classes of generated-Rust borrow/move failures and reduces the need for user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121). -- **Compiler/Codegen**: Default argument expressions that call helpers imported into the defining module now emit those helper calls with the required module qualification when the default is expanded at another call site. This fixes generated Rust failures such as omitted defaults expanding to an unqualified `fallback()` in test runners or downstream modules (#395). -- **Compiler/Codegen**: Ordinary anonymous union wrappers are now shared through the generated crate root for multi-file source modules, so same-shaped unions can be forwarded across modules and member literals can call imported union-typed functions without producing distinct or unqualified Rust wrapper types (#457, #461). -- **Compiler/Codegen**: Wide ordinary-union `isinstance` chains now fully lower before Rust emission, preserving the documented chained narrowing surface instead of leaving runtime `isinstance(...)` calls in generated Rust (#458). -- **Compiler/Codegen**: Generated Rust now retains Rust enum imports that are referenced only from match patterns, including prost-style patterns such as `Some(RelType.Read(_))` (#459). -- **Compiler/Codegen**: `std.testing.assert_eq` and `assert_ne` now isolate their generated Rust operands before comparing them, so checks such as `assert_eq(plan_encoded_len(plan) > 0, true)` emit valid Rust instead of a chained-comparison parse error. -- **Compiler/Codegen**: Cross-module trait-bound propagation no longer lets a same-named external generic helper rewrite a local non-generic function signature. This keeps `std.testing.timeout(...)` independent from `std.async.time.timeout(...)` even though both helpers share the same leaf name. -- **Compiler/Codegen**: Normal generated Rust no longer emits compiler-generated `dead_code` or `unused_imports` allowances. The backend now prunes unused private declarations and imports, keeps Rust extension-trait imports when method lookup needs them, keeps public reexports warning-clean without suppression, and uses narrow `#[expect(dead_code)]` markers only where retained private fields are required for Incan semantics but Rust cannot observe a read (#214). -- **Compiler/Runtime**: Generated Rust now routes the in-scope panic-backed collection and JSON extraction paths, plus proc-macro decorator misuse stubs, through named stdlib helpers instead of open-coded fallback or `panic!` shims. The narrow checked-newtype construction panic remains tracked separately (#351). -- **Compiler/Runtime**: Generated project manifests now keep Tokio and `serde_json` behind the corresponding `incan_stdlib` feature gates for ordinary async and JSON stdlib use, reducing direct generated `Cargo.toml` dependencies without changing the public `std.async` or `std.serde.json` APIs (#157). -- **Compiler/Typechecker**: Typechecker architecture is now split across clearer internal ownership boundaries. Lowering-facing semantic snapshots live outside the main checker state, stdlib trait-method fallback lookup comes from the canonical stdlib registry surface, and import materialization is decomposed into explicit module, stdlib, pub, and Rust import paths without changing language behavior (#283). -- **Compiler/Typechecker**: Unsupported trait-typed local annotations now produce an Incan diagnostic instead of reaching Rust codegen as invalid bare trait local types (#462). -- **Language/Compiler**: Multi-file web builds now retain private route-decorated handlers and the private models they use in dependency modules, so route registration works without forcing those declarations public (#117). -- **Language/Compiler**: Stdlib import validation now rejects unknown names from known stdlib modules, imported stdlib static method calls preserve default arguments at the call site, union narrowing lowers chained `isinstance` branches without leaking raw `isinstance` calls or unit fallthroughs into generated Rust, and Rust interop accepts owned Incan values for shared borrowed generic parameters such as `&T` in both free-function and method-call positions (#499, #500, #501, #502, #506, #508). -- **Language/Interop**: Generated Rust now retains extension-trait imports from typechecker import metadata and receiver trait metadata instead of backend method-name heuristics, so same-name methods from unrelated imported traits do not force unused trait imports (#447). -- **Language/Interop**: Metadata-backed external Rust calls now preserve inspected generic by-value parameters, so prost-style inherent and trait-provided `decode(buf: T)` calls pass owned cursors or borrowed slices directly instead of generating an invalid shared borrow (#609, #612). -- **Tooling/Compiler**: `incan test` now includes implicit generated-code stdlib helper modules such as `std.result` when test files use helper-backed surfaces such as `Result.map_err`, matching the build/check/run dependency closure (#610). -- **Tooling**: `incan lock` now treats manifest projects as a project-wide lock surface, covering declared scripts and test harness dependency inputs so multi-entrypoint projects do not alternate stale-lock warnings between `incan test` and `incan run src/extra.incn` (#505). -- **Tooling**: `incan fmt` now wraps long class trait adoption headers into parseable parenthesized `with (...)` lists, keeping broad adoption surfaces such as `_BytesIO` readable and below the line-length target (#565). -- **Tooling/Compiler**: Generated Rust quality now has artifact-level package baselines, representative stdlib generated-Rust snapshots plus coverage inventory, an audit-report helper with a deterministic strict gate, package-facing callable characterization, a native Rust consumer fixture for generated libraries, and ownership-planner hot-path improvements that avoid proven-unnecessary clone calls for Copy comprehensions and selected owned iterator sources (#599, #600, #601, #602, #603). - -## Documentation and release hardening - -- **Docs**: Added explanation pages for compile-time vs runtime behavior and Rust-shaped confidence, with navigation links and evaluator-guide cross-links for Python and Rust users. -- **Docs**: Added a binary-text encoding how-to for choosing `std.encoding` formats, strict decoding at boundaries, stream/path transforms, and Bech32 five-bit payload handling. -- **Docs**: Stdlib reference pages now keep API contracts separate from task guidance: `std.graph`, `std.regex`, `std.logging`, and `std.hash` link to dedicated how-to or explanation pages, while existing UUID, tempfile, collections, encoding, compression, and datetime references were trimmed back toward reference material. -- **Contributor docs**: Workspace crate boundaries are now classified as stable contracts, compiler/toolchain implementation, runtime-only implementation, and transitional runtime surfaces. The docs also call out explicit ownership metadata for shared surface types, staged Rust interop inspection, and the quarantined `std.web` host-runtime bridge (#284). - -### Versioning and release track - -- **Project lifecycle tooling**: Added lifecycle commands for interactive `incan new` / `incan init`, `incan version`, and `incan env`, plus project lifecycle documentation and `incan.toml` environment metadata support (#73). -- **Dependency policy**: The rust-analyzer proc-macro API dependency is patched locally to request `postcard` without default features, removing the unmaintained `atomic-polyfill` crate from the workspace dependency graph and letting `cargo deny check` run without the `RUSTSEC-2023-0089` advisory ignore (#260). -- **Dependency policy**: The workspace now builds against Wasmtime `44.0.1` / Wasmtime WASI `44.0.1` and raises the Rust MSRV to `1.92`, matching Wasmtime 44's compiler requirement. -- **Dependency policy**: Dependabot security alerts for the VS Code extension lockfile, docs-site Python pins, and Rust `rand` lock entries are remediated, while repo-owned GitHub Actions are moved to Node 24-compatible action releases (#475, #464). -- **Release inventory**: The release-note inventory was reconciled against the 0.3 milestone closeout. Theme-level bullets above cover the detailed generated-Rust, formatter, dependency, test-runner, Rust interop, stdlib, lifecycle, and RFC implementation work; release-relevant direct references include #607, #605, #604, #571, #562, #547, #492, #488, #414, #343, #335, #322, #280, #262, #241, #222, #149, #131, #82, #80, #79, #74, #70, and #69 where those items are grouped rather than named as standalone headline bullets. - -## Known limitations (0.3) - -- Decimal arithmetic is not yet general language behavior. The RFC 009 decimal surface covers typed annotations, literal validation, formatting, generated Rust representation, and display; arithmetic semantics should wait for a follow-up language/library decision. -- `incan fmt` remains intentionally conservative on broader wrapping and may still leave indivisible tokens or unsupported expression shapes beyond the documented 120-character line-length target. RFC 053 / #336 narrows the vertical-spacing contract and adds call/constructor wrapping, while #248 adds common function/method signature wrapping; this is still not a general wrapping/configuration overhaul. -- `std.regex` is the safe default regex surface, not a Python/PCRE compatibility layer. Lookaround, pattern backreferences, and other backtracking-only features belong in a separate package track if they are standardized later. -- Native Windows filesystem semantics are not part of the 0.3 contract. The `std.fs` surface is documented for Unix-like host behavior until the stdlib grows an explicit platform split. +Most ordinary `0.2` programs should continue to compile. The changes below are the ones most likely to show up during adoption. + +1. **Formatter output may change.** `incan fmt` now follows RFC 053's vertical-spacing buckets and wraps more calls/signatures. Projects that run `incan fmt --check` should expect one intentional formatting diff. +2. **Numeric names are more reserved.** Existing `int` and `float` code keeps working, but project-local bare type names such as `decimal`, `numeric`, `bigint`, `integer`, `smallint`, `real`, or `double` can now collide with canonical numeric vocabulary. Rename local aliases or use the new exact forms such as `decimal[12, 2]`. +3. **Testing imports are clearer.** The language `assert` statement is always available, but testing decorators and helpers remain `std.testing` APIs. Files that use `@fixture`, `@parametrize`, `@skip`, `assert_eq`, or similar helpers should import them explicitly. +4. **Lockfiles are less noisy.** `incan.lock` no longer records generation timestamps, and routine `build` / `test` runs warn and reuse stale lock payloads instead of rewriting committed lockfiles. Run `incan lock` when you intentionally refresh the lock. +5. **New features are additive.** `loop:`, `if let`, `while let`, value enums, protocol hooks, iterator adapters, and `Result` combinators do not require rewriting existing `match`, `while True`, helper-function, or nested-`match` code. + +## Features and Enhancements + +- **Language**: Numeric annotations now cover exact integer widths, pointer-sized integers, `f32` / `f64`, analytics aliases, fixed-scale `decimal[p, s]` / `numeric[p, s]`, lossless widening, explicit resize helpers, and Rust boundary adaptation ([RFC 009], #325). +- **Language**: Control flow gained `loop:` with `break `, single-pattern `if let` / `while let`, pattern alternation, anonymous union annotations with narrowing, and value enums with raw `str` / `int` representations ([RFC 016], [RFC 049], [RFC 071], [RFC 029], [RFC 032], #327, #333, #387, #317). +- **Language**: Enums can own methods and adopt traits; models, classes, traits, and wrappers gained computed properties, protocol hooks, operator hooks, typed decorators, declaration aliases, RHS partial callable presets, variadic parameters, call unpacking, and generator values ([RFC 050], [RFC 046], [RFC 068], [RFC 028], [RFC 036], [RFC 083], [RFC 084], [RFC 038], [RFC 006], #334, #203, #86, #162, #170, #437, #453, #83, #324). +- **Rust interop**: Newtypes and rusttypes can adopt Rust traits with Incan source syntax, disambiguate same-name methods with `for Trait`, declare associated types, forward supported `@rust.derive(...)` metadata, and preserve inspected Rust signatures through generated calls ([RFC 043], [RFC 041], #200, #175). +- **Stdlib**: `std.collections` adds specialized containers, and `std.collections.OrdinalMap` adds deterministic immutable key-to-ordinal lookup for schemas, catalogs, dictionary-encoded domains, and reproducible serialized lookup tables ([RFC 030], [RFC 101]). +- **Stdlib**: `std.graph`, `std.json.JsonValue`, `std.regex`, `std.datetime`, and `std.logging` add first-party surfaces for dependency graphs, dynamic JSON payloads, safe-default regular expressions, temporal values, and structured logging ([RFC 047], [RFC 051], [RFC 059], [RFC 058], [RFC 072]). +- **Stdlib**: `std.encoding`, `std.hash`, `std.compression`, `std.fs`, `std.io`, `std.tempfile`, and `std.uuid` cover binary-text transforms, byte/file/reader hashing, codec-based compression, path-centric filesystem work, in-memory byte streams, temporary resources, and UUID parsing/formatting/generation ([RFC 064], [RFC 065], [RFC 061], [RFC 055], [RFC 056], [RFC 010], [RFC 060]). +- **Stdlib**: Iterator values gained lazy adapters and terminal consumers, `Result[T, E]` gained Rust-shaped `map`, `map_err`, `and_then`, `or_else`, `inspect`, and `inspect_err`, and `list.repeat(value, count)` provides import-free fixed-length list initialization ([RFC 088], [RFC 070], [RFC 069], #127, #386, #385). +- **Testing**: The `assert` statement is a language primitive, inline `module tests:` blocks are discovered by `incan test`, the runner now supports fixtures, parametrization, markers, resource-aware parallelism, JSON Lines, JUnit XML, durations, shuffling, `--nocapture`, built-in temp/env fixtures, and async fixtures ([RFC 018], [RFC 019], [RFC 004], #76). +- **Async**: `Awaitable[T]`, expression-position `race for`, `std.async.race`, channel reservation APIs, timeout-join helpers, cancellation-safe barrier behavior, and diagnostics for un-awaited async calls tighten the async surface ([RFC 039], #173, #415, #416, #417, #418, #146). +- **Tooling**: `incan fmt` now follows the vertical-spacing contract and wraps more long calls/signatures; `incan build`, `incan run`, and `incan test` support offline/locked/frozen policy; `incan tools doctor` reports offline readiness and editor binary health; lifecycle commands cover `incan new`, `incan init`, `incan version`, and `incan env` ([RFC 053], [RFC 020], [RFC 015], #73, #460, #426). +- **Tooling**: Checked contract metadata now flows through model bundles, project materialization, `.incnlib` artifacts, `incan tools metadata model`, `incan tools metadata api`, generated API docs, and LSP hover/command surfaces ([RFC 048], #205, #438). + +## Bugfixes and Hardening + +- **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). +- **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). +- **Compiler**: Rust interop fixes cover retained enum-pattern imports, owned Incan values passed to shared borrowed generic Rust parameters, `Vec` adaptation from `list[T]`, prost-style inherent and trait-provided `decode(buf: T)` calls, extension-trait import retention from metadata, and trait-typed local annotation diagnostics (#459, #506, #128, #609, #612, #447, #462). +- **Compiler**: Typechecking and lowering now preserve more generic information, including `Self` substitution on instantiated generic receivers, generic model/class field access, generic instance methods, list literals containing `Self`, trait/supertrait upcasts, imported prost oneof payloads, explicit call-site generic cycles, and locals initialized from static factory calls (#237, #231, #253, #230, #184, #218, #279, #252, #255). +- **Compiler**: Runtime and generated-manifest hardening routes collection/JSON extraction and decorator misuse stubs through named helpers, keeps Tokio and `serde_json` behind feature gates, prunes unused generated Rust without broad `allow` attributes, and improves runtime diagnostics for f-string unknown symbols and collection/string conversion failures (#351, #157, #214, #71, #81). +- **Tooling**: `incan test` reuses more generated harness state, isolates single-file runs, keeps project cwd stable, includes generated helper modules such as `std.result` when test files use helper-backed surfaces, and treats project manifests as one lock surface across scripts and test harness inputs (#268, #269, #271, #288, #378, #610, #505). +- **Tooling**: Formatter fixes preserve escaped f-string newlines, numeric literal spelling, qualified enum/constructor patterns, `mut` markers, block blank-line intent, docstrings, multiline trailing commas, long logical-expression wrapping, and parseable class trait-adoption wrapping (#235, #250, #264, #289, #247, #394, #484, #565). +- **Docs**: User-facing docs were reorganized around reference/how-to/explanation boundaries for stdlib modules including graph, regex, logging, hash, UUID, tempfile, collections, encoding, compression, and datetime. Contributor docs now describe crate boundaries, ownership metadata, staged Rust inspection, and the quarantined `std.web` host-runtime bridge (#284). +- **Dependencies**: Release hardening removes the `atomic-polyfill` advisory path through the local rust-analyzer proc-macro API patch, updates Wasmtime/WASI and MSRV together, remediates Dependabot alerts across docs-site Python pins, VS Code lockfiles, Rust lock entries, and GitHub Actions, and bumps `pymdown-extensions` to `10.21.3` for `GHSA-62q4-447f-wv8h` (#260, #475, #464). + +## Known limitations + +- Decimal arithmetic is not yet general language behavior. The `0.3` decimal surface covers typed annotations, literal validation, formatting, generated Rust representation, and display; arithmetic semantics need a follow-up language/library decision. +- `incan fmt` is still conservative. RFC 053 gives vertical spacing and common wrapping rules, but it is not a general pretty-printer overhaul for every nested expression shape. +- `std.regex` is a safe-default regular-expression surface, not a Python/PCRE compatibility layer. Lookaround, pattern backreferences, and other backtracking-only features belong in a separate package or future stdlib track if standardized. +- Native Windows filesystem behavior is not part of the `0.3` contract. `std.fs` documents Unix-like host behavior until the stdlib has an explicit platform split. ## RFCs implemented -- Async fixtures: [RFC 004](../RFCs/closed/implemented/004_async_fixtures.md) -- Numeric type system and builtin type registry: [RFC 009] -- Temporary files and directories: [RFC 010] -- Hatch-like tooling and project lifecycle CLI: [RFC 015] -- Loop expressions and break values: [RFC 016] -- Validated newtypes with implicit coercion: [RFC 017](../RFCs/closed/implemented/017_validated_newtypes_with_implicit_coercion.md) -- Testing language primitives: [RFC 018] -- Extensible derive protocol: [RFC 024] -- Trait-based operator overloading: [RFC 028] -- Union types and type narrowing: [RFC 029] -- Extended collection types: [RFC 030] -- Value enums: [RFC 032] -- Variadic positional arguments and keyword capture: [RFC 038] -- Open-ended trait methods: [RFC 044] -- Async race and awaitability: [RFC 039](../RFCs/closed/implemented/039_race_for_awaitable_concurrency.md) -- Computed properties: [RFC 046] -- Checked contract metadata and interrogation tooling: [RFC 048] -- Lightweight directed graph types: [RFC 047] -- `if let` and `while let` pattern control flow: [RFC 049] -- Enum methods and enum trait adoption: [RFC 050] -- Dynamic JSON values: [RFC 051] -- Formatter vertical spacing buckets: [RFC 053] -- Path-centric filesystem APIs: [RFC 055] -- `std.datetime` temporal values and intervals: [RFC 058] -- `std.regex` regular expressions, captures, splitting, and replacement: [RFC 059](../RFCs/closed/implemented/059_std_regex.md) -- UUID parsing, formatting, inspection, and generation: [RFC 060](../RFCs/closed/implemented/060_std_uuid.md) -- Codec-based compression and decompression: [RFC 061](../RFCs/closed/implemented/061_std_compression.md) -- Binary-text encoding and decoding utilities: [RFC 064] -- Byte, file, reader, cryptographic, compatibility, and non-cryptographic hashing: [RFC 065] -- Targeted generated-Rust lint suppression: [RFC 057] -- Protocol hooks for core syntax: [RFC 068] -- Fixed-length list initialization with `list.repeat`: [RFC 069] -- Result combinators: [RFC 070](../RFCs/closed/implemented/070_result_combinators_for_result_types.md) -- `std.collections.OrdinalMap` deterministic ordinal indexes: [RFC 101](../RFCs/closed/implemented/101_std_collections_ordinal_map.md) +- **Language and compiler**: [RFC 004], [RFC 006], [RFC 009], [RFC 016], [RFC 017], [RFC 018], [RFC 024], [RFC 025], [RFC 028], [RFC 029], [RFC 032], [RFC 036], [RFC 038], [RFC 039], [RFC 043], [RFC 044], [RFC 046], [RFC 049], [RFC 050], [RFC 053], [RFC 057], [RFC 068], [RFC 069], [RFC 070], [RFC 071], [RFC 083], [RFC 084], [RFC 088]. +- **Stdlib**: [RFC 010], [RFC 030], [RFC 047], [RFC 051], [RFC 055], [RFC 056], [RFC 058], [RFC 059], [RFC 060], [RFC 061], [RFC 064], [RFC 065], [RFC 072], [RFC 101]. +- **Tooling and metadata**: [RFC 015], [RFC 019], [RFC 020], [RFC 040], [RFC 045], [RFC 048]. --8<-- "_snippets/rfcs_refs.md" diff --git a/workspaces/docs-site/requirements-docs.txt b/workspaces/docs-site/requirements-docs.txt index c1caebdd8..e77730665 100644 --- a/workspaces/docs-site/requirements-docs.txt +++ b/workspaces/docs-site/requirements-docs.txt @@ -3,6 +3,6 @@ mkdocs-material==9.5.49 mike==2.1.3 mkdocs-redirects==1.2.1 mkdocs-gen-files==0.6.0 -pymdown-extensions==10.21.2 -# PyMdown 10.21.2 handles Pygments 2.20+ when no fence title produces filename=None. +pymdown-extensions==10.21.3 +# PyMdown 10.21.3 handles Pygments 2.20+ when no fence title produces filename=None. pygments==2.20.0 From 939ff58e0013fb342c256205ad83383f21626d44 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 13:34:37 +0200 Subject: [PATCH 04/58] docs - add v0.3 release note detail links --- workspaces/docs-site/docs/release_notes/0_3.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index ceca8adb1..b6c203eee 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -16,6 +16,15 @@ The main direction is not "more syntax for its own sake." `0.3` moves common pro - **Tooling**: `incan test`, `incan fmt`, `incan lock`, lifecycle commands, doctor diagnostics, checked API metadata, LSP metadata, and generated Rust audits are more deterministic and CI-friendly. - **Architecture**: More behavior is registry- and metadata-driven, and generated Rust relies less on scattered special cases. +## Read the details + +Use these entry points when a feature group needs more than release-note depth. + +- **Language**: [Numeric semantics](../language/reference/numeric_semantics.md), [Choosing numeric types](../language/how-to/choosing_numeric_types.md), [Control Flow](../language/explanation/control_flow.md), [Union types](../language/reference/union_types.md), [Enums](../language/explanation/enums.md), [Traits as language hooks](../language/explanation/traits_as_language_hooks.md), [Derives and traits](../language/reference/derives_and_traits.md), and [Callable objects](../language/reference/stdlib_traits/callable.md). +- **Stdlib**: [Choosing collection types](../language/how-to/choosing_collections.md), [`std.collections`](../language/reference/stdlib/collections.md), [Why `OrdinalMap` exists](../language/explanation/ordinal_map.md), [`std.graph`](../language/reference/stdlib/graph.md), [Working with graphs](../language/how-to/working_with_graphs.md), [`std.json`](../language/reference/stdlib/json.md), [Dynamic JSON](../language/how-to/dynamic_json.md), [`std.regex`](../language/reference/stdlib/regex.md), [Regular expressions](../language/how-to/regular_expressions.md), [`std.datetime`](../language/reference/stdlib/datetime.md), [Dates and times](../language/how-to/dates_and_times.md), [`std.logging`](../language/reference/stdlib/logging.md), [Logging](../language/how-to/logging.md), [`std.encoding`](../language/reference/stdlib/encoding.md), [Binary-text encoding](../language/how-to/binary_text_encoding.md), [`std.hash`](../language/reference/stdlib/hash.md), [`std.compression`](../language/reference/stdlib/compression.md), and [Compression](../language/how-to/compression.md). +- **Rust interop**: [Rust interop](../language/how-to/rust_interop.md), [Understanding Rust types](../language/how-to/rust_types_for_python_devs.md), [Rust-shaped confidence](../language/explanation/rust_shaped_confidence.md), [Derives and traits](../language/explanation/derives_and_traits.md), and [`std.traits`](../language/reference/stdlib/traits.md). +- **Tooling**: [Formatting with `incan fmt`](../tooling/how-to/formatting.md), [Tooling: Testing](../tooling/how-to/testing.md), [Project lifecycle](../language/how-to/project_lifecycle.md), [Project configuration](../tooling/reference/project_configuration.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md). + ## Migrating from 0.2 Most ordinary `0.2` programs should continue to compile. The changes below are the ones most likely to show up during adoption. From 93cdc3ed5f3f25c77549968e60d661f07f4a835d Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 18:36:16 +0200 Subject: [PATCH 05/58] bugfix - fix v0.3 rc2 release regressions (#615, #616, #617) (#619) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/decl.rs | 2 + src/backend/ir/emit/decls/mod.rs | 173 +++++++++++------- src/backend/ir/emit/statements.rs | 21 +++ src/backend/ir/lower/decl/mod.rs | 40 +++- src/backend/ir/lower/mod.rs | 47 ++++- src/format/formatter/expressions.rs | 2 +- src/format/formatter/statements.rs | 3 +- src/format/mod.rs | 14 ++ src/frontend/library_exports.rs | 33 +++- .../typechecker/collect/stdlib_imports.rs | 27 ++- src/frontend/typechecker/tests.rs | 80 +++++++- src/library_manifest/model.rs | 3 + tests/cli_integration.rs | 163 +++++++++++++++++ tests/integration_tests.rs | 52 ++++++ 16 files changed, 583 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c8225dd9..92cf5774c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc1" +version = "0.3.0-rc2" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 0cea4481e..9d0fdbd16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc1" +version = "0.3.0-rc2" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/decl.rs b/src/backend/ir/decl.rs index 33342ef48..f342ce8f7 100644 --- a/src/backend/ir/decl.rs +++ b/src/backend/ir/decl.rs @@ -57,6 +57,8 @@ pub enum IrDeclKind { visibility: Visibility, name: String, target_path: Vec, + target_origin: Option, + target_qualifier: Option, }, /// Constant diff --git a/src/backend/ir/emit/decls/mod.rs b/src/backend/ir/emit/decls/mod.rs index bfbf5d2cc..cd6ea6d18 100644 --- a/src/backend/ir/emit/decls/mod.rs +++ b/src/backend/ir/emit/decls/mod.rs @@ -72,17 +72,13 @@ impl<'a> IrEmitter<'a> { visibility, name, target_path, + target_origin, + target_qualifier, } => { let vis = self.emit_visibility(visibility); let name_ident = format_ident!("{}", name); - let target_segments = target_path - .iter() - .map(|segment| { - let ident = format_ident!("{}", segment); - quote! { #ident } - }) - .collect::>(); - let target = join_path_tokens(&target_segments); + let target = + self.emit_symbol_alias_target_path(target_origin.as_ref(), target_qualifier.as_ref(), target_path); Ok(quote! { #vis use #target as #name_ident; }) @@ -232,6 +228,106 @@ impl<'a> IrEmitter<'a> { // ---- Import emission ---- + /// Return whether an import path refers to the source-authored Incan stdlib namespace. + fn is_incan_source_stdlib_import(origin: &IrImportOrigin, qualifier: &IrImportQualifier, path: &[String]) -> bool { + !matches!(origin, IrImportOrigin::PubLibrary { .. }) + && !matches!(qualifier, IrImportQualifier::None) + && stdlib::is_any_stdlib_path(path) + } + + /// Convert an IR import path into Rust path segments using the same qualification rules for imports and aliases. + fn import_path_tokens( + &self, + origin: &IrImportOrigin, + qualifier: &IrImportQualifier, + path: &[String], + ) -> Vec { + let is_pub_library_import = matches!(origin, IrImportOrigin::PubLibrary { .. }); + let is_stdlib = Self::is_incan_source_stdlib_import(origin, qualifier, path); + + if is_stdlib { + let mut tokens = vec![quote! { crate }]; + let std_namespace = Self::rust_ident(stdlib::INCAN_STD_NAMESPACE); + tokens.push(quote! { #std_namespace }); + for seg in path.iter().skip(1) { + let ident = Self::rust_ident(seg); + tokens.push(quote! { #ident }); + } + return tokens; + } + + if is_pub_library_import { + return path + .iter() + .map(|segment| { + let ident = Self::rust_ident(segment); + quote! { #ident } + }) + .collect(); + } + + let mut tokens: Vec = Vec::new(); + match qualifier { + IrImportQualifier::Auto => { + if self.is_internal_module_path(path) { + tokens.push(quote! { crate }); + } + } + IrImportQualifier::Crate => tokens.push(quote! { crate }), + IrImportQualifier::Super(levels) => { + for _ in 0..*levels { + tokens.push(quote! { super }); + } + } + IrImportQualifier::None => {} + } + tokens.extend(path.iter().map(|segment| { + let ident = Self::rust_ident(segment); + quote! { #ident } + })); + tokens + } + + /// Emit the Rust path used by a module-level symbol alias target. + /// + /// Imported targets use their original import path so public aliases re-export public items directly instead of + /// re-exporting a private local `use` binding. + fn emit_symbol_alias_target_path( + &self, + target_origin: Option<&IrImportOrigin>, + target_qualifier: Option<&IrImportQualifier>, + target_path: &[String], + ) -> TokenStream { + let Some(origin) = target_origin else { + let target_segments = target_path + .iter() + .map(|segment| { + let ident = format_ident!("{}", segment); + quote! { #ident } + }) + .collect::>(); + return join_path_tokens(&target_segments); + }; + let Some(qualifier) = target_qualifier else { + let target_segments = target_path + .iter() + .map(|segment| { + let ident = format_ident!("{}", segment); + quote! { #ident } + }) + .collect::>(); + return join_path_tokens(&target_segments); + }; + + let path_tokens = self.import_path_tokens(origin, qualifier, target_path); + let path = join_path_tokens(&path_tokens); + if matches!(qualifier, IrImportQualifier::None) && !matches!(origin, IrImportOrigin::PubLibrary { .. }) { + quote! { :: #path } + } else { + path + } + } + /// Emit a Rust import or re-export after generated-use analysis prunes private unused bindings. fn emit_import( &self, @@ -257,64 +353,9 @@ impl<'a> IrEmitter<'a> { // Only Incan stdlib imports (qualifier `Auto`) are mapped. Rust crate imports like // `from rust::std::collections import HashMap` (qualifier `None`) are left as-is. let is_pub_library_import = matches!(origin, IrImportOrigin::PubLibrary { .. }); - let is_stdlib = - !is_pub_library_import && !matches!(qualifier, IrImportQualifier::None) && stdlib::is_any_stdlib_path(path); - let is_incan_source_stdlib = is_stdlib; + let is_incan_source_stdlib = Self::is_incan_source_stdlib_import(origin, qualifier, path); - let path_tokens: Vec = if is_incan_source_stdlib { - let mut tokens = vec![quote! { crate }]; - let std_namespace = Self::rust_ident(stdlib::INCAN_STD_NAMESPACE); - tokens.push(quote! { #std_namespace }); - for seg in path.iter().skip(1) { - let ident = Self::rust_ident(seg); - tokens.push(quote! { #ident }); - } - tokens - } else if is_pub_library_import { - path.iter() - .map(|segment| { - let ident = Self::rust_ident(segment); - quote! { #ident } - }) - .collect() - } else { - let mut tokens: Vec = Vec::new(); - let mapped_path_tokens: Vec<_> = if is_stdlib { - let mut mapped = vec![quote! { incan_stdlib }]; - // Skip the `std` root, map the rest with keyword escaping. - for seg in path.iter().skip(1) { - let ident = Self::rust_ident(seg); - mapped.push(quote! { #ident }); - } - mapped - } else { - path.iter() - .map(|s| { - let ident = Self::rust_ident(s); - quote! { #ident } - }) - .collect() - }; - let apply_prefix = !is_stdlib; - if apply_prefix { - match qualifier { - IrImportQualifier::Auto => { - if self.is_internal_module_path(path) { - tokens.push(quote! { crate }); - } - } - IrImportQualifier::Crate => tokens.push(quote! { crate }), - IrImportQualifier::Super(levels) => { - for _ in 0..*levels { - tokens.push(quote! { super }); - } - } - IrImportQualifier::None => {} - } - } - tokens.extend(mapped_path_tokens); - tokens - }; + let path_tokens = self.import_path_tokens(origin, qualifier, path); let path_ts = join_path_tokens(&path_tokens); // Public source imports, stdlib facades, and rust.module imports are re-exported. Private `pub::` library @@ -434,7 +475,7 @@ impl<'a> IrEmitter<'a> { }) .collect(); Ok(quote! { #(#item_stmts)* }) - } else if path.len() == 1 && !is_stdlib { + } else if path.len() == 1 && !is_incan_source_stdlib { Ok(quote! {}) } else if export_module_import { Ok(quote! { diff --git a/src/backend/ir/emit/statements.rs b/src/backend/ir/emit/statements.rs index 23e08fff4..9eda8598b 100644 --- a/src/backend/ir/emit/statements.rs +++ b/src/backend/ir/emit/statements.rs @@ -95,6 +95,15 @@ fn for_body_needs_mut_iteration(pattern: &Pattern, body: &[IrStmt]) -> bool { body.iter().any(|s| stmt_mutates_var(s, loop_var)) } +/// Return the element target type for assignment into a list index. +fn list_index_assignment_element_type(object_ty: &IrType) -> Option<&IrType> { + match object_ty { + IrType::Ref(inner) | IrType::RefMut(inner) => list_index_assignment_element_type(inner), + IrType::List(elem_ty) => Some(elem_ty.as_ref()), + _ => None, + } +} + /// Return the local `StaticBinding` name at the root of a storage-rooted expression. /// /// This is used by statement-slice analysis to detect aliases like `live` in @@ -1133,6 +1142,18 @@ impl<'a> IrEmitter<'a> { .apply(v); return Ok(quote! { #o.insert(#k, #v); }); } + if let AssignTarget::Index { object, .. } = target + && let Some(value_target_ty) = list_index_assignment_element_type(&object.ty) + { + let t = self.emit_assign_target(target)?; + let v = self.emit_expr_for_use( + value, + ValueUseSite::Assignment { + target_ty: Some(value_target_ty), + }, + )?; + return Ok(quote! { #t = #v; }); + } let t = self.emit_assign_target(target)?; let v = self.emit_assignment_value(value, None)?; Ok(quote! { #t = #v; }) diff --git a/src/backend/ir/lower/decl/mod.rs b/src/backend/ir/lower/decl/mod.rs index 1d1e77c66..9125e33b2 100644 --- a/src/backend/ir/lower/decl/mod.rs +++ b/src/backend/ir/lower/decl/mod.rs @@ -16,7 +16,10 @@ mod newtypes; mod traits; use super::super::IrSpan; -use super::super::decl::{IrDecl, IrDeclKind, IrInteropAdapterKind, IrInteropDirection, IrInteropEdge, Visibility}; +use super::super::decl::{ + IrDecl, IrDeclKind, IrImportOrigin, IrImportQualifier, IrInteropAdapterKind, IrInteropDirection, IrInteropEdge, + Visibility, +}; use super::super::types::IrType; use super::AstLowering; use super::errors::LoweringError; @@ -138,11 +141,16 @@ impl AstLowering { is_rusttype: false, interop_edges: Vec::new(), }, - ast::Declaration::Alias(a) => IrDeclKind::SymbolAlias { - visibility: Self::map_visibility(a.visibility), - name: a.name.clone(), - target_path: a.target.segments.clone(), - }, + ast::Declaration::Alias(a) => { + let (target_path, target_origin, target_qualifier) = self.alias_reexport_target(&a.target.segments); + IrDeclKind::SymbolAlias { + visibility: Self::map_visibility(a.visibility), + name: a.name.clone(), + target_path, + target_origin, + target_qualifier, + } + } ast::Declaration::Partial(_) => { return Err(LoweringError { message: "Partial callable presets are not lowered by this syntax-only slice".to_string(), @@ -188,6 +196,26 @@ impl AstLowering { Ok(IrDecl::new(kind)) } + /// Resolve the path that should be used when emitting a module-level alias declaration. + /// + /// A source alias can target a local import binding, but generated Rust public re-exports must point at the + /// imported item path itself. Expression lowering still keeps the source binding for ordinary calls. + fn alias_reexport_target( + &self, + segments: &[String], + ) -> (Vec, Option, Option) { + if let [target] = segments + && let Some(imported) = self.imported_alias_targets.get(target) + { + return ( + imported.path.clone(), + Some(imported.origin.clone()), + Some(imported.qualifier), + ); + } + (segments.to_vec(), None, None) + } + fn lower_interop_edges( &mut self, edges: &[ast::Spanned], diff --git a/src/backend/ir/lower/mod.rs b/src/backend/ir/lower/mod.rs index 98f70f752..53a1ef6fe 100644 --- a/src/backend/ir/lower/mod.rs +++ b/src/backend/ir/lower/mod.rs @@ -35,7 +35,7 @@ mod types; use std::collections::{HashMap, HashSet}; use super::TypedExpr; -use super::decl::{FunctionParam, IrDecl, IrDeclKind}; +use super::decl::{FunctionParam, IrDecl, IrDeclKind, IrImportOrigin, IrImportQualifier}; use super::expr::{IrCallArg, IrCallArgKind, IrExprKind, VarAccess, VarRefKind}; use super::stmt::{IrStmt, IrStmtKind}; use super::types::IrType; @@ -64,6 +64,13 @@ pub(in crate::backend::ir::lower) struct TraitImplLoweringInput<'a> { pub impl_associated_types: &'a [ast::Spanned], } +#[derive(Debug, Clone)] +pub(super) struct ImportedAliasTarget { + pub origin: IrImportOrigin, + pub qualifier: IrImportQualifier, + pub path: Vec, +} + /// AST to IR lowering context. /// /// Maintains state needed during the lowering pass: @@ -144,6 +151,8 @@ pub struct AstLowering { pub(super) callable_param_scopes: Vec>, /// Module-level symbol aliases mapped from alias name to canonical target name. pub(super) symbol_aliases: HashMap, + /// Imported item bindings mapped to their original import paths for public alias re-export emission. + pub(super) imported_alias_targets: HashMap, /// Cached stdlib metadata used to resolve rust.module-backed decorators/derives. pub(super) stdlib_cache: StdlibAstCache, /// `rusttype` underlying Rust type lookup by alias name. @@ -235,6 +244,7 @@ impl AstLowering { rust_import_aliases: HashMap::new(), callable_param_scopes: Vec::new(), symbol_aliases: HashMap::new(), + imported_alias_targets: HashMap::new(), stdlib_cache: StdlibAstCache::new(), rusttype_underlying: HashMap::new(), rusttype_interop_edges: HashMap::new(), @@ -912,6 +922,7 @@ impl AstLowering { let mut errors: Vec = Vec::new(); self.import_aliases = decorator_resolution::collect_import_aliases(program); self.rust_import_aliases = decorator_resolution::collect_rust_import_aliases(program); + self.imported_alias_targets = self.collect_imported_alias_targets(program); self.seed_imported_stdlib_trait_decls(program); self.alias_imported_dependency_trait_decls(); self.symbol_aliases = program @@ -1536,6 +1547,40 @@ impl AstLowering { } } + /// Collect imported item bindings that module-level symbol aliases may need to re-export directly. + fn collect_imported_alias_targets(&self, program: &ast::Program) -> HashMap { + let mut targets = HashMap::new(); + for decl in &program.declarations { + let ast::Declaration::Import(import) = &decl.node else { + continue; + }; + let IrDeclKind::Import { + origin, + qualifier, + path, + items, + .. + } = self.lower_import(import) + else { + continue; + }; + for item in items { + let binding = item.alias.unwrap_or_else(|| item.name.clone()); + let mut item_path = path.clone(); + item_path.push(item.name); + targets.insert( + binding, + ImportedAliasTarget { + origin: origin.clone(), + qualifier, + path: item_path, + }, + ); + } + } + targets + } + /// Lower a function declaration, expanding RFC 036 decorated functions into original/static/wrapper items. fn lower_decorated_function_declarations(&mut self, f: &ast::FunctionDecl) -> Result, LoweringError> { let Some(binding) = self diff --git a/src/format/formatter/expressions.rs b/src/format/formatter/expressions.rs index b0e672c6a..de0e0791a 100644 --- a/src/format/formatter/expressions.rs +++ b/src/format/formatter/expressions.rs @@ -452,7 +452,7 @@ impl Formatter { match clause { ComprehensionClause::For { pattern, iter } => { self.writer.write(" for "); - self.format_pattern(&pattern.node); + self.format_for_pattern(&pattern.node); self.writer.write(" in "); self.format_expr(&iter.node); } diff --git a/src/format/formatter/statements.rs b/src/format/formatter/statements.rs index 62bff7489..50e0bff5d 100644 --- a/src/format/formatter/statements.rs +++ b/src/format/formatter/statements.rs @@ -285,7 +285,8 @@ impl Formatter { self.writer.dedent(); } - fn format_for_pattern(&mut self, pattern: &Pattern) { + /// Format a `for`-target pattern using the grammar's unparenthesized tuple-target spelling. + pub(super) fn format_for_pattern(&mut self, pattern: &Pattern) { if let Pattern::Tuple(items) = pattern { for (i, item) in items.iter().enumerate() { if i > 0 { diff --git a/src/format/mod.rs b/src/format/mod.rs index b707b0fdd..88bab615e 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -367,6 +367,20 @@ mod tests { Ok(()) } + #[test] + fn test_format_source_list_comprehension_tuple_target_omits_parentheses() -> Result<(), FormatError> { + let source = r#"def labels(values: list[str]) -> list[str]: + return [f"{idx}:{value}" for idx, value in enumerate(values)] +"#; + let expected = r#"def labels(values: list[str]) -> list[str]: + return [f"{idx}:{value}" for idx, value in enumerate(values)] +"#; + let formatted = format_source(source)?; + assert_eq!(formatted, expected); + assert_eq!(format_source(&formatted)?, expected); + Ok(()) + } + #[test] fn test_format_source_rfc028_operator_spellings() -> Result<(), FormatError> { let source = r#"def ops(a: Any, b: Any, c: Any) -> None: diff --git a/src/frontend/library_exports.rs b/src/frontend/library_exports.rs index 16bf18693..41a5b5302 100644 --- a/src/frontend/library_exports.rs +++ b/src/frontend/library_exports.rs @@ -117,6 +117,7 @@ pub struct CheckedTypeAliasExport { pub struct CheckedAliasExport { pub name: String, pub target_path: Vec, + pub projected_function: Option, } #[derive(Debug, Clone)] @@ -324,16 +325,32 @@ pub fn collect_checked_public_exports(program: &Program, checker: &TypeChecker) /// Build a checked public export entry for a module-level alias. fn checked_alias_export(alias: &AliasDecl, checker: &TypeChecker) -> Option { - checker.lookup_symbol(alias.name.as_str())?; + let symbol = checker.lookup_symbol(alias.name.as_str())?; + let projected_function = match &symbol.kind { + SymbolKind::Function(info) => Some(checked_alias_function_export(&alias.name, info)), + _ => None, + }; Some(CheckedNamedExport { name: alias.name.clone(), kind: CheckedExportKind::Alias(CheckedAliasExport { name: alias.name.clone(), target_path: alias.target.segments.clone(), + projected_function, }), }) } +/// Build manifest-ready callable metadata for an alias that projects a function. +fn checked_alias_function_export(name: &str, info: &FunctionInfo) -> CheckedFunctionExport { + CheckedFunctionExport { + name: name.to_string(), + type_params: checked_function_type_params(info), + params: info.params.clone(), + return_type: info.return_type.clone(), + is_async: info.is_async, + } +} + /// Build checked export metadata for a public partial callable preset. fn checked_partial_export(partial: &PartialDecl, checker: &TypeChecker) -> Option { let symbol = checker.lookup_symbol(partial.name.as_str())?; @@ -519,6 +536,20 @@ fn checked_function_export(function: &FunctionDecl, checker: &TypeChecker) -> Op }) } +/// Convert checked function metadata type parameters into export metadata type parameters. +fn checked_function_type_params(info: &FunctionInfo) -> Vec { + info.type_params + .iter() + .map(|name| CheckedTypeParam { + name: name.clone(), + bounds: info + .type_param_bound_details + .get(name) + .map_or_else(Vec::new, |bounds| map_type_bound_infos(bounds)), + }) + .collect() +} + fn checked_type_alias_export(alias: &TypeAliasDecl, checker: &TypeChecker) -> CheckedTypeAliasExport { let target = resolve_type(&alias.target.node, &checker.symbols); CheckedTypeAliasExport { diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index 624973ea3..c0f3c00e0 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -733,6 +733,9 @@ impl TypeChecker { fields: fields.iter().map(resolved_type_from_manifest_type_ref).collect(), }), ManifestExportRef::Alias(export) => { + if let Some(function) = &export.projected_function { + return Some(SymbolKind::Function(self.function_info_from_manifest(function))); + } let target_name = export.target_path.last()?; return self.lookup_pub_library_symbol_member(library, target_name); } @@ -990,13 +993,23 @@ impl TypeChecker { is_used: false, }), ManifestExportRef::Alias(export) => { - let Some(target_name) = export.target_path.last() else { - return; - }; - let Some(target_export) = Self::find_manifest_export(manifest, target_name) else { - return; - }; - return self.define_pub_import_symbol(manifest, local_name, target_export, imported_type_aliases, span); + if let Some(function) = &export.projected_function { + SymbolKind::Function(self.function_info_from_manifest(function)) + } else { + let Some(target_name) = export.target_path.last() else { + return; + }; + let Some(target_export) = Self::find_manifest_export(manifest, target_name) else { + return; + }; + return self.define_pub_import_symbol( + manifest, + local_name, + target_export, + imported_type_aliases, + span, + ); + } } }; self.remap_symbol_kind_with_import_aliases(&mut kind, imported_type_aliases); diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index fe8ecc880..ae9d221c7 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -12,10 +12,10 @@ use crate::frontend::library_manifest_index::{ use crate::frontend::testing_markers::TestingFixtureScope; use crate::frontend::{lexer, parser}; use crate::library_manifest::{ - ClassExport, ConstExport, EnumExport, EnumValueExport, EnumValueTypeExport, EnumVariantExport, FunctionExport, - LibraryContractMetadata, LibraryExports, LibraryManifest, LibraryRustAbi, MethodExport, ModelExport, ParamExport, - ParamKindExport, PartialExport, PartialPresetExport, PartialTargetKindExport, PresetValueExport, ReceiverExport, - StaticExport, TraitExport, TypeBoundExport, TypeParamExport, TypeRef, + AliasExport, ClassExport, ConstExport, EnumExport, EnumValueExport, EnumValueTypeExport, EnumVariantExport, + FunctionExport, LibraryContractMetadata, LibraryExports, LibraryManifest, LibraryRustAbi, MethodExport, + ModelExport, ParamExport, ParamKindExport, PartialExport, PartialPresetExport, PartialTargetKindExport, + PresetValueExport, ReceiverExport, StaticExport, TraitExport, TypeBoundExport, TypeParamExport, TypeRef, }; #[cfg(feature = "rust_inspect")] use crate::rust_inspect::{Inspector, InspectorConfig, write_borrowed_param_probe_crate, write_substrait_probe_crate}; @@ -1206,6 +1206,10 @@ pub mean = alias avg assert_eq!(manifest.exports.aliases.len(), 1); assert_eq!(manifest.exports.aliases[0].name, "mean"); assert_eq!(manifest.exports.aliases[0].target_path, vec!["avg"]); + assert!( + manifest.exports.aliases[0].projected_function.is_some(), + "function aliases should carry callable projection metadata for pub:: consumers" + ); assert!( manifest .exports @@ -1493,6 +1497,59 @@ fn library_index_with_mylib_exports() -> LibraryManifestIndex { )])) } +fn library_index_with_callable_alias_export() -> LibraryManifestIndex { + let manifest = LibraryManifest { + name: "mylib".to_string(), + version: "0.1.0".to_string(), + incan_version: crate::version::INCAN_VERSION.to_string(), + manifest_format: crate::library_manifest::LIBRARY_MANIFEST_FORMAT, + exports: LibraryExports { + aliases: vec![AliasExport { + name: "public_target".to_string(), + target_path: vec!["target_impl".to_string()], + projected_function: Some(FunctionExport { + name: "public_target".to_string(), + type_params: Vec::new(), + params: vec![ParamExport { + name: "value".to_string(), + ty: TypeRef::Named { + name: "int".to_string(), + }, + kind: ParamKindExport::Normal, + has_default: false, + }], + return_type: TypeRef::Named { + name: "int".to_string(), + }, + is_async: false, + }), + }], + partials: Vec::new(), + models: Vec::new(), + classes: Vec::new(), + functions: Vec::new(), + traits: Vec::new(), + enums: Vec::new(), + type_aliases: Vec::new(), + newtypes: Vec::new(), + consts: Vec::new(), + statics: Vec::new(), + }, + vocab: None, + soft_keywords: Default::default(), + contract_metadata: LibraryContractMetadata::default(), + rust_abi: None, + }; + + LibraryManifestIndex::from_entries(HashMap::from([( + "mylib".to_string(), + LibraryManifestIndexEntry::Loaded { + manifest: Box::new(manifest), + metadata: LibraryArtifactMetadata::from_crate_root("mylib", "mylib", synthetic_artifact_root("mylib")), + }, + )])) +} + fn library_index_with_trait_export() -> LibraryManifestIndex { let manifest = LibraryManifest { name: "mylib".to_string(), @@ -10938,6 +10995,21 @@ def build() -> Widget: ); } +#[test] +fn test_pub_from_import_manifest_callable_alias_typechecks() { + let source = r#" +from pub::mylib import public_target + +def build() -> int: + return public_target(1) +"#; + let result = check_str_with_library_index(source, library_index_with_callable_alias_export()); + assert!( + result.is_ok(), + "expected pub-imported callable alias to typecheck, got: {result:?}" + ); +} + #[test] fn test_pub_imported_enum_methods_and_trait_adoption_typecheck() { let source = r#" diff --git a/src/library_manifest/model.rs b/src/library_manifest/model.rs index d141d3c24..f68e1d8ce 100644 --- a/src/library_manifest/model.rs +++ b/src/library_manifest/model.rs @@ -88,6 +88,8 @@ pub struct LibraryExports { pub struct AliasExport { pub name: String, pub target_path: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projected_function: Option, } /// Exported partial callable preset metadata. @@ -682,6 +684,7 @@ fn alias_export_from_checked(export: &CheckedAliasExport) -> AliasExport { AliasExport { name: export.name.clone(), target_path: export.target_path.clone(), + projected_function: export.projected_function.as_ref().map(function_export_from_checked), } } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index fd971e836..ce6aa47a9 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1570,6 +1570,169 @@ pub def ping() -> str: Ok(()) } +#[test] +fn fmt_tuple_target_list_comprehension_remains_buildable() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "fmt_tuple_target_list_comp", "")?; + fs::write( + &main_path, + r#"def main() -> None: + values = ["alpha", "beta"] + labels: list[str] = [f"{idx}:{value}" for idx, value in enumerate(values)] +"#, + )?; + + let fmt_output = run_incan( + tmp.path(), + &["fmt", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&fmt_output, "incan fmt tuple-target list comprehension"); + + let formatted = fs::read_to_string(&main_path)?; + assert!( + formatted.contains("for idx, value in enumerate(values)"), + "formatter should keep tuple comprehension targets unparenthesized, got:\n{formatted}" + ); + assert!( + !formatted.contains("for (idx, value) in enumerate(values)"), + "formatter emitted parser-invalid tuple target parentheses, got:\n{formatted}" + ); + + let build_output = run_incan( + tmp.path(), + &["build", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success( + &build_output, + "incan build after formatting tuple-target list comprehension", + ); + Ok(()) +} + +#[test] +fn build_public_alias_of_imported_item_reexports_original_path_issue617() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "public_alias_import_reexport", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + fs::write( + src_dir.join("helper.incn"), + r#"pub def target(value: int) -> int: + """Return one incremented value.""" + return value + 1 +"#, + )?; + fs::write( + &main_path, + r#"from helper import target as target_builder + + +pub public_target = alias target_builder + + +def main() -> None: + """Exercise public alias re-export of an imported public function.""" + assert public_target(1) == 2 +"#, + )?; + + let output_dir = tmp.path().join("out"); + let build_output = run_incan( + tmp.path(), + &[ + "build", + main_path.to_str().ok_or("main path was not valid UTF-8")?, + output_dir.to_str().ok_or("output path was not valid UTF-8")?, + ], + )?; + assert_success(&build_output, "public alias of imported item build"); + + let generated_main = fs::read_to_string(output_dir.join("src/main.rs"))?; + assert!( + !generated_main.contains("pub use target_builder as public_target;"), + "public alias should not re-export the private local import binding, got:\n{generated_main}" + ); + assert!( + generated_main.contains("pub use crate::helper::target as public_target;") + || generated_main.contains("pub use helper::target as public_target;"), + "public alias should re-export the original imported path, got:\n{generated_main}" + ); + Ok(()) +} + +#[test] +fn build_pub_consumer_imports_public_alias_of_imported_item_issue617() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let producer_root = tmp.path().join("alias_lib"); + let producer_src = producer_root.join("src"); + fs::create_dir_all(&producer_src)?; + fs::write( + producer_root.join("incan.toml"), + r#"[project] +name = "alias_lib" +version = "0.1.0" +"#, + )?; + fs::write( + producer_src.join("helper.incn"), + r#"pub def target(value: int) -> int: + return value + 1 +"#, + )?; + fs::write( + producer_src.join("functions.incn"), + r#"from helper import target as target_impl + +pub public_target = alias target_impl +"#, + )?; + fs::write( + producer_src.join("lib.incn"), + r#"pub from functions import public_target +"#, + )?; + + let producer_build = run_incan(&producer_root, &["build", "--lib"])?; + assert_success(&producer_build, "producer build --lib for public alias issue617"); + + let manifest_path = producer_root.join("target").join("lib").join("alias_lib.incnlib"); + let manifest: serde_json::Value = serde_json::from_str(&fs::read_to_string(&manifest_path)?)?; + assert!( + manifest.pointer("/exports/aliases/0/projected_function").is_some(), + "callable alias export should include function projection metadata, got:\n{manifest}" + ); + + let consumer_root = tmp.path().join("alias_consumer"); + let consumer_main = write_minimal_project( + &consumer_root, + "alias_consumer", + r#" +[dependencies] +alias_lib = { path = "../alias_lib" } +"#, + )?; + fs::write( + &consumer_main, + r#"from pub::alias_lib import public_target + + +def main() -> None: + assert public_target(1) == 2 +"#, + )?; + + let output_dir = tmp.path().join("consumer_out"); + let consumer_build = run_incan( + &consumer_root, + &[ + "build", + consumer_main.to_str().ok_or("consumer main path was not valid UTF-8")?, + output_dir.to_str().ok_or("output path was not valid UTF-8")?, + ], + )?; + assert_success(&consumer_build, "pub consumer build for public alias issue617"); + Ok(()) +} + #[test] fn build_frozen_uses_existing_lockfile_without_network() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index ad1e6342e..44381b036 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4770,6 +4770,58 @@ def main() -> None: Ok(()) } + #[test] + fn test_loop_item_field_index_assignment_materializes_owned_value_issue616() + -> Result<(), Box> { + let output = Command::new(incan_debug_binary()) + .args([ + "run", + "-c", + r#" +@derive(Clone) +model Assignment: + output_name: str + +def names(assignments: list[Assignment]) -> list[str]: + mut output_names: list[str] = [] + for assignment in assignments: + existing_idx = index_of_name(output_names, assignment.output_name) + if existing_idx >= 0: + output_names[existing_idx] = assignment.output_name + else: + output_names.append(assignment.output_name) + return output_names + +def index_of_name(names: list[str], name: str) -> int: + for idx, current in enumerate(names): + if current == name: + return idx + return -1 + +def main() -> None: + result = names([Assignment(output_name="amount"), Assignment(output_name="amount")]) + println(result[0]) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "loop item field index-assignment regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["amount"], + "unexpected loop item field index-assignment output:\n{stdout}" + ); + Ok(()) + } + #[test] fn test_field_backed_by_value_method_args_do_not_require_user_clone_issue241() -> Result<(), Box> { From 9c8684998c4afb01e479aa29b2d99e19f26aa198 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 18:57:00 +0200 Subject: [PATCH 06/58] bugfix - fix release CI regressions --- src/frontend/typechecker/tests.rs | 45 ++++++++++++++----------------- tests/integration_tests.rs | 2 -- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index ae9d221c7..a457e83ab 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -8173,31 +8173,26 @@ def f(encoded: bytes) -> None: }, ) .map_err(|err| std::io::Error::other(format!("seed trait metadata: {err}")))?; - for path in ["demo::FileDescriptorSet"] { - checker - .rust_inspect_cache - .insert_test_item( - &manifest_dir, - RustItemMetadata { - canonical_path: path.to_string(), - definition_path: Some(path.to_string()), - visibility: RustVisibility::Public, - kind: RustItemKind::Type(RustTypeInfo { - methods: Vec::new(), - implemented_traits: if path.ends_with("FileDescriptorSet") { - vec![RustImplementedTrait { - path: "demo::Message".to_string(), - }] - } else { - Vec::new() - }, - fields: Vec::new(), - variants: Vec::new(), - }), - }, - ) - .map_err(|err| std::io::Error::other(format!("seed type metadata: {err}")))?; - } + let path = "demo::FileDescriptorSet"; + checker + .rust_inspect_cache + .insert_test_item( + &manifest_dir, + RustItemMetadata { + canonical_path: path.to_string(), + definition_path: Some(path.to_string()), + visibility: RustVisibility::Public, + kind: RustItemKind::Type(RustTypeInfo { + methods: Vec::new(), + implemented_traits: vec![RustImplementedTrait { + path: "demo::Message".to_string(), + }], + fields: Vec::new(), + variants: Vec::new(), + }), + }, + ) + .map_err(|err| std::io::Error::other(format!("seed type metadata: {err}")))?; checker .check_program(&ast) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 44381b036..2b0c28f48 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -6141,7 +6141,6 @@ async def main() -> None: fn test_run_std_regex_rfc059_surface() -> Result<(), Box> { let output = Command::new(incan_debug_binary()) .args(["run", "tests/fixtures/valid/std_regex_surface.incn"]) - .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( @@ -6201,7 +6200,6 @@ def main() -> None: println(err.message()) "#, ]) - .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( From d1df0bde61f0d62069e011635e3e65ab5668daa0 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 20:46:00 +0200 Subject: [PATCH 07/58] bugfix - fix union variants, static mutation, and pub aliases (#620, #621, #622) (#623) --- Cargo.lock | 18 +-- Cargo.toml | 2 +- src/backend/ir/emit/expressions/calls.rs | 60 ++++++-- src/backend/ir/emit/expressions/methods.rs | 21 ++- src/backend/ir/emit/expressions/mod.rs | 52 +++++-- src/backend/ir/emit/mod.rs | 75 +++++++++- src/backend/ir/emit/program.rs | 12 ++ src/backend/ir/emit/statements.rs | 4 +- src/frontend/typechecker/check_decl.rs | 127 +++++++++++++++- src/frontend/typechecker/check_expr/access.rs | 37 +++++ .../typechecker/collect/stdlib_imports.rs | 26 +++- src/frontend/typechecker/mod.rs | 6 + src/frontend/typechecker/tests.rs | 72 +++++++++- tests/integration_tests.rs | 135 ++++++++++++++++++ 14 files changed, 593 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92cf5774c..b33aa9752 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc2" +version = "0.3.0-rc3" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 9d0fdbd16..34822e300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc2" +version = "0.3.0-rc3" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index 8d5f1ee01..655905914 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -174,10 +174,30 @@ impl<'a> IrEmitter<'a> { target_ty: &IrType, union_qualifier: Option<&[String]>, ) -> Result, EmitError> { - if arg.ty.is_union() { + self.emit_union_payload_arg_for_site( + arg, + target_ty, + union_qualifier, + ValueUseSite::IncanCallArg { + target_ty: None, + callee_param: None, + in_return: false, + }, + ) + } + + /// Emit a concrete payload argument for a `Union[...]` target while preserving the caller's ownership site. + pub(super) fn emit_union_payload_arg_for_site( + &self, + arg: &TypedExpr, + target_ty: &IrType, + union_qualifier: Option<&[String]>, + site: ValueUseSite<'_>, + ) -> Result, EmitError> { + let Some(value_ty) = self.union_payload_candidate_type(arg, target_ty) else { return Ok(None); - } - let Some(variant_index) = target_ty.union_variant_index_for_member(&arg.ty) else { + }; + let Some(variant_index) = target_ty.union_variant_index_for_member(&value_ty) else { return Ok(None); }; let Some(members) = target_ty.union_members() else { @@ -188,17 +208,35 @@ impl<'a> IrEmitter<'a> { }; let variant_ident = quote::format_ident!("{}", IrType::union_variant_name(variant_index)); let union_path = self.emit_union_type_path_with_qualifier(target_ty, union_qualifier); - let emitted = self.emit_expr_for_use( - arg, - ValueUseSite::IncanCallArg { - target_ty: Some(member_ty), - callee_param: None, - in_return: false, - }, - )?; + let emitted = self.emit_expr_for_use(arg, Self::retarget_value_use_site(site, Some(member_ty)))?; Ok(Some(quote! { #union_path :: #variant_ident(#emitted) })) } + /// Return the concrete union-member payload type for an argument that may already be typed as the target union. + fn union_payload_candidate_type(&self, arg: &TypedExpr, target_ty: &IrType) -> Option { + if !arg.ty.is_union() { + return Some(arg.ty.clone()); + } + + let candidate_name = match &arg.kind { + IrExprKind::Struct { name, .. } => Some(name.as_str()), + IrExprKind::Call { func, .. } => match &func.kind { + IrExprKind::Var { + name, + ref_kind: VarRefKind::TypeName, + .. + } => Some(name.as_str()), + _ => None, + }, + _ => None, + }?; + target_ty + .union_members()? + .iter() + .find(|member| member.nominal_type_name() == Some(candidate_name)) + .cloned() + } + /// Emit a type-seeded literal argument for `None`/`Ok`/`Err` when possible. /// /// This helper rewrites constructor-shaped arguments into explicit generic forms (for example `None::`, `Ok:: IrEmitter<'a> { }); } - let rewritten_receiver = Self::rewrite_storage_root_expr(receiver, "__incan_static_value"); - let inner = self.emit_known_method_call(&rewritten_receiver, kind, &rewritten_args)?; let use_mut = super::method_kind_uses_mutable_receiver(kind); + let rewritten_receiver = if use_mut { + Self::rewrite_storage_root_expr_for_mut(receiver, "__incan_static_value") + } else { + Self::rewrite_storage_root_expr(receiver, "__incan_static_value") + }; + let inner = self.emit_known_method_call(&rewritten_receiver, kind, &rewritten_args)?; let wrapped = if use_mut { self.emit_storage_with_mut(receiver, inner) } else { @@ -721,7 +725,12 @@ impl<'a> IrEmitter<'a> { ) -> Result { if Self::expr_is_storage_rooted(receiver) { let (arg_bindings, rewritten_args) = self.materialize_storage_rooted_args(args)?; - let rewritten_receiver = Self::rewrite_storage_root_expr(receiver, "__incan_static_value"); + let use_mut = !matches!(arg_policy, MethodCallArgPolicy::PreserveShape); + let rewritten_receiver = if use_mut { + Self::rewrite_storage_root_expr_for_mut(receiver, "__incan_static_value") + } else { + Self::rewrite_storage_root_expr(receiver, "__incan_static_value") + }; let inner = self.emit_method_call_expr_with_result_use( &rewritten_receiver, method, @@ -732,10 +741,10 @@ impl<'a> IrEmitter<'a> { arg_policy, result_use_site, )?; - let wrapped = if matches!(arg_policy, MethodCallArgPolicy::PreserveShape) { - self.emit_storage_with_ref(receiver, inner) - } else { + let wrapped = if use_mut { self.emit_storage_with_mut(receiver, inner) + } else { + self.emit_storage_with_ref(receiver, inner) }?; return Ok(quote! { #(#arg_bindings)* diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 102b63ccb..27d2810b5 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -350,11 +350,16 @@ impl<'a> IrEmitter<'a> { /// expression is emitted. Non-aggregate expressions are emitted normally, then the planned conversion is applied to /// the resulting token stream. pub(super) fn emit_expr_for_use(&self, expr: &TypedExpr, site: ValueUseSite<'_>) -> Result { - if matches!(site, ValueUseSite::CollectionElement { .. }) - && let Some(target_ty) = Self::use_site_target_ty(site) - && let Some(wrapped) = self.emit_inference_seeded_literal_arg(expr, target_ty)? - { - return Ok(wrapped); + let resolved_target_ty = Self::use_site_target_ty(site).map(|ty| self.resolve_type_aliases_for_emit(ty)); + if let Some(target_ty) = resolved_target_ty.as_ref() { + if let Some(wrapped) = self.emit_union_payload_arg_for_site(expr, target_ty, None, site)? { + return Ok(wrapped); + } + if matches!(site, ValueUseSite::CollectionElement { .. }) + && let Some(wrapped) = self.emit_inference_seeded_literal_arg(expr, target_ty)? + { + return Ok(wrapped); + } } match &expr.kind { @@ -374,7 +379,7 @@ impl<'a> IrEmitter<'a> { return self.emit_expr_for_use(inner, site); } IrExprKind::List(items) => { - let site_item_ty = match Self::use_site_target_ty(site) { + let site_item_ty = match resolved_target_ty.as_ref() { Some(IrType::List(elem)) => Some(elem.as_ref()), _ => None, }; @@ -386,7 +391,7 @@ impl<'a> IrEmitter<'a> { return self.emit_list_literal_entries(items, item_target_ty); } IrExprKind::Dict(pairs) => { - let (site_key_ty, site_value_ty) = match Self::use_site_target_ty(site) { + let (site_key_ty, site_value_ty) = match resolved_target_ty.as_ref() { Some(IrType::Dict(key, value)) => (Some(key.as_ref()), Some(value.as_ref())), _ => (None, None), }; @@ -402,7 +407,7 @@ impl<'a> IrEmitter<'a> { if items.is_empty() { return Ok(quote! { std::collections::HashSet::new() }); } - let site_item_ty = match Self::use_site_target_ty(site) { + let site_item_ty = match resolved_target_ty.as_ref() { Some(IrType::Set(elem)) => Some(elem.as_ref()), _ => None, }; @@ -425,7 +430,7 @@ impl<'a> IrEmitter<'a> { return Ok(quote! { [#(#item_tokens),*].into_iter().collect::>() }); } IrExprKind::Tuple(items) => { - let site_tuple_items = match Self::use_site_target_ty(site) { + let site_tuple_items = match resolved_target_ty.as_ref() { Some(IrType::Tuple(items)) => Some(items.as_slice()), _ => None, }; @@ -483,13 +488,18 @@ impl<'a> IrEmitter<'a> { callable_signature, canonical_path, } => { + let target_site = if let Some(target_ty) = resolved_target_ty.as_ref() { + Self::retarget_value_use_site(site, Some(target_ty)) + } else { + site + }; return self.emit_call_expr_for_use( func, type_args, args, callable_signature.as_ref(), canonical_path.as_deref(), - site, + target_site, ); } _ => {} @@ -555,15 +565,31 @@ impl<'a> IrEmitter<'a> { Self::expr_storage_root(expr).is_some() } + /// Rewrite a static/storage binding root to the local borrowed value used inside `with_ref`. pub(super) fn rewrite_storage_root_expr(expr: &TypedExpr, local_name: &str) -> TypedExpr { + Self::rewrite_storage_root_expr_inner(expr, local_name, false) + } + + /// Rewrite a static/storage binding root to the local mutable borrow used inside `with_mut`. + pub(super) fn rewrite_storage_root_expr_for_mut(expr: &TypedExpr, local_name: &str) -> TypedExpr { + Self::rewrite_storage_root_expr_inner(expr, local_name, true) + } + + /// Rewrite the root of a storage-backed path while preserving the original field/index chain. + fn rewrite_storage_root_expr_inner(expr: &TypedExpr, local_name: &str, mutable_root: bool) -> TypedExpr { let replacement = || { + let ty = if mutable_root { + IrType::RefMut(Box::new(expr.ty.clone())) + } else { + expr.ty.clone() + }; TypedExpr::new( IrExprKind::Var { name: local_name.to_string(), access: super::super::expr::VarAccess::Read, ref_kind: VarRefKind::Value, }, - expr.ty.clone(), + ty, ) }; @@ -575,14 +601,14 @@ impl<'a> IrEmitter<'a> { } => replacement(), IrExprKind::Field { object, field } => TypedExpr::new( IrExprKind::Field { - object: Box::new(Self::rewrite_storage_root_expr(object, local_name)), + object: Box::new(Self::rewrite_storage_root_expr_inner(object, local_name, mutable_root)), field: field.clone(), }, expr.ty.clone(), ), IrExprKind::Index { object, index } => TypedExpr::new( IrExprKind::Index { - object: Box::new(Self::rewrite_storage_root_expr(object, local_name)), + object: Box::new(Self::rewrite_storage_root_expr_inner(object, local_name, mutable_root)), index: index.clone(), }, expr.ty.clone(), diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 23a578415..841a47320 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -220,6 +220,8 @@ pub struct IrEmitter<'a> { struct_field_defaults: std::collections::HashMap<(String, String), super::IrExpr>, /// Constructor metadata variants for source-defined structs that share a simple name across modules. struct_constructor_metadata: HashMap>, + /// Transparent local type aliases keyed by alias name. + type_aliases: HashMap, /// Incan `rusttype` aliases that should use compiler-owned call conversion rules at the surface boundary. rusttype_alias_names: HashSet, /// Method signature lookup for Incan-owned nominal receivers, including imported modules. @@ -326,6 +328,7 @@ impl<'a> IrEmitter<'a> { struct_field_descriptions: std::collections::HashMap::new(), struct_field_defaults: std::collections::HashMap::new(), struct_constructor_metadata: HashMap::new(), + type_aliases: HashMap::new(), rusttype_alias_names: HashSet::new(), method_signatures: HashMap::new(), method_signature_type_params: HashMap::new(), @@ -352,6 +355,67 @@ impl<'a> IrEmitter<'a> { } } + /// Resolve transparent type aliases before emission decisions that need structural type information. + pub(in crate::backend::ir::emit) fn resolve_type_aliases_for_emit(&self, ty: &IrType) -> IrType { + let mut visiting = HashSet::new(); + self.resolve_type_aliases_for_emit_inner(ty, &mut visiting) + } + + /// Resolve nested transparent aliases while preserving cycles as their original alias names. + fn resolve_type_aliases_for_emit_inner(&self, ty: &IrType, visiting: &mut HashSet) -> IrType { + match ty { + IrType::Struct(name) | IrType::NamedGeneric(name, _) if self.type_aliases.contains_key(name) => { + if !visiting.insert(name.clone()) { + return ty.clone(); + } + let Some(target) = self.type_aliases.get(name) else { + visiting.remove(name); + return ty.clone(); + }; + let resolved = self.resolve_type_aliases_for_emit_inner(target, visiting); + visiting.remove(name); + resolved + } + IrType::List(inner) => IrType::List(Box::new(self.resolve_type_aliases_for_emit_inner(inner, visiting))), + IrType::Dict(key, value) => IrType::Dict( + Box::new(self.resolve_type_aliases_for_emit_inner(key, visiting)), + Box::new(self.resolve_type_aliases_for_emit_inner(value, visiting)), + ), + IrType::Set(inner) => IrType::Set(Box::new(self.resolve_type_aliases_for_emit_inner(inner, visiting))), + IrType::Tuple(items) => IrType::Tuple( + items + .iter() + .map(|item| self.resolve_type_aliases_for_emit_inner(item, visiting)) + .collect(), + ), + IrType::Option(inner) => { + IrType::Option(Box::new(self.resolve_type_aliases_for_emit_inner(inner, visiting))) + } + IrType::Result(ok, err) => IrType::Result( + Box::new(self.resolve_type_aliases_for_emit_inner(ok, visiting)), + Box::new(self.resolve_type_aliases_for_emit_inner(err, visiting)), + ), + IrType::NamedGeneric(name, args) => IrType::NamedGeneric( + name.clone(), + args.iter() + .map(|arg| self.resolve_type_aliases_for_emit_inner(arg, visiting)) + .collect(), + ), + IrType::Function { params, ret } => IrType::Function { + params: params + .iter() + .map(|param| self.resolve_type_aliases_for_emit_inner(param, visiting)) + .collect(), + ret: Box::new(self.resolve_type_aliases_for_emit_inner(ret, visiting)), + }, + IrType::Ref(inner) => IrType::Ref(Box::new(self.resolve_type_aliases_for_emit_inner(inner, visiting))), + IrType::RefMut(inner) => { + IrType::RefMut(Box::new(self.resolve_type_aliases_for_emit_inner(inner, visiting))) + } + _ => ty.clone(), + } + } + pub(super) fn emit_module_static_init_call(&self) -> TokenStream { if *self.module_has_local_statics.borrow() { let init_fn = Self::rust_ident("__incan_init_module_statics"); @@ -676,13 +740,20 @@ impl<'a> IrEmitter<'a> { } IrDeclKind::TypeAlias { name, - is_rusttype: true, + type_params, + ty, + is_rusttype, .. } => { if skip_ambiguous && self.ambiguous_type_names.contains(name) { continue; } - self.rusttype_alias_names.insert(name.clone()); + if type_params.is_empty() && !is_rusttype { + self.type_aliases.insert(name.clone(), ty.clone()); + } + if *is_rusttype { + self.rusttype_alias_names.insert(name.clone()); + } } IrDeclKind::Impl(i) => { for method in &i.methods { diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index 7e8684ff6..a028d2382 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -2163,6 +2163,18 @@ impl<'a> IrEmitter<'a> { .insert((e.name.clone(), alias.name.clone()), alias.target.clone()); } } + if let IrDeclKind::TypeAlias { + name, + type_params, + ty, + is_rusttype, + .. + } = &decl.kind + && type_params.is_empty() + && !is_rusttype + { + self.type_aliases.insert(name.clone(), ty.clone()); + } if let IrDeclKind::TypeAlias { name, is_rusttype: true, diff --git a/src/backend/ir/emit/statements.rs b/src/backend/ir/emit/statements.rs index 9eda8598b..f1b76b14f 100644 --- a/src/backend/ir/emit/statements.rs +++ b/src/backend/ir/emit/statements.rs @@ -990,11 +990,11 @@ impl<'a> IrEmitter<'a> { let rhs_ident = format_ident!("{}", rhs_name); let rewritten_target = match target { AssignTarget::Field { object, field } => AssignTarget::Field { - object: Box::new(Self::rewrite_storage_root_expr(object, local_name)), + object: Box::new(Self::rewrite_storage_root_expr_for_mut(object, local_name)), field: field.clone(), }, AssignTarget::Index { object, index } => AssignTarget::Index { - object: Box::new(Self::rewrite_storage_root_expr(object, local_name)), + object: Box::new(Self::rewrite_storage_root_expr_for_mut(object, local_name)), index: index.clone(), }, _ => { diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index 8e2c48a27..7a8158567 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -11,7 +11,7 @@ use crate::frontend::testing_markers::{ TestingFixtureMarkerArgs, TestingMarkerSemantics, load_testing_marker_semantics, resolve_testing_fixture_marker_args, }; -use crate::frontend::typechecker::helpers::{dict_ty, list_ty}; +use crate::frontend::typechecker::helpers::{collection_type_id, dict_ty, list_ty}; use super::{DecoratedFunctionBindingInfo, DecoratedMethodBindingInfo, TestingFixtureInfo, TypeChecker, YieldContext}; use incan_core::interop::{RustItemKind, RustItemMetadata, RustTraitAssoc}; @@ -20,6 +20,7 @@ use incan_core::lang::derives::{self, DeriveId}; use incan_core::lang::magic_methods; use incan_core::lang::stdlib; use incan_core::lang::traits::{self as builtin_traits, TraitId}; +use incan_core::lang::types::collections::CollectionTypeId; use incan_semantics_core::SurfaceModifierTypeCheck; use std::collections::{HashMap, HashSet}; @@ -2439,6 +2440,7 @@ impl TypeChecker { // Define fields in scope for field in &model.fields { let ty = self.resolve_type_checked(&field.node.ty); + self.validate_direct_recursive_model_field(&model.name, &ty, field.span); self.symbols.define(Symbol { name: field.node.name.clone(), kind: SymbolKind::Field(FieldInfo { @@ -2485,6 +2487,129 @@ impl TypeChecker { self.symbols.exit_scope(); } + /// Reject model fields whose resolved type contains the model itself without an indirection boundary. + fn validate_direct_recursive_model_field(&mut self, model_name: &str, field_ty: &ResolvedType, span: Span) { + let mut visiting = HashSet::new(); + if self.type_contains_direct_recursive_model(field_ty, model_name, &mut visiting) { + self.errors.push(CompileError::type_error( + format!( + "Model '{model_name}' has a direct recursive field type '{field_ty}'. Use an indirection such as List[...] for recursive payloads." + ), + span, + )); + } + } + + /// Return whether a type contains the target model through only inline Rust-layout positions. + fn type_contains_direct_recursive_model( + &self, + ty: &ResolvedType, + model_name: &str, + visiting: &mut HashSet, + ) -> bool { + match ty { + ResolvedType::Named(name) => { + self.nominal_type_contains_direct_recursive_model(name, &[], model_name, visiting) + } + ResolvedType::Generic(name, args) if name == UNION_TYPE_NAME => args + .iter() + .any(|arg| self.type_contains_direct_recursive_model(arg, model_name, visiting)), + ResolvedType::Generic(name, args) => match collection_type_id(name.as_str()) { + Some( + CollectionTypeId::List + | CollectionTypeId::Dict + | CollectionTypeId::Set + | CollectionTypeId::FrozenList + | CollectionTypeId::FrozenDict + | CollectionTypeId::FrozenSet + | CollectionTypeId::Generator, + ) => false, + Some(CollectionTypeId::Tuple | CollectionTypeId::Option | CollectionTypeId::Result) => args + .iter() + .any(|arg| self.type_contains_direct_recursive_model(arg, model_name, visiting)), + None => self.nominal_type_contains_direct_recursive_model(name, args, model_name, visiting), + }, + ResolvedType::Tuple(items) => items + .iter() + .any(|item| self.type_contains_direct_recursive_model(item, model_name, visiting)), + ResolvedType::Ref(_) + | ResolvedType::RefMut(_) + | ResolvedType::FrozenList(_) + | ResolvedType::FrozenDict(_, _) + | ResolvedType::FrozenSet(_) + | ResolvedType::Function(_, _) => false, + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Numeric(_) + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + | ResolvedType::TypeVar(_) + | ResolvedType::SelfType + | ResolvedType::RustPath(_) + | ResolvedType::CallSiteInfer + | ResolvedType::Unknown => false, + } + } + + /// Follow known nominal field types to find direct recursive model layouts. + fn nominal_type_contains_direct_recursive_model( + &self, + type_name: &str, + type_args: &[ResolvedType], + model_name: &str, + visiting: &mut HashSet, + ) -> bool { + if type_name == model_name { + return true; + } + + let visit_key = if type_args.is_empty() { + type_name.to_string() + } else { + format!( + "{}[{}]", + type_name, + type_args.iter().map(ToString::to_string).collect::>().join(", ") + ) + }; + if !visiting.insert(visit_key.clone()) { + return false; + } + + let result = match self.lookup_semantic_type_info(type_name) { + Some(TypeInfo::Model(info)) => { + let subst = type_param_subst_map(&info.type_params, type_args); + info.fields.values().any(|field| { + let field_ty = substitute_resolved_type(&field.ty, &subst); + let field_ty = self.expand_type_aliases(field_ty); + self.type_contains_direct_recursive_model(&field_ty, model_name, visiting) + }) + } + Some(TypeInfo::Class(info)) => { + let subst = type_param_subst_map(&info.type_params, type_args); + info.fields.values().any(|field| { + let field_ty = substitute_resolved_type(&field.ty, &subst); + let field_ty = self.expand_type_aliases(field_ty); + self.type_contains_direct_recursive_model(&field_ty, model_name, visiting) + }) + } + Some(TypeInfo::Newtype(info)) => { + let subst = type_param_subst_map(&info.type_params, type_args); + let underlying = substitute_resolved_type(&info.underlying, &subst); + let underlying = self.expand_type_aliases(underlying); + self.type_contains_direct_recursive_model(&underlying, model_name, visiting) + } + Some(TypeInfo::Enum(_) | TypeInfo::Builtin | TypeInfo::TypeAlias) | None => false, + }; + + visiting.remove(&visit_key); + result + } + fn check_validate_derive_model(&mut self, model: &ModelDecl) { // Validate that validate() exists and has the expected signature. let Some(TypeInfo::Model(info)) = self.lookup_type_info(&model.name) else { diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 79c5a19f9..165c2bba5 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -3065,6 +3065,12 @@ impl TypeChecker { } } + if let Some(ret) = + self.resolve_union_clone_trait_method_call(&base_ty, method, type_args, args, &arg_types, span) + { + return ret; + } + if let ResolvedType::Generic(type_name, _type_args) = &base_ty && let Some(type_info) = self.lookup_semantic_type_info(type_name).cloned() { @@ -3311,6 +3317,37 @@ impl TypeChecker { ResolvedType::Unknown } + /// Resolve methods supplied by Clone for anonymous union wrappers. + fn resolve_union_clone_trait_method_call( + &mut self, + receiver_ty: &ResolvedType, + method: &str, + type_args: &[Spanned], + args: &[CallArg], + arg_types: &[ResolvedType], + span: Span, + ) -> Option { + if !receiver_ty.is_union() { + return None; + } + + let adoption = TypeBoundInfo { + name: core_traits::as_str(TraitId::Clone).to_string(), + source_name: None, + type_args: Vec::new(), + module_path: None, + }; + let method_info = self.trait_method_info_resolved_for_adoption(&adoption, method, span)?; + if !self.is_clone_type(receiver_ty) { + self.errors.push(CompileError::type_error( + format!("Union type '{receiver_ty}' cannot use '{method}(...)' because not all variants are cloneable"), + span, + )); + return Some(ResolvedType::Unknown); + } + Some(self.check_generic_method_call(method, method_info, type_args, args, arg_types, span, receiver_ty)) + } + /// Return known method result types for Rust imports when rust-inspect metadata is not specific enough. fn known_rust_path_method_return(path: &str, method: &str) -> Option { use incan_core::lang::types::numerics::NumericTypeId as N; diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index c0f3c00e0..b25c3ca88 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -16,7 +16,7 @@ use crate::frontend::typechecker::type_info::RustTraitImportInfo; use crate::library_manifest::{ AliasExport, ClassExport, ConstExport, EnumExport, EnumValueExport, EnumValueTypeExport, FieldExport, FunctionExport, LibraryManifest, MethodExport, ModelExport, NewtypeExport, ParamExport, ParamKindExport, - PartialExport, ReceiverExport, StaticExport, TraitExport, TypeBoundExport, TypeParamExport, + PartialExport, ReceiverExport, StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, TypeParamExport, resolved_type_from_manifest_type_ref, }; use incan_core::interop::{RustItemKind, RustTraitAssoc, is_rust_capability_bound}; @@ -36,7 +36,7 @@ enum ManifestExportRef<'a> { enum_name: &'a str, fields: &'a [crate::library_manifest::TypeRef], }, - TypeAlias, + TypeAlias(&'a TypeAliasExport), Newtype(&'a NewtypeExport), Const(&'a ConstExport), Static(&'a StaticExport), @@ -713,7 +713,7 @@ impl TypeChecker { ManifestExportRef::Partial(export) => SymbolKind::Function(self.partial_info_from_manifest(export)), ManifestExportRef::Trait(export) => SymbolKind::Trait(self.trait_info_from_manifest(export)), ManifestExportRef::Enum(export) => SymbolKind::Type(TypeInfo::Enum(self.enum_info_from_manifest(export))), - ManifestExportRef::TypeAlias => SymbolKind::Type(TypeInfo::TypeAlias), + ManifestExportRef::TypeAlias(_) => SymbolKind::Type(TypeInfo::TypeAlias), ManifestExportRef::Newtype(export) => { SymbolKind::Type(TypeInfo::Newtype(self.newtype_info_from_manifest(export))) } @@ -907,8 +907,8 @@ impl TypeChecker { }); } } - if manifest.exports.type_aliases.iter().any(|item| item.name == name) { - return Some(ManifestExportRef::TypeAlias); + if let Some(item) = manifest.exports.type_aliases.iter().find(|item| item.name == name) { + return Some(ManifestExportRef::TypeAlias(item)); } if let Some(item) = manifest.exports.newtypes.iter().find(|item| item.name == name) { return Some(ManifestExportRef::Newtype(item)); @@ -922,6 +922,7 @@ impl TypeChecker { None } + /// Return whether a manifest export introduces a type-like name into the importing module. fn manifest_export_is_type(export: &ManifestExportRef<'_>) -> bool { matches!( export, @@ -929,7 +930,7 @@ impl TypeChecker { | ManifestExportRef::Class(_) | ManifestExportRef::Trait(_) | ManifestExportRef::Enum(_) - | ManifestExportRef::TypeAlias + | ManifestExportRef::TypeAlias(_) | ManifestExportRef::Newtype(_) ) } @@ -977,7 +978,18 @@ impl TypeChecker { enum_name: enum_name.to_string(), fields: fields.iter().map(resolved_type_from_manifest_type_ref).collect(), }), - ManifestExportRef::TypeAlias => SymbolKind::Type(TypeInfo::TypeAlias), + ManifestExportRef::TypeAlias(export) => { + let mut target = resolved_type_from_manifest_type_ref(&export.target); + Self::remap_resolved_type_with_import_aliases(&mut target, imported_type_aliases); + self.type_aliases.insert( + local_name.clone(), + crate::frontend::typechecker::TypeAliasTarget { + type_params: export.type_params.iter().map(|param| param.name.clone()).collect(), + target, + }, + ); + SymbolKind::Type(TypeInfo::TypeAlias) + } ManifestExportRef::Newtype(export) => { SymbolKind::Type(TypeInfo::Newtype(self.newtype_info_from_manifest(export))) } diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 265f1bd2f..691b41547 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -3815,6 +3815,12 @@ impl TypeChecker { } }; + let expanded_actual = self.expand_type_aliases(actual.clone()); + let expanded_expected = self.expand_type_aliases(expected.clone()); + if &expanded_actual != actual || &expanded_expected != expected { + return self.types_compatible(&expanded_actual, &expanded_expected); + } + if actual == expected { return true; } diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index a457e83ab..edc8a0cdc 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -15,7 +15,8 @@ use crate::library_manifest::{ AliasExport, ClassExport, ConstExport, EnumExport, EnumValueExport, EnumValueTypeExport, EnumVariantExport, FunctionExport, LibraryContractMetadata, LibraryExports, LibraryManifest, LibraryRustAbi, MethodExport, ModelExport, ParamExport, ParamKindExport, PartialExport, PartialPresetExport, PartialTargetKindExport, - PresetValueExport, ReceiverExport, StaticExport, TraitExport, TypeBoundExport, TypeParamExport, TypeRef, + PresetValueExport, ReceiverExport, StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, TypeParamExport, + TypeRef, }; #[cfg(feature = "rust_inspect")] use crate::rust_inspect::{Inspector, InspectorConfig, write_borrowed_param_probe_crate, write_substrait_probe_crate}; @@ -1464,7 +1465,13 @@ fn library_index_with_mylib_exports() -> LibraryManifestIndex { }], derives: Vec::new(), }], - type_aliases: Vec::new(), + type_aliases: vec![TypeAliasExport { + name: "WidgetAlias".to_string(), + type_params: Vec::new(), + target: TypeRef::Named { + name: "Widget".to_string(), + }, + }], newtypes: Vec::new(), consts: vec![ConstExport { name: "DEFAULT_NAME".to_string(), @@ -2887,6 +2894,49 @@ def normalize(value: int | str) -> str: ); } +#[test] +fn test_union_clone_method_typechecks_when_members_are_cloneable() { + let source = r#" +@derive(Clone) +model Leaf: + value: int + +@derive(Clone) +model Pair: + args: List[Expr] + +type Expr = Union[Leaf, Pair] + +def clone_expr(expr: Expr) -> Expr: + return expr.clone() +"#; + assert!(check_str(source).is_ok()); +} + +#[test] +fn test_union_model_variants_reject_direct_recursive_payload_without_indirection() { + let source = r#" +@derive(Clone) +model Leaf: + value: int + +@derive(Clone) +model Pair: + left: Expr + right: Expr + +type Expr = Union[Leaf, Pair] +"#; + let errors = check_str_err(source, "direct recursive union model payload should be rejected"); + assert!( + errors + .iter() + .any(|error| error.message.contains("direct recursive") && error.message.contains("Pair")), + "expected direct recursive model diagnostic, got: {:?}", + errors.iter().map(|error| &error.message).collect::>() + ); +} + #[test] fn test_match_pattern_alternation_typechecks_and_counts_exhaustiveness() { let source = r#" @@ -10974,6 +11024,24 @@ def build() -> Widget: assert!(result.is_ok(), "expected pub import to typecheck, got: {result:?}"); } +#[test] +fn test_pub_from_import_type_alias_is_transparent() { + let source = r#" +from pub::mylib import WidgetAlias, make_widget + +def keep(widget: WidgetAlias) -> WidgetAlias: + return widget + +def build() -> WidgetAlias: + return keep(make_widget("ok")) +"#; + let result = check_str_with_library_index(source, library_index_with_mylib_exports()); + assert!( + result.is_ok(), + "expected pub-imported type alias to behave transparently, got: {result:?}" + ); +} + #[test] fn test_pub_from_import_manifest_partial_callable_typechecks() { let source = r#" diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 2b0c28f48..45668ef65 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2296,6 +2296,36 @@ def main() -> None: ); } +#[test] +fn test_static_list_index_assignment_and_remove_compile_and_run() -> Result<(), Box> { + let source = r#" +static entries: list[int] = [] + +def main() -> None: + entries.append(1) + entries[0] = 2 + println(entries[0]) + entries.remove(0) + entries.append(3) + println(entries[0]) +"#; + let output = Command::new(incan_debug_binary()) + .args(["run", "-c", source]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + + assert!( + output.status.success(), + "expected static list index mutation program to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, ["2", "3"], "unexpected static list mutation output"); + Ok(()) +} + #[test] fn test_list_concatenation_plus_operator_runs() -> Result<(), Box> { let source = r#" @@ -4376,6 +4406,111 @@ def main() -> None: Ok(()) } + #[test] + fn test_union_model_variants_compile_and_run() -> Result<(), Box> { + let output = Command::new(incan_debug_binary()) + .args([ + "run", + "-c", + r#" +@derive(Clone) +model Leaf: + value: int + +@derive(Clone) +model Pair: + args: list[Expr] + +type Expr = Union[Leaf, Pair] + +def pair() -> Expr: + return Pair(args=[Leaf(value=1), Leaf(value=2)]) + +def clone_expr(expr: Expr) -> Expr: + return expr.clone() + +def sum_expr(expr: Expr) -> int: + match expr: + Leaf(leaf) => + return leaf.value + Pair(pair) => + return sum_expr(pair.args[0]) + +def main() -> None: + println(sum_expr(clone_expr(pair()))) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "union model variant run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["1"], "unexpected union model variant output:\n{stdout}"); + Ok(()) + } + + #[test] + fn test_imported_union_alias_list_field_compiles_issue622() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path().join("union_list_cross_module_alias_repro"); + fs::create_dir_all(project_root.join("src"))?; + fs::write( + project_root.join("incan.toml"), + "[project]\nname = \"union_list_cross_module_alias_repro\"\nversion = \"0.1.0\"\n", + )?; + fs::write( + project_root.join("src/exprs.incn"), + r#" +@derive(Clone) +pub model Leaf: + pub value: int + +@derive(Clone) +pub model Pair: + pub args: list[Expr] + +pub type Expr = Union[Leaf, Pair] + +pub def pair() -> Expr: + return Pair(args=[Leaf(value=1), Leaf(value=2)]) +"#, + )?; + fs::write( + project_root.join("src/lib.incn"), + r#" +from exprs import Expr, Leaf, Pair, pair + +def sum_expr(expr: Expr) -> int: + match expr: + Leaf(leaf) => return leaf.value + Pair(pair_expr) => return sum_expr(pair_expr.args[0]) + +pub def main_value() -> int: + return sum_expr(pair()) +"#, + )?; + + let output = Command::new(incan_debug_binary()) + .args(["build", "--lib"]) + .current_dir(&project_root) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "expected imported union alias list-field project to build for #622.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn test_issue562_type_alias_dict_and_union_surfaces_compile_and_run() -> Result<(), Box> { let output = Command::new(incan_debug_binary()) From ed5a3f3daf94b8a5186226b3a032d32fabaa9765 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 21 May 2026 22:27:11 +0200 Subject: [PATCH 08/58] bugfix - preserve f-string debug and list formatting (#624, #625) (#626) --- Cargo.lock | 18 ++++---- Cargo.toml | 2 +- crates/incan_core/src/lang/trait_bounds.rs | 1 + crates/incan_syntax/src/ast/exprs.rs | 8 +++- crates/incan_syntax/src/parser/expr.rs | 53 +++++++++++++++++++--- crates/incan_syntax/src/parser/tests.rs | 49 ++++++++++++++++++-- src/backend/ir/emit/decls/functions.rs | 4 +- src/backend/ir/emit/decls/mutation_scan.rs | 4 +- src/backend/ir/emit/expressions/format.rs | 14 ++++-- src/backend/ir/emit/program.rs | 4 +- src/backend/ir/emit/statements.rs | 2 +- src/backend/ir/expr.rs | 33 +++++++++++++- src/backend/ir/lower/expr/mod.rs | 10 ++-- src/backend/ir/lower/stmt.rs | 2 +- src/backend/ir/trait_bound_inference.rs | 29 ++++++++---- src/cli/test_runner/execution.rs | 2 +- src/format/formatter/expressions.rs | 5 +- src/format/mod.rs | 12 +++++ src/frontend/ast_walk.rs | 2 +- src/frontend/typechecker/check_expr/mod.rs | 4 +- src/frontend/typechecker/mod.rs | 4 +- src/lsp/backend.rs | 6 +-- src/lsp/call_site_type_args.rs | 4 +- tests/integration_tests.rs | 43 ++++++++++++++++++ 24 files changed, 256 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b33aa9752..d04a61d8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc3" +version = "0.3.0-rc4" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 34822e300..6d639ef77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc3" +version = "0.3.0-rc4" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/lang/trait_bounds.rs b/crates/incan_core/src/lang/trait_bounds.rs index 7fdac0437..763bb80fc 100644 --- a/crates/incan_core/src/lang/trait_bounds.rs +++ b/crates/incan_core/src/lang/trait_bounds.rs @@ -126,6 +126,7 @@ pub mod rust { pub const CLONE: &str = "Clone"; // Formatting + pub const DEBUG: &str = "std::fmt::Debug"; pub const DISPLAY: &str = "std::fmt::Display"; // Arithmetic ops diff --git a/crates/incan_syntax/src/ast/exprs.rs b/crates/incan_syntax/src/ast/exprs.rs index 95c26e49f..2788f22b0 100644 --- a/crates/incan_syntax/src/ast/exprs.rs +++ b/crates/incan_syntax/src/ast/exprs.rs @@ -182,10 +182,16 @@ pub struct ScopedSurfaceOwner { pub call: Option, } +#[derive(Debug, Clone, PartialEq)] +pub enum FStringFormat { + Display, + Debug, +} + #[derive(Debug, Clone, PartialEq)] pub enum FStringPart { Literal(String), - Expr(Spanned), + Expr { expr: Spanned, format: FStringFormat }, } /// Parsed integer literal with the **source substring** used for formatting. diff --git a/crates/incan_syntax/src/parser/expr.rs b/crates/incan_syntax/src/parser/expr.rs index 22f0d2ba6..650bbb05b 100644 --- a/crates/incan_syntax/src/parser/expr.rs +++ b/crates/incan_syntax/src/parser/expr.rs @@ -1269,17 +1269,21 @@ impl<'a> Parser<'a> { } } + /// Convert lexer f-string segments into parsed AST parts while preserving interpolation spans and format markers. fn convert_fstring_parts(&self, parts: &[LexFStringPart]) -> Vec { parts .iter() .map(|p| match p { LexFStringPart::Literal(s) => FStringPart::Literal(s.clone()), LexFStringPart::Expr { text, offset } => { - // Parse simple field access chains like "user.name" or "obj.field.sub" let expr_span = Span::new(*offset, offset + text.len() + 2); - let mut expr = self.parse_fstring_expr(text); + let (expr_text, format) = split_fstring_format(text); + let mut expr = self.parse_fstring_expr(expr_text); self.shift_expr_spans(&mut expr, offset + 1); - FStringPart::Expr(Spanned::new(expr, expr_span)) + FStringPart::Expr { + expr: Spanned::new(expr, expr_span), + format, + } } }) .collect() @@ -1441,8 +1445,8 @@ impl<'a> Parser<'a> { } Expr::FString(parts) => { for part in parts { - if let FStringPart::Expr(value) = part { - self.shift_spanned_expr(value, offset); + if let FStringPart::Expr { expr, .. } = part { + self.shift_spanned_expr(expr, offset); } } } @@ -1547,7 +1551,6 @@ impl<'a> Parser<'a> { next_leading = 0; } } - self.expect(&TokenKind::Dedent, "Expected dedent after match body")?; let end = self.tokens[self.pos - 1].span.end; Ok(Spanned::new( @@ -2475,3 +2478,41 @@ impl<'a> Parser<'a> { } } + +/// Split a raw f-string interpolation body into expression text plus the supported top-level format marker. +fn split_fstring_format(text: &str) -> (&str, FStringFormat) { + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + let mut format_colon = None; + + for (idx, ch) in text.char_indices() { + if let Some(active_quote) = quote { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == active_quote { + quote = None; + } + continue; + } + + match ch { + '\'' | '"' => quote = Some(ch), + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + ':' if depth == 0 => format_colon = Some(idx), + _ => {} + } + } + + if let Some(idx) = format_colon { + let spec = text[idx + 1..].trim(); + if spec == "?" { + return (text[..idx].trim_end(), FStringFormat::Debug); + } + } + + (text, FStringFormat::Display) +} diff --git a/crates/incan_syntax/src/parser/tests.rs b/crates/incan_syntax/src/parser/tests.rs index b795b115f..85d3da308 100644 --- a/crates/incan_syntax/src/parser/tests.rs +++ b/crates/incan_syntax/src/parser/tests.rs @@ -3113,14 +3113,14 @@ def main() -> int: }; let first_expr = match &parts[1] { - FStringPart::Expr(expr) => expr, + FStringPart::Expr { expr, .. } => expr, _ => panic!("Expected first interpolation expression"), }; assert_eq!(first_expr.span.start, first_expected_start); assert_eq!(first_expr.span.end, first_expected_start + "{title}".len()); let second_expr = match &parts[3] { - FStringPart::Expr(expr) => expr, + FStringPart::Expr { expr, .. } => expr, _ => panic!("Expected second interpolation expression"), }; assert_eq!(second_expr.span.start, second_expected_start); @@ -3155,7 +3155,7 @@ def main() -> int: }; let interpolation = match &parts[1] { - FStringPart::Expr(expr) => expr, + FStringPart::Expr { expr, .. } => expr, _ => panic!("Expected interpolation expression"), }; @@ -3166,6 +3166,45 @@ def main() -> int: Ok(()) } + #[test] + fn test_parse_fstring_debug_format_marker() -> Result<(), Vec> { + let source = "def render(columns: list[str]) -> str:\n return f\"columns: {columns:?}\"\n"; + let program = parse_str(source)?; + + let function = match &program.declarations[0].node { + Declaration::Function(f) => f, + _ => panic!("Expected function"), + }; + + let return_expr = match &function.body[0].node { + Statement::Return(Some(expr)) => expr, + _ => panic!("Expected return with expression"), + }; + + let parts = match &return_expr.node { + Expr::FString(parts) => parts, + _ => panic!("Expected f-string expression"), + }; + + let expected_start = match source.find("{columns:?}") { + Some(start) => start, + None => panic!("Could not find interpolation in source"), + }; + let interpolation = match &parts[1] { + FStringPart::Expr { expr, format } => { + assert!(matches!(format, FStringFormat::Debug)); + expr + } + _ => panic!("Expected interpolation expression"), + }; + + assert_eq!(interpolation.span.start, expected_start); + assert_eq!(interpolation.span.end, expected_start + "{columns:?}".len()); + assert!(matches!(interpolation.node, Expr::Ident(ref name) if name == "columns")); + + Ok(()) + } + #[test] fn test_parse_fstring_expr_span_method_call_with_index() -> Result<(), Vec> { let source = "def render(users: List[str]) -> str:\n return f\"user: {users[unknown_idx].upper()}\"\n"; @@ -3192,7 +3231,7 @@ def main() -> int: }; let interpolation = match &parts[1] { - FStringPart::Expr(expr) => expr, + FStringPart::Expr { expr, .. } => expr, _ => panic!("Expected interpolation expression"), }; assert_eq!(interpolation.span.start, expected_start); @@ -3353,7 +3392,7 @@ def main() -> int: }; let interpolation = match &parts[1] { - FStringPart::Expr(expr) => expr, + FStringPart::Expr { expr, .. } => expr, _ => panic!("Expected interpolation expression"), }; assert_eq!(interpolation.span.start, expected_start); diff --git a/src/backend/ir/emit/decls/functions.rs b/src/backend/ir/emit/decls/functions.rs index 0ae0beeaa..2b0665b02 100644 --- a/src/backend/ir/emit/decls/functions.rs +++ b/src/backend/ir/emit/decls/functions.rs @@ -274,7 +274,7 @@ impl<'a> IrEmitter<'a> { } IrExprKind::Format { parts } => { for part in parts { - if let super::super::super::expr::FormatPart::Expr(expr) = part { + if let super::super::super::expr::FormatPart::Expr { expr, .. } = part { Self::rewrite_borrowed_param_types_in_expr(expr, borrowed); } } @@ -1483,7 +1483,7 @@ impl<'a> IrEmitter<'a> { } IrExprKind::Format { parts } => { for part in parts { - if let super::super::super::expr::FormatPart::Expr(expr) = part { + if let super::super::super::expr::FormatPart::Expr { expr, .. } = part { Self::collect_expr_used_names(expr, param_names, shadowed_names, used_names); } } diff --git a/src/backend/ir/emit/decls/mutation_scan.rs b/src/backend/ir/emit/decls/mutation_scan.rs index 8f7911bd8..1d66d42ff 100644 --- a/src/backend/ir/emit/decls/mutation_scan.rs +++ b/src/backend/ir/emit/decls/mutation_scan.rs @@ -316,8 +316,8 @@ impl<'a> IrEmitter<'a> { } IrExprKind::Format { parts } => { for part in parts { - if let super::super::super::expr::FormatPart::Expr(e) = part { - self.scan_expr_for_param_writes(e, param_names, mutated); + if let super::super::super::expr::FormatPart::Expr { expr, .. } = part { + self.scan_expr_for_param_writes(expr, param_names, mutated); } } } diff --git a/src/backend/ir/emit/expressions/format.rs b/src/backend/ir/emit/expressions/format.rs index d5d936509..6bd49a02d 100644 --- a/src/backend/ir/emit/expressions/format.rs +++ b/src/backend/ir/emit/expressions/format.rs @@ -27,8 +27,8 @@ impl<'a> IrEmitter<'a> { /// ## Notes /// /// - Literal segments are brace-escaped via `incan_core::strings::escape_format_literal`. - /// - Expression segments are formatted via `format!("{}", expr)` before being passed to the semantic-core f-string - /// join helper. + /// - Display expression segments are formatted via `format!("{}", expr)`. + /// - Debug expression segments are formatted via `format!("{:?}", expr)`. pub(in super::super) fn emit_format_expr(&self, parts: &[FormatPart]) -> Result { // Build literal parts (length = args + 1) and a parallel list of formatted args. let mut literal_parts: Vec = Vec::new(); @@ -40,11 +40,15 @@ impl<'a> IrEmitter<'a> { FormatPart::Literal(s) => { current.push_str(&escape_format_literal(s)); } - FormatPart::Expr(e) => { + FormatPart::Expr { expr, style } => { literal_parts.push(current.clone()); current.clear(); - let arg_expr = self.emit_expr(e)?; - args.push(quote! { format!("{}", #arg_expr) }); + let arg_expr = self.emit_expr(expr)?; + if style.emits_rust_debug(&expr.ty) { + args.push(quote! { format!("{:?}", #arg_expr) }); + } else { + args.push(quote! { format!("{}", #arg_expr) }); + } } } } diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index a028d2382..f1d16181a 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -763,7 +763,7 @@ impl<'program> GeneratedUseAnalyzer<'program> { } IrExprKind::Format { parts } => { for part in parts { - if let super::super::expr::FormatPart::Expr(expr) = part { + if let super::super::expr::FormatPart::Expr { expr, .. } = part { self.scan_expr(expr); } } @@ -1792,7 +1792,7 @@ impl<'a> IrEmitter<'a> { } IrExprKind::Format { parts } => { for part in parts { - if let super::super::expr::FormatPart::Expr(expr) = part { + if let super::super::expr::FormatPart::Expr { expr, .. } = part { Self::collect_union_types_from_expr(expr, out); } } diff --git a/src/backend/ir/emit/statements.rs b/src/backend/ir/emit/statements.rs index f1b76b14f..e4efeb2bd 100644 --- a/src/backend/ir/emit/statements.rs +++ b/src/backend/ir/emit/statements.rs @@ -773,7 +773,7 @@ fn expr_uses_binding_name(expr: &super::super::expr::IrExpr, binding_name: &str) .is_some_and(|expr| expr_uses_binding_name(expr, binding_name)) } IrExprKind::Format { parts } => parts.iter().any(|part| match part { - super::super::expr::FormatPart::Expr(expr) => expr_uses_binding_name(expr, binding_name), + super::super::expr::FormatPart::Expr { expr, .. } => expr_uses_binding_name(expr, binding_name), super::super::expr::FormatPart::Literal(_) => false, }), IrExprKind::Unit diff --git a/src/backend/ir/expr.rs b/src/backend/ir/expr.rs index 3a53d0fb1..13d21934f 100644 --- a/src/backend/ir/expr.rs +++ b/src/backend/ir/expr.rs @@ -417,7 +417,38 @@ pub enum FormatPart { /// Literal text Literal(String), /// Expression to interpolate - Expr(IrExpr), + Expr { expr: IrExpr, style: FormatStyle }, +} + +/// Formatting style requested by one f-string interpolation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FormatStyle { + /// User-facing display formatting (`{value}`). + #[default] + Display, + /// Structured debug formatting (`{value:?}`). + Debug, +} + +impl FormatStyle { + /// Return whether this interpolation should emit Rust debug formatting for the resolved backend type. + pub fn emits_rust_debug(self, ty: &IrType) -> bool { + matches!(self, Self::Debug) || matches!(self, Self::Display) && display_style_uses_structured_debug(ty) + } +} + +/// Return whether default Incan f-string display should use structured formatting for a backend representation that +/// does not expose Rust `Display` directly. +pub fn display_style_uses_structured_debug(ty: &IrType) -> bool { + matches!( + ty, + IrType::List(_) + | IrType::Dict(_, _) + | IrType::Set(_) + | IrType::Tuple(_) + | IrType::Option(_) + | IrType::Result(_, _) + ) } /// How a variable is accessed diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index 4ac449765..17a752ee1 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -1259,9 +1259,13 @@ impl AstLowering { .iter() .map(|part| match part { ast::FStringPart::Literal(s) => Ok(super::super::expr::FormatPart::Literal(s.clone())), - ast::FStringPart::Expr(e) => { - let lowered = self.lower_expr_spanned(e)?; - Ok(super::super::expr::FormatPart::Expr(lowered)) + ast::FStringPart::Expr { expr, format } => { + let lowered = self.lower_expr_spanned(expr)?; + let style = match format { + ast::FStringFormat::Display => super::super::expr::FormatStyle::Display, + ast::FStringFormat::Debug => super::super::expr::FormatStyle::Debug, + }; + Ok(super::super::expr::FormatPart::Expr { expr: lowered, style }) } }) .collect::, LoweringError>>()?; diff --git a/src/backend/ir/lower/stmt.rs b/src/backend/ir/lower/stmt.rs index f8b00fc99..c12f5995a 100644 --- a/src/backend/ir/lower/stmt.rs +++ b/src/backend/ir/lower/stmt.rs @@ -2188,7 +2188,7 @@ impl AstLowering { ast::Expr::Constructor(_, args) => self.count_call_args_ident_reads(args, counts), ast::Expr::FString(parts) => { for part in parts { - if let ast::FStringPart::Expr(expr) = part { + if let ast::FStringPart::Expr { expr, .. } = part { self.count_expr_ident_reads(&expr.node, counts); } } diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index 17286e2a0..a2e53f355 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -1,7 +1,7 @@ //! RFC 023: Trait bound inference for generic functions. //! //! This module scans IR function bodies to infer which Rust trait bounds are required on each type parameter based on -//! how the parameter is used (e.g., `==` requires `PartialEq`, f-string interpolation requires `Display`). +//! how the parameter is used (e.g., `==` requires `PartialEq`, display f-string interpolation requires `Display`). //! //! ## Inference rules (from RFC 023) //! @@ -9,7 +9,8 @@ //! | --------------------------- | ------------------------------ | //! | `==`, `!=` | `PartialEq` | //! | `<`, `<=`, `>`, `>=` | `PartialOrd` | -//! | f-string interpolation | `std::fmt::Display` | +//! | f-string `{value}` | `std::fmt::Display` | +//! | f-string `{value:?}` | `std::fmt::Debug` | //! | `+` | `std::ops::Add` | //! | `-` | `std::ops::Sub` | //! | `*` | `std::ops::Mul` | @@ -1042,7 +1043,7 @@ fn collect_backend_clone_bounds_in_expr( } IrExprKind::Format { parts } => { for part in parts { - if let FormatPart::Expr(expr) = part { + if let FormatPart::Expr { expr, .. } = part { collect_backend_clone_bounds_in_expr(expr, type_param_names, self_clone_params, clone_params); } } @@ -1437,12 +1438,24 @@ fn scan_expr_for_bounds( scan_expr_for_bounds(right, type_params, params, bounds_map); } - // ---- f-string interpolation: expressions used in format require Display ---- + // ---- f-string interpolation: expressions used in format require the matching formatting trait ---- IrExprKind::Format { parts } => { for part in parts { - if let FormatPart::Expr(inner) = part { + if let FormatPart::Expr { expr: inner, style } = part { + let bound = if style.emits_rust_debug(&inner.ty) { + tb::DEBUG + } else { + tb::DISPLAY + }; + let mut formatted_type_params = HashSet::new(); if let Some(tp_name) = expr_type_param_name(inner, type_params, params) { - add_bound(bounds_map, &tp_name, IrTraitBound::simple(tb::DISPLAY)); + formatted_type_params.insert(tp_name); + } + if style.emits_rust_debug(&inner.ty) { + collect_generic_type_param_names(&inner.ty, type_params, &mut formatted_type_params); + } + for tp_name in formatted_type_params { + add_bound(bounds_map, &tp_name, IrTraitBound::simple(bound)); } scan_expr_for_bounds(inner, type_params, params, bounds_map); } @@ -2259,8 +2272,8 @@ fn collect_calls_in_expr( } IrExprKind::Format { parts } => { for part in parts { - if let FormatPart::Expr(e) = part { - recurse_expr(e, result); + if let FormatPart::Expr { expr, .. } = part { + recurse_expr(expr, result); } } } diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 5e6cf26ed..6aad0842c 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -640,7 +640,7 @@ fn expr_references_name(expr: &Expr, name: &str) -> bool { | CallArg::KeywordUnpack(expr) => expr_references_name(&expr.node, name), }), Expr::FString(parts) => parts.iter().any(|part| { - if let crate::frontend::ast::FStringPart::Expr(expr) = part { + if let crate::frontend::ast::FStringPart::Expr { expr, .. } = part { expr_references_name(&expr.node, name) } else { false diff --git a/src/format/formatter/expressions.rs b/src/format/formatter/expressions.rs index de0e0791a..651083233 100644 --- a/src/format/formatter/expressions.rs +++ b/src/format/formatter/expressions.rs @@ -372,9 +372,12 @@ impl Formatter { for part in parts { match part { FStringPart::Literal(s) => self.writer.write(&escape_fstring_literal(s)), - FStringPart::Expr(expr) => { + FStringPart::Expr { expr, format } => { self.writer.write("{"); self.format_expr(&expr.node); + if matches!(format, FStringFormat::Debug) { + self.writer.write(":?"); + } self.writer.write("}"); } } diff --git a/src/format/mod.rs b/src/format/mod.rs index 88bab615e..7e08710c8 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -1105,6 +1105,18 @@ async def run() -> int: Ok(()) } + /// Regression (GitHub #625): f-string debug markers are semantic and must survive formatting. + #[test] + fn test_format_source_preserves_fstring_debug_marker() -> Result<(), FormatError> { + let source = "def main(columns: list[str]) -> str:\n return f\"columns: {columns:?}\"\n"; + let formatted = assert_format_round_trip_lex_parse(source)?; + assert!( + formatted.contains(r#"f"columns: {columns:?}""#), + "expected formatter to preserve f-string debug marker, got: {formatted}" + ); + Ok(()) + } + /// Regression #235: qualified constructor patterns use `::` in the AST; the formatter must print Incan surface `.`. #[test] fn test_format_source_qualified_match_pattern_round_trip() -> Result<(), FormatError> { diff --git a/src/frontend/ast_walk.rs b/src/frontend/ast_walk.rs index 113ad3d4e..a591facea 100644 --- a/src/frontend/ast_walk.rs +++ b/src/frontend/ast_walk.rs @@ -376,7 +376,7 @@ where }), Expr::FString(parts) => parts.iter().any(|part| match part { crate::frontend::ast::FStringPart::Literal(_) => false, - crate::frontend::ast::FStringPart::Expr(expr) => expr_has(&expr.node, pred), + crate::frontend::ast::FStringPart::Expr { expr, .. } => expr_has(&expr.node, pred), }), Expr::Yield(Some(expr)) => expr_has(&expr.node, pred), Expr::Yield(None) | Expr::Partial(_) => false, diff --git a/src/frontend/typechecker/check_expr/mod.rs b/src/frontend/typechecker/check_expr/mod.rs index 90036842d..a889234c3 100644 --- a/src/frontend/typechecker/check_expr/mod.rs +++ b/src/frontend/typechecker/check_expr/mod.rs @@ -185,8 +185,8 @@ impl TypeChecker { Expr::Constructor(name, args) => self.check_constructor(name, args, expr.span), Expr::FString(parts) => { for part in parts { - if let FStringPart::Expr(e) = part { - self.check_expr(e); + if let FStringPart::Expr { expr, .. } = part { + self.check_expr(expr); } } ResolvedType::Str diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 691b41547..3903115d1 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -1740,7 +1740,7 @@ impl TypeChecker { } Expr::FString(parts) => { for part in parts { - if let FStringPart::Expr(expr) = part { + if let FStringPart::Expr { expr, .. } = part { self.collect_static_dependencies_from_expr(&expr.node, deps, visiting_functions); } } @@ -1918,7 +1918,7 @@ impl TypeChecker { } Expr::FString(parts) => { for part in parts { - if let FStringPart::Expr(inner) = part { + if let FStringPart::Expr { expr: inner, .. } = part { self.collect_static_initializer_static_writes_from_expr( inner, current_static, diff --git a/src/lsp/backend.rs b/src/lsp/backend.rs index f0b6d3644..a29305a93 100644 --- a/src/lsp/backend.rs +++ b/src/lsp/backend.rs @@ -1938,7 +1938,7 @@ fn local_signature_in_expr( }), Expr::Constructor(_, args) => local_signature_in_call_args(args, ast, source, offset), Expr::FString(parts) => parts.iter().find_map(|part| match part { - crate::frontend::ast::FStringPart::Expr(expr) => local_signature_in_expr(expr, ast, source, offset), + crate::frontend::ast::FStringPart::Expr { expr, .. } => local_signature_in_expr(expr, ast, source, offset), crate::frontend::ast::FStringPart::Literal(_) => None, }), Expr::Yield(Some(value)) => local_signature_in_expr(value, ast, source, offset), @@ -3569,7 +3569,7 @@ fn scoped_symbol_in_expr<'a>( Expr::Constructor(_, args) => scoped_symbol_in_call_args(args, ident, symbol_span, surfaces, found), Expr::FString(parts) => { for part in parts { - if let crate::frontend::ast::FStringPart::Expr(expr) = part { + if let crate::frontend::ast::FStringPart::Expr { expr, .. } = part { scoped_symbol_in_expr(expr, ident, symbol_span, surfaces, found); } } @@ -4086,7 +4086,7 @@ fn scoped_symbol_context_in_expr(expr: &Spanned, offset: usize, context: & Expr::Constructor(_, args) => scoped_symbol_context_in_call_args(args, offset, context), Expr::FString(parts) => { for part in parts { - if let crate::frontend::ast::FStringPart::Expr(expr) = part { + if let crate::frontend::ast::FStringPart::Expr { expr, .. } = part { scoped_symbol_context_in_expr(expr, offset, context); } } diff --git a/src/lsp/call_site_type_args.rs b/src/lsp/call_site_type_args.rs index 3d307022e..64331748e 100644 --- a/src/lsp/call_site_type_args.rs +++ b/src/lsp/call_site_type_args.rs @@ -251,8 +251,8 @@ fn call_site_type_in_expr(expr: &Spanned, offset: usize) -> Option<&Spanne Expr::Paren(inner) => call_site_type_in_expr(inner, offset), Expr::Constructor(_, args) => scan_call_args(args, offset), Expr::FString(parts) => parts.iter().find_map(|p| { - if let crate::frontend::ast::FStringPart::Expr(e) = p { - call_site_type_in_expr(e, offset) + if let crate::frontend::ast::FStringPart::Expr { expr, .. } = p { + call_site_type_in_expr(expr, offset) } else { None } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 45668ef65..0de517557 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1964,6 +1964,49 @@ fn test_fstring_unknown_symbol_cli_caret_points_to_interpolation() { ); } +#[test] +fn test_fstring_list_interpolation_uses_structured_formatting() -> Result<(), Box> { + let source = r#"def debug_values[T](values: list[T]) -> str: + return f"{values:?}" + +def display_values[T](values: list[T]) -> str: + return f"{values}" + +def main() -> None: + columns: list[str] = ["id", "amount"] + println(f"debug: {columns:?}") + println(f"display: {columns}") + println(debug_values[str](["id", "amount"])) + println(display_values[str](["id", "amount"])) +"#; + let output = Command::new(incan_debug_binary()) + .args(["run", "-c", source]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "expected list f-string interpolation to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("debug: [\"id\", \"amount\"]"), + "expected debug list output, got:\n{stdout}" + ); + assert!( + stdout.contains("display: [\"id\", \"amount\"]"), + "expected default list f-string output to use structured formatting, got:\n{stdout}" + ); + assert!( + stdout.lines().filter(|line| *line == "[\"id\", \"amount\"]").count() == 2, + "expected both generic list helpers to render, got:\n{stdout}" + ); + + Ok(()) +} + #[test] fn fixed_call_unpack_runs_for_positional_and_keyword_shapes() -> Result<(), Box> { let source = r#" From 8d28bbeb61d7ea16656e1efc723078980380f7a6 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 00:46:19 +0200 Subject: [PATCH 09/58] bugfix - prevent return-context union argument stringification (#627) (#628) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/emit/expressions/calls.rs | 209 +++++++------- src/backend/ir/emit/expressions/methods.rs | 45 ++- src/backend/ir/emit/expressions/mod.rs | 179 ++++++++---- src/backend/ir/ownership.rs | 258 ++++++++++++++++++ src/backend/ir/trait_bound_inference.rs | 16 +- .../check_expr/calls/rust_boundary.rs | 15 +- tests/integration_tests.rs | 126 +++++++++ .../docs-site/docs/release_notes/0_3.md | 1 + 10 files changed, 655 insertions(+), 214 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d04a61d8a..6f55ff3e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc4" +version = "0.3.0-rc5" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 6d639ef77..1c4ff8518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc4" +version = "0.3.0-rc5" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index 655905914..df1878e8d 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -8,8 +8,8 @@ use quote::quote; use super::super::super::FunctionSignature; use super::super::super::conversions::{BinOpEmitKind, determine_binop_plan}; use super::super::super::decl::FunctionParam; -use super::super::super::expr::{BinOp, IrCallArg, IrCallArgKind, IrExprKind, TypedExpr, VarAccess, VarRefKind}; -use super::super::super::ownership::{ValueUseSite, incan_call_arg_needs_rust_mut_borrow, plan_value_use}; +use super::super::super::expr::{BinOp, IrCallArg, IrCallArgKind, IrExprKind, TypedExpr, VarRefKind}; +use super::super::super::ownership::{ArgumentPassingPlan, ValueUseSite}; use super::super::super::types::IrType; use super::super::{EmitError, IrEmitter}; use crate::frontend::ast::ParamKind; @@ -718,18 +718,21 @@ impl<'a> IrEmitter<'a> { }; let target_aware_aggregate_literal_arg = aggregate_literal_arg && !matches!(use_site, ValueUseSite::ExternalCallArg { .. }); + let arg_plan = ArgumentPassingPlan::for_use_site(a, use_site); let previous_qualify = if *from_default { Some(self.qualify_internal_canonical_paths.replace(true)) } else { None }; let emitted = (|| { + let mut emitted_from_seed = false; let emitted = if let Some(target_ty) = target_ty { if let Some(seed) = self.emit_inference_seeded_literal_arg_with_union_qualifier( a, target_ty, pub_library_union_qualifier.as_deref(), )? { + emitted_from_seed = true; seed } else if Self::is_unresolved_call_seed_type(target_ty) { // Signature exists but leaves generics unresolved: fallback to the argument's own inferred @@ -739,6 +742,7 @@ impl<'a> IrEmitter<'a> { &a.ty, pub_library_union_qualifier.as_deref(), )? { + emitted_from_seed = true; seed } else if target_aware_aggregate_literal_arg { self.emit_expr_for_use(a, use_site)? @@ -758,6 +762,7 @@ impl<'a> IrEmitter<'a> { &a.ty, pub_library_union_qualifier.as_deref(), )? { + emitted_from_seed = true; seed } else if target_aware_aggregate_literal_arg { self.emit_expr_for_use(a, use_site)? @@ -765,76 +770,22 @@ impl<'a> IrEmitter<'a> { self.emit_expr(a)? } }; - Ok::(emitted) + Ok::<(TokenStream, bool), EmitError>((emitted, emitted_from_seed)) })(); if let Some(previous) = previous_qualify { self.qualify_internal_canonical_paths.replace(previous); } - let emitted = emitted?; + let (emitted, emitted_from_seed) = emitted?; if let Some(adapter) = self.borrowed_function_adapter_arg(a, target_ty) { return Ok(adapter); } - // Check VarAccess for explicit borrow requirements - if let IrExprKind::Var { access, .. } = &a.kind { - match access { - VarAccess::BorrowMut => return Ok(quote! { &mut #emitted }), - VarAccess::Borrow if matches!(target_ty, Some(IrType::Ref(_) | IrType::RefMut(_)) | None) => { - return Ok(quote! { &#emitted }); - } - _ => {} - } - } - - // Prefer explicit lowering access decisions, then derive obvious borrow requirements from parameter - // typing information. - if let Some(param) = sig_param { - match ¶m.ty { - IrType::Ref(_) => match &a.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Ok(emitted), - _ => return Ok(quote! { &#emitted }), - }, - IrType::RefMut(_) => match &a.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Ok(emitted), - _ => return Ok(quote! { &mut #emitted }), - }, - _ => {} - } - } else if let Some(target_ty) = target_ty { - // Toward #121: when registry metadata is unavailable, use the call expression's function type as a - // borrow hint. - match target_ty { - IrType::RefMut(_) => match &a.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Ok(emitted), - _ => return Ok(quote! { &mut #emitted }), - }, - IrType::Ref(_) => match &a.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Ok(emitted), - _ => return Ok(quote! { &#emitted }), - }, - _ => {} - } - } - - let mut tokens = if target_aware_aggregate_literal_arg { - emitted + let tokens = if emitted_from_seed || target_aware_aggregate_literal_arg { + arg_plan.apply_after_value_plan(emitted) } else { - match use_site { - ValueUseSite::ExternalCallArg { target_ty } => self - .external_list_arg_element_coercion(a, target_ty, emitted.clone()) - .unwrap_or_else(|| plan_value_use(a, use_site).apply(emitted)), - _ => plan_value_use(a, use_site).apply(emitted), - } + arg_plan.apply_full(emitted) }; - if let Some(param) = sig_param - && incan_call_arg_needs_rust_mut_borrow(param) - { - match &a.ty { - IrType::Ref(_) | IrType::RefMut(_) => {} - _ => tokens = quote! { &mut #tokens }, - } - } Ok(tokens) }) .collect::>()?; @@ -1316,54 +1267,20 @@ impl<'a> IrEmitter<'a> { in_return, } }; + let arg_plan = ArgumentPassingPlan::for_use_site(arg, use_site); let emitted = if let Some(seed) = self.emit_inference_seeded_literal_arg(arg, ¶m.ty)? { - seed + arg_plan.apply_after_value_plan(seed) } else if Self::is_unresolved_call_seed_type(¶m.ty) { if let Some(seed) = self.emit_inference_seeded_literal_arg(arg, &arg.ty)? { - seed + arg_plan.apply_after_value_plan(seed) } else { - self.emit_expr_for_use(arg, use_site)? + arg_plan.apply_after_value_plan(self.emit_expr_for_use(arg, use_site)?) } } else { - self.emit_expr_for_use(arg, use_site)? + arg_plan.apply_after_value_plan(self.emit_expr_for_use(arg, use_site)?) }; - - if let IrExprKind::Var { access, .. } = &arg.kind { - match access { - VarAccess::BorrowMut => return Ok(quote! { &mut #emitted }), - VarAccess::Borrow if matches!(target_ty, Some(IrType::Ref(_) | IrType::RefMut(_)) | None) => { - return Ok(quote! { &#emitted }); - } - _ => {} - } - } - - match ¶m.ty { - IrType::Ref(_) => match &arg.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Ok(emitted), - _ => return Ok(quote! { &#emitted }), - }, - IrType::RefMut(_) => match &arg.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Ok(emitted), - _ => return Ok(quote! { &mut #emitted }), - }, - _ => {} - } - - let mut tokens = match use_site { - ValueUseSite::ExternalCallArg { target_ty } => self - .external_list_arg_element_coercion(arg, target_ty, emitted.clone()) - .unwrap_or(emitted), - _ => emitted, - }; - if incan_call_arg_needs_rust_mut_borrow(param) { - match &arg.ty { - IrType::Ref(_) | IrType::RefMut(_) => {} - _ => tokens = quote! { &mut #tokens }, - } - } let _ = idx; - Ok(tokens) + Ok(emitted) } /// Emit a canonical callee path when the compiler knows how to materialize that namespace at the current call @@ -1549,7 +1466,7 @@ mod tests { use crate::backend::ir::expr::{ IrCallArg, IrCallArgKind, IrInteropCoercionKind, Literal as IrLiteral, VarAccess, VarRefKind, }; - use crate::backend::ir::types::{IrType, Mutability}; + use crate::backend::ir::types::{IR_UNION_TYPE_NAME, IrType, Mutability}; use crate::backend::ir::{FunctionRegistry, IrEmitter, TypedExpr}; use incan_core::lang::types::numerics::NumericTypeId; @@ -1871,6 +1788,55 @@ mod tests { Ok(()) } + #[test] + fn emit_call_expr_keeps_return_context_union_string_seed_as_union_value() -> Result<(), Box> + { + let union_ty = IrType::NamedGeneric( + IR_UNION_TYPE_NAME.to_string(), + vec![IrType::String, IrType::Bool, IrType::Float, IrType::Int], + ); + let mut registry = FunctionRegistry::new(); + registry.register( + "lit".to_string(), + vec![FunctionParam { + name: "value".to_string(), + ty: union_ty.clone(), + mutability: Mutability::Immutable, + is_self: false, + kind: ParamKind::Normal, + default: None, + }], + IrType::String, + ); + let emitter = IrEmitter::new(®istry); + emitter.in_return_context.replace(true); + let func = TypedExpr::new( + IrExprKind::Var { + name: "lit".to_string(), + access: VarAccess::Copy, + ref_kind: VarRefKind::Value, + }, + IrType::Function { + params: vec![union_ty], + ret: Box::new(IrType::String), + }, + ); + let arg = TypedExpr::new(IrExprKind::String("open".to_string()), IrType::String); + let tokens = emitter + .emit_call_expr(&func, &[], &[pos_arg(arg)], None, None) + .map_err(|err| { + std::io::Error::other(format!( + "union string literal call should emit without post-wrapper coercion: {err:?}" + )) + })?; + + assert_eq!( + render(tokens), + "lit(__IncanUnion43fbd19e99c1db05::V0(\"open\".to_string()))" + ); + Ok(()) + } + #[test] fn emit_call_expr_borrows_struct_arg_for_rust_ref_param() -> Result<(), Box> { let mut registry = FunctionRegistry::new(); @@ -2095,6 +2061,45 @@ mod tests { Ok(()) } + #[test] + fn rest_aware_call_arg_uses_argument_plan_without_double_borrow() -> Result<(), Box> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let func = rust_call_target("takes_ref_rest"); + let signature = FunctionSignature { + params: vec![ + FunctionParam { + name: "value".to_string(), + ty: IrType::Ref(Box::new(IrType::Struct("demo::Thing".to_string()))), + mutability: Mutability::Immutable, + is_self: false, + kind: ParamKind::Normal, + default: None, + }, + FunctionParam { + name: "rest".to_string(), + ty: IrType::List(Box::new(IrType::Int)), + mutability: Mutability::Immutable, + is_self: false, + kind: ParamKind::RestPositional, + default: None, + }, + ], + return_type: IrType::Unit, + }; + let arg = local_arg("value", IrType::Struct("demo::Thing".to_string())); + let tokens = emitter + .emit_call_expr(&func, &[], &[pos_arg(arg)], Some(&signature), None) + .map_err(|err| std::io::Error::other(format!("rest-aware call should emit borrowed arg: {err:?}")))?; + let rendered = render(tokens); + assert!(rendered.starts_with("takes_ref_rest(&value,")); + assert!( + !rendered.contains("&&value"), + "argument plan must not add a second borrow after emit_expr_for_use: {rendered}" + ); + Ok(()) + } + #[test] fn emit_canonical_assert_raises_catches_panic_payloads() -> Result<(), Box> { let registry = FunctionRegistry::new(); diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index c3daaf632..d440cc87d 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -11,7 +11,7 @@ use super::super::super::expr::{ CollectionMethodKind, InternalMethodKind, IrCallArg, IrExprKind, IrMethodDispatch, MethodCallArgPolicy, MethodKind, TypedExpr, VarAccess, VarRefKind, }; -use super::super::super::ownership::ValueUseSite; +use super::super::super::ownership::{ArgumentPassingPlan, ValueUseSite}; use super::super::super::types::IrType; use super::super::{EmitError, IrEmitter}; use incan_core::interop::RustCollectionFamily; @@ -335,7 +335,6 @@ impl<'a> IrEmitter<'a> { base_use_site, ValueUseSite::ExternalCallArg { .. } | ValueUseSite::MethodArg ); - let external_call_arg_shape = matches!(base_use_site, ValueUseSite::ExternalCallArg { .. }); let arg_use_site = match (base_use_site, param) { (ValueUseSite::ExternalCallArg { .. }, Some(param)) => ValueUseSite::ExternalCallArg { target_ty: Some(¶m.ty), @@ -352,8 +351,7 @@ impl<'a> IrEmitter<'a> { } else { None }; - let external_param_planned = - matches!(arg_use_site, ValueUseSite::ExternalCallArg { target_ty: Some(_) }); + let arg_plan = ArgumentPassingPlan::for_use_site(arg, arg_use_site); let direct_mut_trait_receiver = external_method_shape && idx == 0 && Self::external_trait_first_arg_needs_mut_borrow(receiver, method); @@ -407,29 +405,9 @@ impl<'a> IrEmitter<'a> { return Ok(emitted); }; if let Some(wrapped) = self.emit_union_payload_arg(arg, ¶m.ty, None)? { - return Ok(wrapped); - } - if external_call_arg_shape - && let Some(coerced) = - self.external_list_arg_element_coercion(arg, Some(¶m.ty), emitted.clone()) - { - emitted = coerced; - } - if !external_param_planned { - match ¶m.ty { - IrType::Ref(_) if matches!(base_use_site, ValueUseSite::MethodArg) => {} - IrType::Ref(_) => match &arg.ty { - _ if Self::method_arg_already_has_reference_shape(arg) => {} - _ => emitted = quote! { &#emitted }, - }, - IrType::RefMut(_) => match &arg.ty { - IrType::Ref(_) | IrType::RefMut(_) => {} - _ => emitted = quote! { &mut #emitted }, - }, - _ => {} - } + return Ok(arg_plan.apply_after_value_plan(wrapped)); } - Ok(emitted) + Ok(arg_plan.apply_after_value_plan(emitted)) }) .collect() } @@ -567,6 +545,17 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether a receiver is a zero-cost `rusttype` alias over an external Rust type. + fn is_rusttype_alias_receiver(&self, receiver_ty: &IrType) -> bool { + match Self::receiver_type_for_method_dispatch(receiver_ty) { + IrType::Struct(name) | IrType::NamedGeneric(name, _) => { + let short_name = name.rsplit("::").next().unwrap_or(name); + self.rusttype_alias_names.contains(name) || self.rusttype_alias_names.contains(short_name) + } + _ => false, + } + } + /// Recover a field receiver's declared surface type before choosing method-call ownership policy. fn receiver_with_known_field_type(&self, receiver: &TypedExpr) -> Option { let IrExprKind::Field { object, field } = &receiver.kind else { @@ -900,8 +889,10 @@ impl<'a> IrEmitter<'a> { let preserve_lookup_arg_shape = matches!(arg_policy, MethodCallArgPolicy::PreserveShape) || rust_collection_family_for_ir_type(&receiver.ty) .is_some_and(|family| family.preserves_lookup_arg_shape(method)); + let rusttype_alias_receiver = self.is_rusttype_alias_receiver(&receiver.ty); let use_site = if receiver_ref_kind != Some(VarRefKind::ExternalRustName) - && (has_incan_method_signature || self.is_incan_owned_nominal_receiver(&receiver.ty)) + && (has_incan_method_signature + || (self.is_incan_owned_nominal_receiver(&receiver.ty) && !rusttype_alias_receiver)) { ValueUseSite::IncanCallArg { target_ty: None, diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 27d2810b5..b1b663086 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -59,7 +59,7 @@ use super::super::expr::{ }; use super::super::types::IrType; use super::{EmitError, IrEmitter}; -use crate::backend::ir::ownership::{ValueUseSite, plan_value_use}; +use crate::backend::ir::ownership::{ValueUseSite, plan_value_use, value_use_site_target_ty}; use incan_core::lang::types::collections::{self, CollectionTypeId}; #[derive(Debug, Clone)] @@ -91,31 +91,6 @@ pub(in crate::backend::ir::emit) fn method_kind_uses_mutable_receiver(kind: &Met } impl<'a> IrEmitter<'a> { - /// Convert a direct `Vec` argument into `Vec` at external Rust call boundaries. - /// - /// The Incan typechecker does not prove Rust `From` relationships. At an external Rust boundary, Rust's own - /// trait checker is the source of truth, so this emits an element-level `.into()` map only when metadata says the - /// parameter expects a different direct list element type. - pub(super) fn external_list_arg_element_coercion( - &self, - arg: &TypedExpr, - target_ty: Option<&IrType>, - emitted: TokenStream, - ) -> Option { - let Some(IrType::List(target_elem)) = target_ty else { - return None; - }; - let IrType::List(source_elem) = &arg.ty else { - return None; - }; - if source_elem == target_elem || Self::is_unresolved_call_seed_type(target_elem) { - return None; - } - Some(quote! { - (#emitted).into_iter().map(|__incan_item| ::std::convert::Into::into(__incan_item)).collect::>() - }) - } - /// Build a typed tuple-field read for compiler-expanded tuple unpacking. pub(super) fn tuple_field_expr(expr: &TypedExpr, idx: usize, ty: IrType) -> TypedExpr { TypedExpr::new( @@ -273,15 +248,57 @@ impl<'a> IrEmitter<'a> { /// Return the target type carried by a value-use site, if the site has one. fn use_site_target_ty<'b>(site: ValueUseSite<'b>) -> Option<&'b IrType> { - match site { - ValueUseSite::IncanCallArg { target_ty, .. } - | ValueUseSite::ExternalCallArg { target_ty } - | ValueUseSite::StructField { target_ty } - | ValueUseSite::CollectionElement { target_ty } - | ValueUseSite::Assignment { target_ty } - | ValueUseSite::ReturnValue { target_ty } - | ValueUseSite::MatchScrutinee { target_ty } => target_ty, - ValueUseSite::MethodArg => None, + value_use_site_target_ty(site) + } + + /// Return whether an expression already emits an owned Rust `String` value. + fn expr_already_materializes_owned_string(expr: &TypedExpr) -> bool { + matches!(expr.ty, IrType::String) + && !matches!( + expr.kind, + IrExprKind::String(_) | IrExprKind::Literal(IrLiteral::StaticStr(_)) | IrExprKind::StaticRead { .. } + ) + } + + /// Return whether an expression already emits an owned Rust `Vec` value. + fn expr_already_materializes_owned_bytes(expr: &TypedExpr) -> bool { + matches!(expr.ty, IrType::Bytes) && !matches!(expr.kind, IrExprKind::Bytes(_) | IrExprKind::StaticRead { .. }) + } + + /// Emit a typechecker-selected Rust borrow coercion without re-planning ownership at the call site. + fn emit_builtin_borrow_coercion( + inner_expr: &TypedExpr, + inner_tokens: TokenStream, + rust_target: &str, + ) -> TokenStream { + match rust_target { + "&str" => match &inner_expr.ty { + IrType::StaticStr | IrType::StrRef | IrType::FrozenStr | IrType::Ref(_) | IrType::RefMut(_) => { + quote! { #inner_tokens } + } + _ => quote! { &#inner_tokens }, + }, + "&[u8]" => match &inner_expr.ty { + IrType::StaticBytes | IrType::FrozenBytes | IrType::Ref(_) | IrType::RefMut(_) => { + quote! { #inner_tokens } + } + _ => quote! { &#inner_tokens }, + }, + "&String" | "&std::string::String" | "&alloc::string::String" => { + if Self::expr_already_materializes_owned_string(inner_expr) { + quote! { &#inner_tokens } + } else { + quote! { &(#inner_tokens).to_string() } + } + } + "&Vec" | "&std::vec::Vec" | "&alloc::vec::Vec" => { + if Self::expr_already_materializes_owned_bytes(inner_expr) { + quote! { &#inner_tokens } + } else { + quote! { &(#inner_tokens).to_vec() } + } + } + _ => quote! { &#inner_tokens }, } } @@ -1046,19 +1063,19 @@ impl<'a> IrEmitter<'a> { to_ty: _, kind, } => { - let inner = self.emit_expr(inner)?; + let inner_tokens = self.emit_expr(inner)?; match kind { IrInteropCoercionKind::Builtin { policy, rust_target } => { let rust_target = rust_target.replace(' ', ""); let emitted = match policy { incan_core::interop::CoercionPolicy::Exact => match rust_target.as_str() { "String" | "std::string::String" => { - quote! { (#inner).to_string() } + quote! { (#inner_tokens).to_string() } } "Vec" | "std::vec::Vec" => { - quote! { (#inner).to_vec() } + quote! { (#inner_tokens).to_vec() } } - _ => quote! { #inner }, + _ => quote! { #inner_tokens }, }, incan_core::interop::CoercionPolicy::Lossless => { let target = syn::parse_str::(rust_target.as_str()).map_err(|err| { @@ -1066,35 +1083,28 @@ impl<'a> IrEmitter<'a> { "invalid Rust boundary cast target `{rust_target}`: {err}" )) })?; - quote! { (#inner) as #target } + quote! { (#inner_tokens) as #target } + } + incan_core::interop::CoercionPolicy::Borrow => { + Self::emit_builtin_borrow_coercion(inner, inner_tokens, rust_target.as_str()) } - incan_core::interop::CoercionPolicy::Borrow => match rust_target.as_str() { - "&str" | "&[u8]" => quote! { &#inner }, - "&String" | "&std::string::String" | "&alloc::string::String" => { - quote! { &(#inner).to_string() } - } - "&Vec" | "&std::vec::Vec" | "&alloc::vec::Vec" => { - quote! { &(#inner).to_vec() } - } - _ => quote! { &#inner }, - }, incan_core::interop::CoercionPolicy::Lossy => match rust_target.as_str() { - "f32" => quote! { (#inner) as f32 }, - _ => quote! { #inner }, + "f32" => quote! { (#inner_tokens) as f32 }, + _ => quote! { #inner_tokens }, }, }; Ok(emitted) } IrInteropCoercionKind::AdapterCall { adapter, adapter_kind } => { let adapter = self.emit_expr(adapter)?; - let call = quote! { #adapter(#inner) }; + let call = quote! { #adapter(#inner_tokens) }; let emitted = match adapter_kind { IrInteropAdapterKind::Via => call, IrInteropAdapterKind::Try => quote! { #call? }, }; Ok(emitted) } - IrInteropCoercionKind::RustTypeUnwrap => Ok(quote! { #inner }), + IrInteropCoercionKind::RustTypeUnwrap => Ok(quote! { #inner_tokens }), } } @@ -1593,6 +1603,45 @@ mod tests { Ok(()) } + #[test] + fn interop_borrowed_string_coercion_borrows_owned_string_without_materializing() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let expr = TypedExpr::new( + IrExprKind::InteropCoerce { + expr: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "text".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::String, + )), + from_ty: IrType::String, + to_ty: IrType::Ref(Box::new(IrType::String)), + kind: IrInteropCoercionKind::Builtin { + policy: incan_core::interop::CoercionPolicy::Borrow, + rust_target: "&String".to_string(), + }, + }, + IrType::Ref(Box::new(IrType::String)), + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered == "& text" || rendered == "&text", + "expected borrowed owned String interop coercion to borrow directly, got `{rendered}`" + ); + assert!( + !rendered.contains("to_string"), + "owned String borrow coercions must not clone through `.to_string()`, got `{rendered}`" + ); + Ok(()) + } + #[test] fn interop_wrapped_dict_literal_keeps_call_site_value_target() -> Result<(), String> { let registry = FunctionRegistry::new(); @@ -2528,7 +2577,7 @@ mod tests { } #[test] - fn qualified_rusttype_receiver_method_uses_incan_string_conversion() -> Result<(), String> { + fn qualified_rusttype_receiver_method_uses_rust_signature_borrowing() -> Result<(), String> { let registry = FunctionRegistry::new(); let mut emitter = IrEmitter::new(®istry); emitter.rusttype_alias_names.insert("_RawRegex".to_string()); @@ -2567,7 +2616,17 @@ mod tests { IrType::String, ), }], - callable_signature: None, + callable_signature: Some(FunctionSignature { + params: vec![FunctionParam { + name: "text".to_string(), + ty: IrType::Ref(Box::new(IrType::String)), + mutability: Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }], + return_type: IrType::Struct("_RawMatchIterator".to_string()), + }), arg_policy: MethodCallArgPolicy::Default, }, IrType::Struct("_RawMatchIterator".to_string()), @@ -2582,8 +2641,12 @@ mod tests { "expected regular method-call emission on qualified rusttype receiver, got `{rendered}`" ); assert!( - !rendered.contains("& text") && !rendered.contains("&text"), - "qualified rusttype receiver methods must use Incan arg rules for owned string args, got `{rendered}`" + rendered.contains("find_iter (& text)") || rendered.contains("find_iter (&text)"), + "metadata-resolved rusttype receiver methods should borrow owned strings for Rust &str params, got `{rendered}`" + ); + assert!( + !rendered.contains("to_string"), + "metadata-resolved rusttype receiver methods should not clone strings before borrowing, got `{rendered}`" ); Ok(()) } diff --git a/src/backend/ir/ownership.rs b/src/backend/ir/ownership.rs index ee071a198..1866fc95f 100644 --- a/src/backend/ir/ownership.rs +++ b/src/backend/ir/ownership.rs @@ -110,11 +110,171 @@ pub fn plan_value_use(expr: &IrExpr, site: ValueUseSite<'_>) -> OwnershipPlan { } } +/// Return the target type carried by a value-use site, if the site has one. +pub fn value_use_site_target_ty<'a>(site: ValueUseSite<'a>) -> Option<&'a IrType> { + match site { + ValueUseSite::IncanCallArg { target_ty, .. } + | ValueUseSite::ExternalCallArg { target_ty } + | ValueUseSite::StructField { target_ty } + | ValueUseSite::CollectionElement { target_ty } + | ValueUseSite::Assignment { target_ty } + | ValueUseSite::ReturnValue { target_ty } + | ValueUseSite::MatchScrutinee { target_ty } => target_ty, + ValueUseSite::MethodArg => None, + } +} + +/// Value-level coercion selected for a callable argument before the final pass-by shape is applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgumentValuePlan { + /// Apply the ordinary ownership/coercion conversion for this value-use site. + Ownership(OwnershipPlan), + /// Convert `Vec` into `Vec` at an external Rust call boundary. + ExternalListElementInto, +} + +impl ArgumentValuePlan { + /// Apply the value-level plan to an unplanned emitted argument expression. + fn apply_full(&self, tokens: TokenStream) -> TokenStream { + match self { + Self::Ownership(plan) => plan.apply(tokens), + Self::ExternalListElementInto => quote! { + (#tokens).into_iter().map(|__incan_item| ::std::convert::Into::into(__incan_item)).collect::>() + }, + } + } + + /// Apply only value-level work that is not already handled by [`plan_value_use`]. + fn apply_after_value_plan(&self, tokens: TokenStream) -> TokenStream { + match self { + Self::Ownership(_) => tokens, + Self::ExternalListElementInto => self.apply_full(tokens), + } + } +} + +/// Final Rust argument passing shape after value-level ownership/coercion has been handled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArgumentPassingMode { + /// Pass the value expression directly. + ByValue, + /// Pass the value expression as `&value`. + SharedBorrow, + /// Pass the value expression as `&mut value`. + MutableBorrow, +} + +impl ArgumentPassingMode { + /// Apply the final argument passing shape. + fn apply(self, tokens: TokenStream) -> TokenStream { + match self { + Self::ByValue => tokens, + Self::SharedBorrow => quote! { &#tokens }, + Self::MutableBorrow => quote! { &mut #tokens }, + } + } +} + +/// Explicit argument-passing plan for a callable argument. +/// +/// Argument emission is intentionally two-stage because some Incan calls need both value-level materialization and a +/// final Rust borrow shape, for example `mut s: str` lowering to `&mut "x".to_string()`. Call emitters should build one +/// of these plans, emit the argument expression, then apply the plan once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArgumentPassingPlan { + value: ArgumentValuePlan, + passing: ArgumentPassingMode, +} + +impl ArgumentPassingPlan { + /// Plan one argument at the given use site. + pub fn for_use_site(expr: &IrExpr, site: ValueUseSite<'_>) -> Self { + let mut value = match site { + ValueUseSite::ExternalCallArg { target_ty } + if external_list_arg_needs_element_into(&expr.ty, target_ty) => + { + ArgumentValuePlan::ExternalListElementInto + } + _ => ArgumentValuePlan::Ownership(plan_value_use(expr, site)), + }; + let mut passing = ArgumentPassingMode::ByValue; + + if let IrExprKind::Var { access, .. } = &expr.kind { + match access { + VarAccess::BorrowMut => { + passing = ArgumentPassingMode::MutableBorrow; + value = ArgumentValuePlan::Ownership(OwnershipPlan::None); + } + VarAccess::Borrow if value_use_site_target_ty(site).is_none() => { + passing = ArgumentPassingMode::SharedBorrow; + value = ArgumentValuePlan::Ownership(OwnershipPlan::None); + } + _ => {} + } + } + + if let ValueUseSite::IncanCallArg { + callee_param: Some(param), + .. + } = site + && incan_mutable_param_passed_as_rust_mut_ref(param) + && !matches!(expr.ty, IrType::Ref(_) | IrType::RefMut(_)) + { + passing = ArgumentPassingMode::MutableBorrow; + } + + Self { value, passing } + } + + /// Apply the complete plan to an argument that was emitted without value-use planning. + pub fn apply_full(&self, tokens: TokenStream) -> TokenStream { + self.passing.apply(self.value.apply_full(tokens)) + } + + /// Apply only the portion of the plan that remains after `emit_expr_for_use` or literal seeding already shaped the + /// value. + pub fn apply_after_value_plan(&self, tokens: TokenStream) -> TokenStream { + self.passing.apply(self.value.apply_after_value_plan(tokens)) + } +} + /// Wrapper predicate for mutable aggregate Incan parameters at Rust call sites. pub fn incan_call_arg_needs_rust_mut_borrow(param: &FunctionParam) -> bool { incan_mutable_param_passed_as_rust_mut_ref(param) } +/// Return whether an external Rust list argument needs element-wise `Into` coercion. +fn external_list_arg_needs_element_into(source_ty: &IrType, target_ty: Option<&IrType>) -> bool { + let Some(IrType::List(target_elem)) = target_ty else { + return false; + }; + let IrType::List(source_elem) = source_ty else { + return false; + }; + source_elem != target_elem && !is_unresolved_call_seed_type(target_elem) +} + +/// Return whether a call-seed target still contains unresolved generic or unknown parts. +fn is_unresolved_call_seed_type(ty: &IrType) -> bool { + match ty { + IrType::Unknown | IrType::Generic(_) => true, + IrType::Ref(inner) | IrType::RefMut(inner) | IrType::Option(inner) | IrType::List(inner) => { + is_unresolved_call_seed_type(inner) + } + IrType::Set(inner) => is_unresolved_call_seed_type(inner), + IrType::Dict(key, value) | IrType::Result(key, value) => { + is_unresolved_call_seed_type(key) || is_unresolved_call_seed_type(value) + } + IrType::Tuple(items) => items.iter().any(is_unresolved_call_seed_type), + IrType::NamedGeneric(_, args) => args.iter().any(is_unresolved_call_seed_type), + IrType::Function { params, ret } => { + params.iter().any(is_unresolved_call_seed_type) || is_unresolved_call_seed_type(ret) + } + IrType::Struct(_) | IrType::Enum(_) | IrType::Trait(_) => false, + _ => false, + } +} + /// Whether a collection receiver should be passed through, borrowed, or mutably borrowed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CollectionReceiverPlan { @@ -437,6 +597,10 @@ mod tests { use crate::backend::ir::expr::{IrExpr, IrExprKind, VarAccess, VarRefKind}; use crate::backend::ir::types::Mutability; + fn render(tokens: TokenStream) -> String { + tokens.to_string().replace(' ', "") + } + #[test] fn incan_call_string_literal_plans_owned_string() { let expr = IrExpr::new(IrExprKind::String("x".to_string()), IrType::String); @@ -464,6 +628,100 @@ mod tests { assert!(incan_call_arg_needs_rust_mut_borrow(¶m)); } + #[test] + fn argument_plan_mutable_list_param_reborrows_without_value_clone() { + let expr = IrExpr::new( + IrExprKind::Var { + name: "items".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::List(Box::new(IrType::Int)), + ); + let param = FunctionParam { + name: "items".to_string(), + ty: IrType::List(Box::new(IrType::Int)), + mutability: Mutability::Mutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }; + let plan = ArgumentPassingPlan::for_use_site( + &expr, + ValueUseSite::IncanCallArg { + target_ty: Some(¶m.ty), + callee_param: Some(¶m), + in_return: false, + }, + ); + assert_eq!(render(plan.apply_after_value_plan(quote! { items })), "&mutitems"); + } + + #[test] + fn argument_plan_mutable_string_literal_materializes_then_reborrows() { + let expr = IrExpr::new(IrExprKind::String("x".to_string()), IrType::String); + let param = FunctionParam { + name: "s".to_string(), + ty: IrType::String, + mutability: Mutability::Mutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }; + let plan = ArgumentPassingPlan::for_use_site( + &expr, + ValueUseSite::IncanCallArg { + target_ty: Some(¶m.ty), + callee_param: Some(¶m), + in_return: false, + }, + ); + assert_eq!(render(plan.apply_full(quote! { "x" })), "&mut\"x\".to_string()"); + } + + #[test] + fn argument_plan_external_ref_param_borrows_once() { + let expr = IrExpr::new( + IrExprKind::Var { + name: "thing".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("demo::Thing".to_string()), + ); + let target = IrType::Ref(Box::new(IrType::Struct("demo::Thing".to_string()))); + let plan = ArgumentPassingPlan::for_use_site( + &expr, + ValueUseSite::ExternalCallArg { + target_ty: Some(&target), + }, + ); + assert_eq!(render(plan.apply_full(quote! { thing })), "&thing"); + assert_eq!(render(plan.apply_after_value_plan(quote! { &thing })), "&thing"); + } + + #[test] + fn argument_plan_external_list_element_into_is_value_plan() { + let expr = IrExpr::new( + IrExprKind::Var { + name: "items".to_string(), + access: VarAccess::Move, + ref_kind: VarRefKind::Value, + }, + IrType::List(Box::new(IrType::String)), + ); + let target = IrType::List(Box::new(IrType::Struct("demo::Name".to_string()))); + let plan = ArgumentPassingPlan::for_use_site( + &expr, + ValueUseSite::ExternalCallArg { + target_ty: Some(&target), + }, + ); + let rendered = render(plan.apply_full(quote! { items })); + assert!(rendered.contains("items).into_iter().map")); + assert!(rendered.contains("Into::into(__incan_item)")); + } + #[test] fn list_shared_receiver_borrows_plain_list() { assert_eq!( diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index a2e53f355..8acc9f0a1 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -35,7 +35,7 @@ use super::expr::{ BinOp, FormatPart, IrCallArg, IrDictEntry, IrExpr, IrExprKind, IrGeneratorClause, IrListEntry, MethodCallArgPolicy, VarAccess, VarRefKind, }; -use super::ownership::{ValueUseSite, plan_value_use}; +use super::ownership::{ValueUseSite, plan_value_use, value_use_site_target_ty}; use super::stmt::{IrStmt, IrStmtKind}; use super::types::IrType; @@ -1127,20 +1127,6 @@ fn incan_call_arg_requires_backend_clone(expr: &IrExpr) -> bool { } } -/// Return the target type carried by a use site, if that site has one. -fn value_use_site_target_ty(site: ValueUseSite<'_>) -> Option<&IrType> { - match site { - ValueUseSite::IncanCallArg { target_ty, .. } - | ValueUseSite::ExternalCallArg { target_ty } - | ValueUseSite::StructField { target_ty } - | ValueUseSite::CollectionElement { target_ty } - | ValueUseSite::Assignment { target_ty } - | ValueUseSite::ReturnValue { target_ty } - | ValueUseSite::MatchScrutinee { target_ty } => target_ty, - ValueUseSite::MethodArg => None, - } -} - /// Rebuild a parent value-use site for one tuple item while preserving the parent ownership context. /// /// Tuple elements can be planned as call arguments, return values, collection elements, and match scrutinees. This diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index b1cd35d32..6a5f31fdc 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -409,7 +409,7 @@ impl TypeChecker { for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(params.iter()) { let arg_expr = Self::call_arg_expr(arg); let normalized = param.type_display.replace(' ', ""); - let target_ty = self.resolved_type_from_rust_display(normalized.as_str()); + let target_ty = self.resolved_param_type_from_rust_display(param.type_display.as_str()); if preserves_lookup_arg_shape && self.rust_lookup_probe_boundary_match(arg_ty, &target_ty) { continue; } @@ -462,7 +462,7 @@ impl TypeChecker { for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(sig.params.iter()) { let arg_expr = Self::call_arg_expr(arg); let normalized = param.type_display.replace(' ', ""); - let target_ty = self.resolved_type_from_rust_display(normalized.as_str()); + let target_ty = self.resolved_param_type_from_rust_display(param.type_display.as_str()); match self.rust_arg_boundary_match(arg_ty, param.type_display.as_str()) { RustArgBoundaryMatch::Exact => {} RustArgBoundaryMatch::Coercion(kind) => { @@ -837,6 +837,17 @@ mod validate_rust_function_call_tests { .contains_key(&(span.start, span.end)), "expected rust arg coercion metadata for borrowed String boundary" ); + let coercion = checker + .type_info + .rust + .arg_coercions + .get(&(span.start, span.end)) + .expect("coercion metadata should be present"); + assert_eq!( + coercion.target_type, + ResolvedType::Ref(Box::new(ResolvedType::Str)), + "borrowed Rust params must preserve borrow shape in lowering metadata" + ); } #[test] diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0de517557..596d3c9b7 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -6357,6 +6357,31 @@ async def main() -> None: ], "unexpected std.regex output:\n{stdout}" ); + let generated_core = fs::read_to_string("target/incan/std_regex_surface/src/__incan_std/regex/_core.rs")?; + for unexpected in [ + "RegexBuilder::new(&(pattern).to_string())", + "raw.find(&(text).to_string())", + "raw.find_iter(&(text).to_string())", + "raw.captures(&(text).to_string())", + "raw.captures_iter(&(text).to_string())", + ] { + assert!( + !generated_core.contains(unexpected), + "std.regex should let the compiler borrow Incan strings for Rust regex APIs instead of cloning them:\n{generated_core}" + ); + } + for expected in [ + "RegexBuilder::new(&pattern)", + "raw.find(&text)", + "raw.find_iter(&text)", + "raw.captures(&text)", + "raw.captures_iter(&text)", + ] { + assert!( + generated_core.contains(expected), + "std.regex should preserve compiler-managed Rust borrow boundaries; missing `{expected}`:\n{generated_core}" + ); + } Ok(()) } @@ -10636,6 +10661,107 @@ mod rfc031_pub_import_integration_tests { .output()?) } + #[test] + fn build_keeps_return_context_string_literal_union_arg_as_union_value() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path().join("return_context_union_arg"); + std::fs::create_dir_all(project_root.join("src"))?; + std::fs::write( + project_root.join("incan.toml"), + "[project]\nname = \"return_context_union_arg\"\nversion = \"0.1.0\"\n", + )?; + std::fs::write( + project_root.join("src/projection_builders.incn"), + r#"pub model ColumnRefExpr: + column_name: str + +pub model StringLiteralExpr: + value: str + +pub model FloatLiteralExpr: + value: float + +pub model EqExpr: + arguments: list[ColumnExpr] + +pub type ColumnExpr = Union[ColumnRefExpr, StringLiteralExpr, FloatLiteralExpr, EqExpr] + +pub def col(name: str) -> ColumnExpr: + return ColumnRefExpr(column_name=name) + +pub def str_expr(value: str) -> ColumnExpr: + return StringLiteralExpr(value=value) + +pub def float_expr(value: float) -> ColumnExpr: + return FloatLiteralExpr(value=value) + +pub def lit(value: Union[int, float, str, bool]) -> ColumnExpr: + match value: + float(number) => return float_expr(number) + str(text) => return str_expr(text) + bool(flag) => return str_expr("bool") + int(number) => return str_expr("int") + +pub def eq(left: ColumnExpr, right: ColumnExpr) -> ColumnExpr: + return EqExpr(arguments=[left, right]) +"#, + )?; + std::fs::write( + project_root.join("src/functions.incn"), + "from projection_builders import col as col_builder, eq as eq_builder, lit as lit_builder\n\npub col = alias col_builder\npub lit = alias lit_builder\npub eq = alias eq_builder\n", + )?; + std::fs::write( + project_root.join("src/dataset.incn"), + r#"from projection_builders import ColumnExpr + +pub class LazyFrame[T with Clone]: + pub rows: list[T] + + def filter(self, predicate: ColumnExpr) -> Self: + return self +"#, + )?; + let main_path = project_root.join("src/main.incn"); + std::fs::write( + &main_path, + r#"from dataset import LazyFrame +from functions import col, eq, lit + +model OrderLine: + status: str + discount: float + +def repro(lines: LazyFrame[OrderLine]) -> LazyFrame[OrderLine]: + return lines.filter(eq(col("status"), lit("open"))).filter(eq(col("discount"), lit(0.9))) + +def main() -> None: + lines: LazyFrame[OrderLine] = LazyFrame[OrderLine](rows=[]) + _ = repro(lines) + println("done") +"#, + )?; + + let out_dir = project_root.join("out"); + let output = run_build(&main_path, &out_dir)?; + assert!( + output.status.success(), + "expected union literal regression build to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let generated_main = std::fs::read_to_string(out_dir.join("src/main.rs"))?; + let normalized: String = generated_main.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + normalized.contains("lit(crate::__IncanUnion43fbd19e99c1db05::V0(\"open\".to_string()))"), + "expected string literal to be wrapped directly as the union string arm, got:\n{generated_main}" + ); + assert!( + !normalized.contains("V0(\"open\".to_string()).to_string()"), + "union wrapper must not receive a post-wrapper string coercion, got:\n{generated_main}" + ); + Ok(()) + } + #[test] fn explicit_serialize_trait_adoption_runs_with_default_to_json() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index b6c203eee..86a843bd3 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,6 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening +- **Release-candidate hardening**: The RC validation loop against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, and `std.regex` Rust-boundary text borrowing (#615, #616, #617, #620, #621, #622, #624, #625, #627). - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). - **Compiler**: Rust interop fixes cover retained enum-pattern imports, owned Incan values passed to shared borrowed generic Rust parameters, `Vec` adaptation from `list[T]`, prost-style inherent and trait-provided `decode(buf: T)` calls, extension-trait import retention from metadata, and trait-typed local annotation diagnostics (#459, #506, #128, #609, #612, #447, #462). From 862a0433c932aa27982748e39859aac1b0ecfc5f Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 11:41:09 +0200 Subject: [PATCH 10/58] bugfix - stabilize v0.3 ownership and registry dispatch (#615, #616, #617, #620, #621, #622, #624, #625, #627) (#629) --- .github/workflows/ci.yml | 4 +- Cargo.lock | 18 +- Cargo.toml | 2 +- .../src/bin/generate_lang_reference.rs | 25 + .../src/interop/extension_traits.rs | 66 ++ crates/incan_core/src/interop/metadata.rs | 225 +++++ crates/incan_core/src/interop/mod.rs | 12 +- crates/incan_core/src/lang/mod.rs | 1 + crates/incan_core/src/lang/stdlib.rs | 246 ++++- crates/incan_core/src/lang/surface/methods.rs | 172 ++++ crates/incan_core/src/lang/surface/mod.rs | 2 +- crates/incan_core/src/lang/testing.rs | 158 +++ .../incan_core/src/lang/types/collections.rs | 11 + .../tests/lang_registry_guardrails.rs | 58 +- crates/incan_stdlib/src/testing.rs | 107 +- .../stdlib/compression/_auto.incn | 10 +- .../incan_stdlib/stdlib/compression/bz2.incn | 4 +- .../stdlib/compression/deflate.incn | 4 +- .../incan_stdlib/stdlib/compression/gzip.incn | 4 +- .../incan_stdlib/stdlib/compression/lzma.incn | 2 +- .../stdlib/compression/snappy.incn | 2 +- .../stdlib/compression/snappy/raw.incn | 2 +- .../incan_stdlib/stdlib/compression/zlib.incn | 4 +- .../incan_stdlib/stdlib/compression/zstd.incn | 4 +- crates/incan_stdlib/stdlib/hash/_core.incn | 20 +- crates/incan_syntax/src/ast/imports.rs | 4 +- .../incan_syntax/src/parser/decl/imports.rs | 4 +- crates/incan_syntax/src/parser/tests.rs | 28 +- crates/rust_inspect/src/cache.rs | 2 +- crates/rust_inspect/src/extractor.rs | 168 ++-- .../pro/vocab_querykit/consumer/incan.lock | 12 +- .../pro/vocab_querykit/producer/incan.lock | 10 +- .../pro/vocab_routekit/consumer/incan.lock | 12 +- .../pro/vocab_routekit/producer/incan.lock | 10 +- .../pro/vocab_studiokit/consumer/incan.lock | 12 +- .../pro/vocab_studiokit/producer/incan.lock | 10 +- scripts/check_changed_rustdocs.py | 55 +- src/backend/ir/codegen.rs | 715 +------------ src/backend/ir/codegen/dependency_metadata.rs | 286 ++++++ src/backend/ir/codegen/ordinal_bridge.rs | 284 ++++++ src/backend/ir/codegen/serde_activation.rs | 139 +++ src/backend/ir/conversions.rs | 65 +- src/backend/ir/emit/decls/impls.rs | 20 +- src/backend/ir/emit/expressions/calls.rs | 304 +----- .../emit/expressions/calls/testing_asserts.rs | 335 +++++++ .../ir/emit/expressions/interop_coercions.rs | 156 +++ src/backend/ir/emit/expressions/methods.rs | 218 ++-- src/backend/ir/emit/expressions/mod.rs | 324 ++++-- src/backend/ir/emit/mod.rs | 33 +- src/backend/ir/emit/program.rs | 51 +- src/backend/ir/expr.rs | 64 +- src/backend/ir/lower/decl/methods.rs | 29 +- src/backend/ir/lower/expr/calls.rs | 150 ++- src/backend/ir/mod.rs | 1 + src/backend/ir/ownership.rs | 84 +- src/backend/ir/reference_shape.rs | 33 + src/backend/ir/trait_bound_inference.rs | 937 +++++++++++++++--- src/cli/commands/common.rs | 126 +-- src/dependency_resolver.rs | 135 +-- src/frontend/testing_markers.rs | 166 +++- src/frontend/typechecker/check_decl.rs | 6 +- src/frontend/typechecker/check_expr/access.rs | 159 ++- .../check_expr/calls/generic_bounds.rs | 652 ------------ .../check_expr/calls/rust_boundary.rs | 114 ++- .../typechecker/collect/stdlib_imports.rs | 69 +- src/frontend/typechecker/mod.rs | 255 +++-- src/frontend/typechecker/tests.rs | 66 ++ .../typechecker/trait_bound_relations.rs | 659 ++++++++++++ src/lib.rs | 2 +- tests/codegen_snapshot_tests.rs | 21 + .../semantic_string_audit.json | 322 ++++++ tests/vocab_guardrails.rs | 385 +++++++ .../docs/language/reference/language.md | 29 + .../docs-site/docs/release_notes/0_3.md | 1 + 74 files changed, 6250 insertions(+), 2635 deletions(-) create mode 100644 crates/incan_core/src/interop/extension_traits.rs create mode 100644 crates/incan_core/src/lang/testing.rs create mode 100644 src/backend/ir/codegen/dependency_metadata.rs create mode 100644 src/backend/ir/codegen/ordinal_bridge.rs create mode 100644 src/backend/ir/codegen/serde_activation.rs create mode 100644 src/backend/ir/emit/expressions/calls/testing_asserts.rs create mode 100644 src/backend/ir/emit/expressions/interop_coercions.rs create mode 100644 src/backend/ir/reference_shape.rs create mode 100644 src/frontend/typechecker/trait_bound_relations.rs create mode 100644 tests/fixtures/vocab_guardrails/semantic_string_audit.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5009a7e4..3e8da6aeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, release/** ] pull_request: - branches: [ main ] + branches: [ main, release/** ] env: CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index 6f55ff3e0..aaac3967e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc5" +version = "0.3.0-rc6" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 1c4ff8518..cda1140de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc5" +version = "0.3.0-rc6" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/bin/generate_lang_reference.rs b/crates/incan_core/src/bin/generate_lang_reference.rs index 4e6e451e3..22ca33faa 100644 --- a/crates/incan_core/src/bin/generate_lang_reference.rs +++ b/crates/incan_core/src/bin/generate_lang_reference.rs @@ -1065,6 +1065,31 @@ fn render_surface_methods_section(out: &mut String) { } out.push('\n'); + // Iterator + out.push_str("\n### Iterator methods\n\n"); + out.push_str(table_header()); + for m in surface::iterator_methods::ITERATOR_METHODS { + let id = format!("{:?}", m.id); + let canonical = format!("`{}`", m.canonical); + let aliases = if m.aliases.is_empty() { + String::new() + } else { + m.aliases + .iter() + .map(|a| format!("`{}`", a)) + .collect::>() + .join(", ") + }; + let desc = m.description; + let rfc = m.introduced_in_rfc; + let since = m.since; + let stability = format!("{:?}", m.stability); + out.push_str(&format!( + "| {id} | {canonical} | {aliases} | {desc} | {rfc} | {since} | {stability} |\n" + )); + } + out.push('\n'); + // Frozen containers out.push_str("\n### FrozenList methods\n\n"); out.push_str(table_header()); diff --git a/crates/incan_core/src/interop/extension_traits.rs b/crates/incan_core/src/interop/extension_traits.rs new file mode 100644 index 000000000..1e0aabbf1 --- /dev/null +++ b/crates/incan_core/src/interop/extension_traits.rs @@ -0,0 +1,66 @@ +//! Fallback Rust extension-trait method vocabulary used when rust-inspect metadata is unavailable. + +/// Return fallback trait method names for Rust traits when structured trait metadata is unavailable. +#[must_use] +pub fn fallback_rust_trait_methods(path: &str) -> &'static [&'static str] { + match path { + "std::io::Read" => &[ + "read", + "read_to_end", + "read_to_string", + "read_exact", + "read_buf", + "read_buf_exact", + "bytes", + "chain", + "take", + ], + "std::io::Write" => &["write", "write_all", "write_fmt", "flush"], + "std::io::Seek" => &["seek", "rewind", "stream_position", "seek_relative"], + "byteorder::ReadBytesExt" => &[ + "read_u8", + "read_i8", + "read_u16", + "read_i16", + "read_u32", + "read_i32", + "read_u64", + "read_i64", + "read_u128", + "read_i128", + "read_f32", + "read_f64", + ], + "byteorder::WriteBytesExt" => &[ + "write_u8", + "write_i8", + "write_u16", + "write_i16", + "write_u32", + "write_i32", + "write_u64", + "write_i64", + "write_u128", + "write_i128", + "write_f32", + "write_f64", + ], + "sha2::Digest" | "sha3::Digest" | "blake2::Digest" | "md5::Digest" | "sha1::Digest" => &[ + "new", + "new_with_prefix", + "update", + "chain_update", + "finalize", + "finalize_into", + "finalize_reset", + "reset", + "output_size", + "digest", + ], + "blake2::digest::XofReader" | "sha3::digest::XofReader" => &["read"], + "std::os::unix::fs::MetadataExt" => &[ + "dev", "ino", "mode", "nlink", "uid", "gid", "rdev", "size", "atime", "mtime", "ctime", "blksize", "blocks", + ], + _ => &[], + } +} diff --git a/crates/incan_core/src/interop/metadata.rs b/crates/incan_core/src/interop/metadata.rs index 8bf656974..92400b7d6 100644 --- a/crates/incan_core/src/interop/metadata.rs +++ b/crates/incan_core/src/interop/metadata.rs @@ -114,6 +114,132 @@ impl RustItemMetadata { } } +/// Borrow shape for a metadata-free external method compatibility policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetadataFreeMethodArgBorrowPolicy { + Shared, + Mutable, +} + +/// Receiver class used by metadata-free external method compatibility policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetadataFreeReceiverClass { + IoValue, + EncodingInstance, + ExternalAssociated, +} + +/// Argument class used by metadata-free external method compatibility policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetadataFreeArgClass { + StringBuffer, + ByteBuffer, + Any, +} + +/// Borrow compatibility rule for one metadata-free Rust method surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataFreeMethodBorrowRule { + pub methods: &'static [&'static str], + pub receiver: MetadataFreeReceiverClass, + pub arg: MetadataFreeArgClass, + pub policy: MetadataFreeMethodArgBorrowPolicy, +} + +/// One parameter in a metadata-free Rust method signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataFreeMethodParamRule { + pub name: Option<&'static str>, + pub type_display: &'static str, +} + +/// Complete callable signature for one metadata-free Rust method surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MetadataFreeMethodSignatureRule { + pub receiver_path: &'static str, + pub method: &'static str, + pub params: &'static [MetadataFreeMethodParamRule], + pub return_type: &'static str, + pub is_async: bool, + pub is_unsafe: bool, +} + +/// Metadata-free external method borrow policies used when rust-inspect metadata is unavailable. +pub const METADATA_FREE_METHOD_BORROW_RULES: &[MetadataFreeMethodBorrowRule] = &[ + MetadataFreeMethodBorrowRule { + methods: &["read_to_string"], + receiver: MetadataFreeReceiverClass::IoValue, + arg: MetadataFreeArgClass::StringBuffer, + policy: MetadataFreeMethodArgBorrowPolicy::Mutable, + }, + MetadataFreeMethodBorrowRule { + methods: &["read", "read_to_end", "read_exact", "read_buf", "read_buf_exact"], + receiver: MetadataFreeReceiverClass::IoValue, + arg: MetadataFreeArgClass::ByteBuffer, + policy: MetadataFreeMethodArgBorrowPolicy::Mutable, + }, + MetadataFreeMethodBorrowRule { + methods: &["write"], + receiver: MetadataFreeReceiverClass::IoValue, + arg: MetadataFreeArgClass::ByteBuffer, + policy: MetadataFreeMethodArgBorrowPolicy::Shared, + }, + MetadataFreeMethodBorrowRule { + methods: &["write_all"], + receiver: MetadataFreeReceiverClass::IoValue, + arg: MetadataFreeArgClass::Any, + policy: MetadataFreeMethodArgBorrowPolicy::Shared, + }, + MetadataFreeMethodBorrowRule { + methods: &["for_label", "encode", "decode"], + receiver: MetadataFreeReceiverClass::EncodingInstance, + arg: MetadataFreeArgClass::Any, + policy: MetadataFreeMethodArgBorrowPolicy::Shared, + }, + MetadataFreeMethodBorrowRule { + methods: &["decode"], + receiver: MetadataFreeReceiverClass::ExternalAssociated, + arg: MetadataFreeArgClass::ByteBuffer, + policy: MetadataFreeMethodArgBorrowPolicy::Shared, + }, +]; + +/// Metadata-free external method signatures used when rust-inspect metadata is unavailable. +pub const METADATA_FREE_METHOD_SIGNATURE_RULES: &[MetadataFreeMethodSignatureRule] = + &[MetadataFreeMethodSignatureRule { + receiver_path: "encoding_rs::Encoding", + method: "for_label", + params: &[MetadataFreeMethodParamRule { + name: Some("label"), + type_display: "&[u8]", + }], + return_type: "Option<&'static encoding_rs::Encoding>", + is_async: false, + is_unsafe: false, + }]; + +/// Return conservative callable metadata for Rust surfaces the stdlib must compile against even when rust-inspect +/// cannot recover full crate metadata in generated smoke projects. +#[must_use] +pub fn metadata_free_method_signature(rust_path: &str, method: &str) -> Option { + let rule = METADATA_FREE_METHOD_SIGNATURE_RULES + .iter() + .find(|rule| rule.receiver_path == rust_path && rule.method == method)?; + Some(RustFunctionSig { + params: rule + .params + .iter() + .map(|param| RustParam { + name: param.name.map(str::to_string), + type_display: param.type_display.to_string(), + }) + .collect(), + return_type: rule.return_type.to_string(), + is_async: rule.is_async, + is_unsafe: rule.is_unsafe, + }) +} + /// A single parameter in a Rust function signature (display strings only for Phase 1). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RustParam { @@ -177,6 +303,105 @@ pub enum RustTypeShape { Unknown, } +/// Render `path` with generic arguments as `path` for stable Rust-like display. +#[must_use] +pub fn render_rust_type_shape_path(path: &str, args: &[RustTypeShape]) -> String { + if args.is_empty() { + return path.to_string(); + } + let rendered_args: Vec = args.iter().map(render_rust_type_shape).collect(); + format!("{path}<{}>", rendered_args.join(", ")) +} + +/// Pretty-print a [`RustTypeShape`] as a stable Rust-like type string. +#[must_use] +pub fn render_rust_type_shape(shape: &RustTypeShape) -> String { + match shape { + RustTypeShape::Bool => "bool".to_string(), + RustTypeShape::Float => "f64".to_string(), + RustTypeShape::Int => "i64".to_string(), + RustTypeShape::Str => "String".to_string(), + RustTypeShape::Bytes => "Vec".to_string(), + RustTypeShape::Unit => "()".to_string(), + RustTypeShape::Option(inner) => format!("Option<{}>", render_rust_type_shape(inner)), + RustTypeShape::Result(ok, err) => { + format!( + "Result<{}, {}>", + render_rust_type_shape(ok), + render_rust_type_shape(err) + ) + } + RustTypeShape::Tuple(items) => { + let rendered: Vec = items.iter().map(render_rust_type_shape).collect(); + format!("({})", rendered.join(", ")) + } + RustTypeShape::Ref(inner) => format!("&{}", render_rust_type_shape(inner)), + RustTypeShape::RustPath { path, args } => render_rust_type_shape_path(path, args), + RustTypeShape::TypeParam(name) => name.clone(), + RustTypeShape::Unknown => "?".to_string(), + } +} + +/// Remove Rust lifetime labels that decorate borrowed display types. +#[must_use] +pub fn strip_rust_borrow_lifetimes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + out.push(ch); + if ch != '&' { + continue; + } + while matches!(chars.peek(), Some(next) if next.is_whitespace()) { + if let Some(next) = chars.next() { + out.push(next); + } + } + if !matches!(chars.peek(), Some('\'')) { + continue; + } + chars.next(); + while matches!(chars.peek(), Some(next) if next.is_ascii_alphanumeric() || *next == '_') { + chars.next(); + } + while matches!(chars.peek(), Some(next) if next.is_whitespace()) { + chars.next(); + } + } + out +} + +/// Split a comma-separated Rust generic/tuple argument list without splitting inside nested generic, tuple, or slice +/// delimiters. +#[must_use] +pub fn split_top_level_rust_args(text: &str) -> Vec<&str> { + let mut args = Vec::new(); + let mut start = 0usize; + let mut angle = 0usize; + let mut paren = 0usize; + let mut bracket = 0usize; + for (idx, ch) in text.char_indices() { + match ch { + '<' => angle += 1, + '>' => angle = angle.saturating_sub(1), + '(' => paren += 1, + ')' => paren = paren.saturating_sub(1), + '[' => bracket += 1, + ']' => bracket = bracket.saturating_sub(1), + ',' if angle == 0 && paren == 0 && bracket == 0 => { + args.push(text[start..idx].trim()); + start = idx + ch.len_utf8(); + } + _ => {} + } + } + let tail = text[start..].trim(); + if !tail.is_empty() { + args.push(tail); + } + args +} + /// A public field surfaced on a Rust struct/union-like type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RustFieldInfo { diff --git a/crates/incan_core/src/interop/mod.rs b/crates/incan_core/src/interop/mod.rs index 0abb18740..69eaeafcf 100644 --- a/crates/incan_core/src/interop/mod.rs +++ b/crates/incan_core/src/interop/mod.rs @@ -6,12 +6,18 @@ pub mod capabilities; pub mod coercions; +mod extension_traits; pub mod metadata; pub use capabilities::{RUST_CAPABILITY_BOUNDS, is_rust_capability_bound}; pub use coercions::{CoercionPolicy, admitted_builtin_coercion}; +pub use extension_traits::fallback_rust_trait_methods; pub use metadata::{ - RustCollectionFamily, RustFieldInfo, RustFunctionSig, RustImplementedTrait, RustItemKind, RustItemMetadata, - RustMethodSig, RustModuleChild, RustModuleChildKind, RustModuleInfo, RustParam, RustTraitAssoc, RustTraitInfo, - RustTypeInfo, RustTypeShape, RustVariantInfo, RustVisibility, + METADATA_FREE_METHOD_BORROW_RULES, METADATA_FREE_METHOD_SIGNATURE_RULES, MetadataFreeArgClass, + MetadataFreeMethodArgBorrowPolicy, MetadataFreeMethodBorrowRule, MetadataFreeMethodParamRule, + MetadataFreeMethodSignatureRule, MetadataFreeReceiverClass, RustCollectionFamily, RustFieldInfo, RustFunctionSig, + RustImplementedTrait, RustItemKind, RustItemMetadata, RustMethodSig, RustModuleChild, RustModuleChildKind, + RustModuleInfo, RustParam, RustTraitAssoc, RustTraitInfo, RustTypeInfo, RustTypeShape, RustVariantInfo, + RustVisibility, metadata_free_method_signature, render_rust_type_shape, render_rust_type_shape_path, + split_top_level_rust_args, strip_rust_borrow_lifetimes, }; diff --git a/crates/incan_core/src/lang/mod.rs b/crates/incan_core/src/lang/mod.rs index d97648563..73914e850 100644 --- a/crates/incan_core/src/lang/mod.rs +++ b/crates/incan_core/src/lang/mod.rs @@ -42,6 +42,7 @@ pub mod registry; pub mod rust_keywords; pub mod stdlib; pub mod surface; +pub mod testing; pub mod trait_bounds; pub mod trait_capabilities; pub mod traits; diff --git a/crates/incan_core/src/lang/stdlib.rs b/crates/incan_core/src/lang/stdlib.rs index 1a0bbeb4d..6353179f0 100644 --- a/crates/incan_core/src/lang/stdlib.rs +++ b/crates/incan_core/src/lang/stdlib.rs @@ -33,17 +33,93 @@ pub const STDLIB_RUST: &str = "rust"; pub const STDLIB_BUILTINS: &str = "builtins"; /// `std.json` module name. pub const STDLIB_JSON: &str = "json"; +/// `std.serde` module name. +pub const STDLIB_SERDE: &str = "serde"; /// Dynamic JSON value type exported by `std.json`. pub const JSON_VALUE_TYPE_NAME: &str = "JsonValue"; /// Runtime Rust path carried by `std.json.JsonValue`. pub const JSON_VALUE_RUST_PATH: &str = "incan_stdlib::json::JsonValue"; +/// Stable ids for compiler-known stdlib JSON protocol traits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StdlibJsonTraitId { + Serialize, + Deserialize, +} + +const STDLIB_JSON_SERIALIZE_TRAIT_NAMES: &[&str] = &[ + "Serialize", + "JsonSerialize", + "json.Serialize", + "std.serde.json.Serialize", +]; + +const STDLIB_JSON_DESERIALIZE_TRAIT_NAMES: &[&str] = &[ + "Deserialize", + "JsonDeserialize", + "json.Deserialize", + "std.serde.json.Deserialize", +]; + /// Return whether `name` is the canonical dynamic JSON value type. #[must_use] pub fn is_json_value_type_name(name: &str) -> bool { name == JSON_VALUE_TYPE_NAME } +/// Return the stdlib JSON trait id for a source, alias, or qualified trait spelling. +#[must_use] +pub fn stdlib_json_trait_id(name: &str) -> Option { + if STDLIB_JSON_SERIALIZE_TRAIT_NAMES.contains(&name) { + Some(StdlibJsonTraitId::Serialize) + } else if STDLIB_JSON_DESERIALIZE_TRAIT_NAMES.contains(&name) { + Some(StdlibJsonTraitId::Deserialize) + } else { + None + } +} + +/// Return whether `segments` names the `std.serde.json` trait module. +#[must_use] +pub fn is_stdlib_json_trait_module_path(segments: &[String]) -> bool { + matches!( + segments, + [std, serde, json] + if std == STDLIB_ROOT && serde == STDLIB_SERDE && json == STDLIB_JSON + ) +} + +/// Return the stdlib JSON trait id for a resolved source import path. +#[must_use] +pub fn stdlib_json_trait_id_from_path(segments: &[String]) -> Option { + if is_stdlib_json_trait_module_path(segments) { + return None; + } + stdlib_json_trait_id(&segments.join(".")) +} + +/// Return the stdlib JSON trait id when generated Rust must import the trait module for method resolution. +#[must_use] +pub fn stdlib_json_trait_scope_import_id(name: &str) -> Option { + match name { + "json.Serialize" | "std.serde.json.Serialize" => Some(StdlibJsonTraitId::Serialize), + "json.Deserialize" | "std.serde.json.Deserialize" => Some(StdlibJsonTraitId::Deserialize), + _ => None, + } +} + +/// Return whether `name` refers to the stdlib JSON serialization trait. +#[must_use] +pub fn is_stdlib_json_serialize_trait_name(name: &str) -> bool { + stdlib_json_trait_id(name) == Some(StdlibJsonTraitId::Serialize) +} + +/// Return whether `name` refers to the stdlib JSON deserialization trait. +#[must_use] +pub fn is_stdlib_json_deserialize_trait_name(name: &str) -> bool { + stdlib_json_trait_id(name) == Some(StdlibJsonTraitId::Deserialize) +} + const STDLIB_GRAPH_CONSTRUCTOR_TYPES: &[&str] = &["DiGraph", "Dag", "MultiDiGraph"]; /// Check if a module path starts with `std.`. @@ -96,6 +172,8 @@ pub struct StdlibExtraCrateDep { pub crate_name: &'static str, /// Dependency source and version/path metadata. pub source: StdlibExtraCrateSource, + /// Cargo features enabled for this stdlib-managed dependency. + pub features: &'static [&'static str], } /// Source descriptor for a namespace-provided extra crate dependency. @@ -204,14 +282,17 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ StdlibExtraCrateDep { crate_name: "incan_web_macros", source: StdlibExtraCrateSource::Path("crates/incan_web_macros"), + features: &[], }, StdlibExtraCrateDep { crate_name: "inventory", source: StdlibExtraCrateSource::Version("0.3"), + features: &[], }, StdlibExtraCrateDep { crate_name: "axum", source: StdlibExtraCrateSource::Version("0.8"), + features: &[], }, ], submodules: &["app", "routing", "request", "response", "macros", "prelude"], @@ -248,14 +329,22 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ StdlibNamespace { name: "serde", feature: Some("json"), - extra_crate_deps: &[], + extra_crate_deps: &[StdlibExtraCrateDep { + crate_name: "serde", + source: StdlibExtraCrateSource::Version("1.0"), + features: &["derive"], + }], submodules: &["json"], typechecker_only: false, }, StdlibNamespace { name: STDLIB_JSON, feature: Some("json"), - extra_crate_deps: &[], + extra_crate_deps: &[StdlibExtraCrateDep { + crate_name: "serde", + source: StdlibExtraCrateSource::Version("1.0"), + features: &["derive"], + }], submodules: &[], typechecker_only: false, }, @@ -293,6 +382,7 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ extra_crate_deps: &[StdlibExtraCrateDep { crate_name: "libm", source: StdlibExtraCrateSource::Version("0.2"), + features: &[], }], submodules: &[], typechecker_only: false, @@ -332,6 +422,7 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ extra_crate_deps: &[StdlibExtraCrateDep { crate_name: "rand", source: StdlibExtraCrateSource::Version("0.8"), + features: &[], }], submodules: &[], typechecker_only: false, @@ -342,6 +433,7 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ extra_crate_deps: &[StdlibExtraCrateDep { crate_name: "regex", source: StdlibExtraCrateSource::Version("1.0"), + features: &[], }], submodules: &["_core", "_replacement", "types", "prelude"], typechecker_only: false, @@ -359,6 +451,7 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ extra_crate_deps: &[StdlibExtraCrateDep { crate_name: "byteorder", source: StdlibExtraCrateSource::Version("1"), + features: &[], }], submodules: &[], typechecker_only: false, @@ -375,7 +468,43 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ StdlibNamespace { name: "hash", feature: None, - extra_crate_deps: &[], + extra_crate_deps: &[ + StdlibExtraCrateDep { + crate_name: "blake2", + source: StdlibExtraCrateSource::Version("0.10"), + features: &[], + }, + StdlibExtraCrateDep { + crate_name: "blake3", + source: StdlibExtraCrateSource::Version("1"), + features: &[], + }, + StdlibExtraCrateDep { + crate_name: "md5", + source: StdlibExtraCrateSource::Version("0.10"), + features: &[], + }, + StdlibExtraCrateDep { + crate_name: "sha1", + source: StdlibExtraCrateSource::Version("0.10"), + features: &[], + }, + StdlibExtraCrateDep { + crate_name: "sha2", + source: StdlibExtraCrateSource::Version("0.10"), + features: &[], + }, + StdlibExtraCrateDep { + crate_name: "sha3", + source: StdlibExtraCrateSource::Version("0.10"), + features: &[], + }, + StdlibExtraCrateDep { + crate_name: "xxhash_rust", + source: StdlibExtraCrateSource::Version("0.8"), + features: &["xxh3", "xxh32", "xxh64"], + }, + ], submodules: &["_core", "_streaming", "prelude"], typechecker_only: false, }, @@ -386,22 +515,27 @@ pub const STDLIB_NAMESPACES: &[StdlibNamespace] = &[ StdlibExtraCrateDep { crate_name: "flate2", source: StdlibExtraCrateSource::Version("1"), + features: &[], }, StdlibExtraCrateDep { crate_name: "zstd", source: StdlibExtraCrateSource::Version("0.13"), + features: &[], }, StdlibExtraCrateDep { crate_name: "bzip2", source: StdlibExtraCrateSource::Version("0.6"), + features: &[], }, StdlibExtraCrateDep { crate_name: "xz2", source: StdlibExtraCrateSource::Version("0.1"), + features: &[], }, StdlibExtraCrateDep { crate_name: "snap", source: StdlibExtraCrateSource::Version("1"), + features: &[], }, ], submodules: &[ @@ -450,6 +584,33 @@ pub fn find_namespace(name: &str) -> Option<&'static StdlibNamespace> { STDLIB_NAMESPACES.iter().find(|ns| ns.name == name) } +/// Look up an extra Cargo crate dependency declared by any registered stdlib namespace. +/// +/// This is the registry boundary for compiler subsystems that need stdlib-managed dependency metadata without +/// duplicating namespace traversal or crate version knowledge. +#[must_use] +pub fn find_extra_crate_dep(crate_name: &str) -> Option<&'static StdlibExtraCrateDep> { + extra_crate_deps().find(|dep| dep.crate_name == crate_name) +} + +/// Return the published Cargo package name when a stdlib-managed Rust crate imports under a different crate key. +#[must_use] +pub fn extra_crate_package_alias(crate_name: &str) -> Option<&'static str> { + match crate_name { + "md5" => Some("md-5"), + "xxhash_rust" => Some("xxhash-rust"), + _ => None, + } +} + +/// Iterate over every extra Cargo crate dependency declared by registered stdlib namespaces. +/// +/// Consumers that need to filter by dependency source can use this iterator while keeping namespace traversal +/// centralized in the stdlib registry. +pub fn extra_crate_deps() -> impl Iterator { + STDLIB_NAMESPACES.iter().flat_map(|ns| ns.extra_crate_deps) +} + /// Return the stdlib module path that owns fallback method signatures for a builtin trait name. /// /// The returned segments can be passed to the typechecker's stdlib cache to load the full `.incn` trait declaration @@ -829,6 +990,64 @@ mod tests { assert_eq!(trait_method_module_segments("Serialize"), None); } + #[test] + fn stdlib_json_trait_lookup_covers_aliases_and_qualified_names() { + for name in [ + "Serialize", + "JsonSerialize", + "json.Serialize", + "std.serde.json.Serialize", + ] { + assert_eq!(stdlib_json_trait_id(name), Some(StdlibJsonTraitId::Serialize)); + assert!(is_stdlib_json_serialize_trait_name(name)); + } + + for name in [ + "Deserialize", + "JsonDeserialize", + "json.Deserialize", + "std.serde.json.Deserialize", + ] { + assert_eq!(stdlib_json_trait_id(name), Some(StdlibJsonTraitId::Deserialize)); + assert!(is_stdlib_json_deserialize_trait_name(name)); + } + + assert_eq!(stdlib_json_trait_id("yaml.Serialize"), None); + assert_eq!(stdlib_json_trait_scope_import_id("Serialize"), None); + assert_eq!(stdlib_json_trait_scope_import_id("JsonSerialize"), None); + assert_eq!( + stdlib_json_trait_scope_import_id("json.Serialize"), + Some(StdlibJsonTraitId::Serialize) + ); + let json_trait_module = vec!["std".to_string(), "serde".to_string(), "json".to_string()]; + assert!(is_stdlib_json_trait_module_path(&json_trait_module)); + let serialize_path = vec![ + "std".to_string(), + "serde".to_string(), + "json".to_string(), + "Serialize".to_string(), + ]; + assert_eq!( + stdlib_json_trait_id_from_path(&serialize_path), + Some(StdlibJsonTraitId::Serialize) + ); + } + + #[test] + fn extra_crate_dependency_lookup_is_registry_driven() { + let axum = find_extra_crate_dep("axum"); + assert_eq!(axum.map(|dep| dep.crate_name), Some("axum")); + assert_eq!(axum.map(|dep| dep.source), Some(StdlibExtraCrateSource::Version("0.8"))); + + let macros = find_extra_crate_dep("incan_web_macros"); + assert_eq!( + macros.map(|dep| dep.source), + Some(StdlibExtraCrateSource::Path("crates/incan_web_macros")) + ); + + assert!(find_extra_crate_dep("not_a_stdlib_dependency").is_none()); + } + #[test] fn stdlib_registry_keeps_phase_023_metadata() { let async_ns = find_namespace("async"); @@ -839,6 +1058,8 @@ mod tests { let math_ns = find_namespace("math"); let graph_ns = find_namespace("graph"); let uuid_ns = find_namespace("uuid"); + let serde_ns = find_namespace("serde"); + let json_ns = find_namespace(STDLIB_JSON); let hash_ns = find_namespace("hash"); let datetime_ns = find_namespace("datetime"); let collections_ns = find_namespace("collections"); @@ -866,6 +1087,20 @@ mod tests { ); assert_eq!(uuid_ns.map(|ns| ns.submodules.is_empty()), Some(true)); assert_eq!(uuid_ns.map(|ns| ns.typechecker_only), Some(false)); + assert_eq!( + serde_ns.map(|ns| ns.extra_crate_deps.iter().map(|dep| dep.crate_name).collect::>()), + Some(vec!["serde"]) + ); + assert_eq!( + serde_ns + .and_then(|ns| ns.extra_crate_deps.first()) + .map(|dep| dep.features), + Some(&["derive"][..]) + ); + assert_eq!( + json_ns.map(|ns| ns.extra_crate_deps.iter().map(|dep| dep.crate_name).collect::>()), + Some(vec!["serde"]) + ); assert_eq!(collections_ns.map(|ns| ns.feature), Some(None)); assert_eq!(collections_ns.map(|ns| ns.extra_crate_deps.is_empty()), Some(true)); assert_eq!(collections_ns.map(|ns| ns.submodules.is_empty()), Some(true)); @@ -877,7 +1112,10 @@ mod tests { Some("byteorder") ); assert_eq!(hash_ns.map(|ns| ns.feature), Some(None)); - assert_eq!(hash_ns.map(|ns| ns.extra_crate_deps.is_empty()), Some(true)); + assert_eq!( + hash_ns.map(|ns| ns.extra_crate_deps.iter().map(|dep| dep.crate_name).collect::>()), + Some(vec!["blake2", "blake3", "md5", "sha1", "sha2", "sha3", "xxhash_rust",]) + ); assert_eq!(hash_ns.map(|ns| ns.submodules.contains(&"prelude")), Some(true)); assert_eq!(hash_ns.map(|ns| ns.submodules.contains(&"_core")), Some(true)); assert_eq!(hash_ns.map(|ns| ns.submodules.contains(&"_streaming")), Some(true)); diff --git a/crates/incan_core/src/lang/surface/methods.rs b/crates/incan_core/src/lang/surface/methods.rs index a1b2cd292..4664c21fc 100644 --- a/crates/incan_core/src/lang/surface/methods.rs +++ b/crates/incan_core/src/lang/surface/methods.rs @@ -1100,6 +1100,8 @@ pub mod result_methods { OrElse, Inspect, InspectErr, + Unwrap, + UnwrapOr, } pub type ResultMethodInfo = LangItemInfo; @@ -1153,6 +1155,22 @@ pub mod result_methods { RFC::_070, Since(0, 3), ), + info( + ResultMethodId::Unwrap, + "unwrap", + &[], + "Return the Ok payload or panic.", + RFC::_000, + Since(0, 1), + ), + info( + ResultMethodId::UnwrapOr, + "unwrap_or", + &[], + "Return the Ok payload or a default value.", + RFC::_000, + Since(0, 1), + ), ]; /// Resolve a result method spelling to its stable id. @@ -1194,3 +1212,157 @@ pub mod result_methods { } } } + +pub mod iterator_methods { + //! Iterator protocol method surface vocabulary. + + use super::LangItemInfo; + use crate::lang::registry::{RFC, Since, Stability}; + + /// Stable identifier for an RFC 088 iterator protocol method. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum IteratorMethodId { + Iter, + Map, + Filter, + Enumerate, + Zip, + Take, + Skip, + TakeWhile, + SkipWhile, + Chain, + FlatMap, + Batch, + Collect, + Count, + Reduce, + Fold, + Any, + All, + Find, + ForEach, + Sum, + } + + pub type IteratorMethodInfo = LangItemInfo; + + pub const ITERATOR_METHODS: &[IteratorMethodInfo] = &[ + info(IteratorMethodId::Iter, "iter", "Create an iterator over an iterable."), + info(IteratorMethodId::Map, "map", "Lazily transform iterator items."), + info( + IteratorMethodId::Filter, + "filter", + "Lazily keep items that match a predicate.", + ), + info( + IteratorMethodId::Enumerate, + "enumerate", + "Yield each item with its zero-based index.", + ), + info(IteratorMethodId::Zip, "zip", "Pair items from two iterables."), + info( + IteratorMethodId::Take, + "take", + "Yield at most the requested number of items.", + ), + info( + IteratorMethodId::Skip, + "skip", + "Discard at most the requested number of items.", + ), + info( + IteratorMethodId::TakeWhile, + "take_while", + "Yield items until a predicate first returns false.", + ), + info( + IteratorMethodId::SkipWhile, + "skip_while", + "Discard items while a predicate returns true.", + ), + info( + IteratorMethodId::Chain, + "chain", + "Yield receiver items followed by another iterable.", + ), + info( + IteratorMethodId::FlatMap, + "flat_map", + "Map items to iterables and flatten the result.", + ), + info(IteratorMethodId::Batch, "batch", "Yield fixed-size list batches."), + info(IteratorMethodId::Collect, "collect", "Consume an iterator into a list."), + info( + IteratorMethodId::Count, + "count", + "Consume an iterator and return the item count.", + ), + info( + IteratorMethodId::Reduce, + "reduce", + "Consume an iterator with an explicit accumulator.", + ), + info( + IteratorMethodId::Fold, + "fold", + "Consume an iterator with an explicit accumulator.", + ), + info( + IteratorMethodId::Any, + "any", + "Return whether any item satisfies a predicate.", + ), + info( + IteratorMethodId::All, + "all", + "Return whether every item satisfies a predicate.", + ), + info( + IteratorMethodId::Find, + "find", + "Return the first item satisfying a predicate.", + ), + info( + IteratorMethodId::ForEach, + "for_each", + "Consume an iterator for side effects.", + ), + info( + IteratorMethodId::Sum, + "sum", + "Consume an iterator and return the numeric sum.", + ), + ]; + + /// Resolve an iterator method spelling to its stable id. + pub fn from_str(name: &str) -> Option { + super::from_str_impl(ITERATOR_METHODS, name) + } + + /// Return the canonical spelling for an iterator method. + pub fn as_str(id: IteratorMethodId) -> &'static str { + info_for(id).canonical + } + + /// Return the full metadata entry for an iterator method. + /// + /// ## Panics + /// - If the registry is missing an entry for `id` (this indicates a programming error). + pub fn info_for(id: IteratorMethodId) -> &'static IteratorMethodInfo { + super::info_for_impl(ITERATOR_METHODS, id, "iterator method info missing") + } + + const fn info(id: IteratorMethodId, canonical: &'static str, description: &'static str) -> IteratorMethodInfo { + LangItemInfo { + id, + canonical, + aliases: &[], + description, + introduced_in_rfc: RFC::_088, + since: Since(0, 3), + stability: Stability::Stable, + examples: &[], + } + } +} diff --git a/crates/incan_core/src/lang/surface/mod.rs b/crates/incan_core/src/lang/surface/mod.rs index 90e84d63a..f85b30c5f 100644 --- a/crates/incan_core/src/lang/surface/mod.rs +++ b/crates/incan_core/src/lang/surface/mod.rs @@ -20,5 +20,5 @@ pub mod types; // `crate::lang::surface::string_methods`, `crate::lang::surface::list_methods`, ... pub use methods::{ dict_methods, float_methods, frozen_bytes_methods, frozen_dict_methods, frozen_list_methods, frozen_set_methods, - list_methods, option_methods, result_methods, set_methods, string_methods, + iterator_methods, list_methods, option_methods, result_methods, set_methods, string_methods, }; diff --git a/crates/incan_core/src/lang/testing.rs b/crates/incan_core/src/lang/testing.rs new file mode 100644 index 000000000..081bfd946 --- /dev/null +++ b/crates/incan_core/src/lang/testing.rs @@ -0,0 +1,158 @@ +//! Shared testing marker vocabulary. + +use super::registry::{LangItemInfo, RFC, Since, Stability}; +use super::stdlib; + +/// Standard-library testing module segment. +pub const STDLIB_TESTING_MODULE: &str = "testing"; + +/// Stable identifier for a canonical `std.testing` assertion helper. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TestingAssertHelperId { + Assert, + AssertFalse, + AssertEq, + AssertNe, + AssertIsSome, + AssertIsNone, + AssertIsOk, + AssertIsErr, + AssertRaises, +} + +pub type TestingAssertHelperInfo = LangItemInfo; + +/// Canonical `std.testing` assertion helpers with compiler-specialized emission. +pub const TESTING_ASSERT_HELPERS: &[TestingAssertHelperInfo] = &[ + assert_helper(TestingAssertHelperId::Assert, "assert"), + assert_helper(TestingAssertHelperId::AssertFalse, "assert_false"), + assert_helper(TestingAssertHelperId::AssertEq, "assert_eq"), + assert_helper(TestingAssertHelperId::AssertNe, "assert_ne"), + assert_helper(TestingAssertHelperId::AssertIsSome, "assert_is_some"), + assert_helper(TestingAssertHelperId::AssertIsNone, "assert_is_none"), + assert_helper(TestingAssertHelperId::AssertIsOk, "assert_is_ok"), + assert_helper(TestingAssertHelperId::AssertIsErr, "assert_is_err"), + assert_helper(TestingAssertHelperId::AssertRaises, "assert_raises"), +]; + +/// Resolve an assertion helper spelling to its stable id. +pub fn assert_helper_from_str(name: &str) -> Option { + TESTING_ASSERT_HELPERS + .iter() + .find(|helper| helper.canonical == name) + .map(|helper| helper.id) +} + +/// Return the canonical spelling for an assertion helper id. +/// +/// ## Panics +/// - If the registry is missing an entry for `id` (this indicates a programming error). +pub fn assert_helper_as_str(id: TestingAssertHelperId) -> &'static str { + TESTING_ASSERT_HELPERS + .iter() + .find(|helper| helper.id == id) + .unwrap_or_else(|| panic!("testing assert helper info missing")) + .canonical +} + +/// Return the canonical fully qualified `std.testing` path for an assertion helper. +#[must_use] +pub fn assert_helper_path(id: TestingAssertHelperId) -> [&'static str; 3] { + [stdlib::STDLIB_ROOT, STDLIB_TESTING_MODULE, assert_helper_as_str(id)] +} + +/// Resolve a fully qualified `std.testing` path to an assertion helper id. +#[must_use] +pub fn assert_helper_id_from_std_path(path: &[String]) -> Option { + let [root, module, name] = path else { + return None; + }; + if root == stdlib::STDLIB_ROOT && module == STDLIB_TESTING_MODULE { + assert_helper_from_str(name) + } else { + None + } +} + +/// Return whether a fully qualified path names one specific `std.testing` assertion helper. +#[must_use] +pub fn is_assert_helper_std_path(path: &[String], id: TestingAssertHelperId) -> bool { + assert_helper_id_from_std_path(path) == Some(id) +} + +/// Return the default assertion failure text for helpers whose message does not depend on operands. +#[must_use] +pub fn assert_helper_default_failure_message(id: TestingAssertHelperId) -> Option<&'static str> { + match id { + TestingAssertHelperId::Assert | TestingAssertHelperId::AssertFalse => Some("AssertionError"), + TestingAssertHelperId::AssertIsSome => Some("AssertionError: expected Some, got None"), + TestingAssertHelperId::AssertIsNone => Some("AssertionError: expected None, got Some"), + TestingAssertHelperId::AssertIsOk => Some("AssertionError: expected Ok, got Err"), + TestingAssertHelperId::AssertIsErr => Some("AssertionError: expected Err, got Ok"), + TestingAssertHelperId::AssertEq | TestingAssertHelperId::AssertNe | TestingAssertHelperId::AssertRaises => None, + } +} + +/// Return the operand relation text used by comparison assertion failures. +#[must_use] +pub fn assert_comparison_failure_kind(id: TestingAssertHelperId) -> Option<&'static str> { + match id { + TestingAssertHelperId::AssertEq => Some("left != right"), + TestingAssertHelperId::AssertNe => Some("left == right"), + _ => None, + } +} + +const fn assert_helper(id: TestingAssertHelperId, canonical: &'static str) -> TestingAssertHelperInfo { + LangItemInfo { + id, + canonical, + aliases: &[], + description: "Canonical testing assertion helper.", + introduced_in_rfc: RFC::_018, + since: Since(0, 1), + stability: Stability::Stable, + examples: &[], + } +} + +/// Runtime marker name for `std.testing.test`. +pub const TESTING_MARKER_TEST: &str = "test"; +/// Runtime marker name for `std.testing.fixture`. +pub const TESTING_MARKER_FIXTURE: &str = "fixture"; +/// Runtime marker name for `std.testing.skip`. +pub const TESTING_MARKER_SKIP: &str = "skip"; +/// Runtime marker name for `std.testing.skipif`. +pub const TESTING_MARKER_SKIPIF: &str = "skipif"; +/// Runtime marker name for `std.testing.xfail`. +pub const TESTING_MARKER_XFAIL: &str = "xfail"; +/// Runtime marker name for `std.testing.xfailif`. +pub const TESTING_MARKER_XFAILIF: &str = "xfailif"; +/// Runtime marker name for `std.testing.slow`. +pub const TESTING_MARKER_SLOW: &str = "slow"; +/// Runtime marker name for `std.testing.mark`. +pub const TESTING_MARKER_MARK: &str = "mark"; +/// Runtime marker name for `std.testing.resource`. +pub const TESTING_MARKER_RESOURCE: &str = "resource"; +/// Runtime marker name for `std.testing.serial`. +pub const TESTING_MARKER_SERIAL: &str = "serial"; +/// Runtime marker name for `std.testing.timeout`. +pub const TESTING_MARKER_TIMEOUT: &str = "timeout"; +/// Runtime marker name for `std.testing.parametrize`. +pub const TESTING_MARKER_PARAMETRIZE: &str = "parametrize"; + +/// Runner-only marker names that must have matching `@rust.extern` metadata in `stdlib/testing.incn`. +pub const RUNNER_ONLY_MARKER_NAMES: &[&str] = &[ + TESTING_MARKER_TEST, + TESTING_MARKER_FIXTURE, + TESTING_MARKER_SKIP, + TESTING_MARKER_SKIPIF, + TESTING_MARKER_XFAIL, + TESTING_MARKER_XFAILIF, + TESTING_MARKER_SLOW, + TESTING_MARKER_MARK, + TESTING_MARKER_RESOURCE, + TESTING_MARKER_SERIAL, + TESTING_MARKER_TIMEOUT, + TESTING_MARKER_PARAMETRIZE, +]; diff --git a/crates/incan_core/src/lang/types/collections.rs b/crates/incan_core/src/lang/types/collections.rs index 766d8172e..8f5813638 100644 --- a/crates/incan_core/src/lang/types/collections.rs +++ b/crates/incan_core/src/lang/types/collections.rs @@ -155,6 +155,17 @@ pub fn from_str(name: &str) -> Option { .map(|t| t.id) } +/// Resolve a Rust generic display base such as `Vec`, `HashMap`, or `HashSet` into the matching +/// Incan collection type without making Rust-specific names valid source-level aliases. +pub fn from_rust_display_base(base: &str) -> Option { + let tail = base.rsplit("::").next().unwrap_or(base); + match tail { + "HashMap" => Some(CollectionTypeId::Dict), + "HashSet" => Some(CollectionTypeId::Set), + _ => from_str(tail), + } +} + /// Return the canonical spelling for a collection/generic-base builtin type. /// /// ## Parameters diff --git a/crates/incan_core/tests/lang_registry_guardrails.rs b/crates/incan_core/tests/lang_registry_guardrails.rs index bc87df9c0..6f789032e 100644 --- a/crates/incan_core/tests/lang_registry_guardrails.rs +++ b/crates/incan_core/tests/lang_registry_guardrails.rs @@ -10,7 +10,8 @@ use incan_core::lang::operators; use incan_core::lang::punctuation; use incan_core::lang::registry::{RFC, Since}; use incan_core::lang::surface::types::{SurfaceTypeCategory, SurfaceTypeId, SurfaceTypeOwner}; -use incan_core::lang::surface::{constructors, functions, types as surface_types}; +use incan_core::lang::surface::{constructors, functions, iterator_methods, result_methods, types as surface_types}; +use incan_core::lang::testing; use incan_core::lang::traits; use incan_core::lang::types::{collections, numerics, stringlike}; use std::path::{Path, PathBuf}; @@ -232,6 +233,19 @@ fn types_spellings_unique_and_resolvable() { }); } +#[test] +fn collection_rust_display_bases_are_not_ordinary_source_aliases() { + assert_eq!( + collections::from_rust_display_base("std::collections::HashSet"), + Some(collections::CollectionTypeId::Set) + ); + assert_eq!( + collections::from_rust_display_base("HashMap"), + Some(collections::CollectionTypeId::Dict) + ); + assert_eq!(collections::from_str("HashSet"), None); +} + #[test] fn derives_spellings_unique_and_resolvable() { assert_registry_round_trip(RegistryRoundTrip { @@ -342,6 +356,48 @@ fn surface_functions_spellings_unique_and_resolvable() { }); } +#[test] +fn iterator_methods_spellings_unique_and_resolvable() { + assert_registry_round_trip(RegistryRoundTrip { + label: "iterator method", + expected_len: 21, + items: iterator_methods::ITERATOR_METHODS, + id_of: |info| info.id, + canonical_of: |info| info.canonical, + aliases_of: |info| info.aliases, + from_str: iterator_methods::from_str, + as_str: iterator_methods::as_str, + }); +} + +#[test] +fn result_methods_spellings_unique_and_resolvable() { + assert_registry_round_trip(RegistryRoundTrip { + label: "result method", + expected_len: 8, + items: result_methods::RESULT_METHODS, + id_of: |info| info.id, + canonical_of: |info| info.canonical, + aliases_of: |info| info.aliases, + from_str: result_methods::from_str, + as_str: result_methods::as_str, + }); +} + +#[test] +fn testing_assert_helpers_spellings_unique_and_resolvable() { + assert_registry_round_trip(RegistryRoundTrip { + label: "testing assert helper", + expected_len: 9, + items: testing::TESTING_ASSERT_HELPERS, + id_of: |info| info.id, + canonical_of: |info| info.canonical, + aliases_of: |info| info.aliases, + from_str: testing::assert_helper_from_str, + as_str: testing::assert_helper_as_str, + }); +} + #[test] fn surface_types_spellings_unique_and_resolvable() { assert_registry_round_trip(RegistryRoundTrip { diff --git a/crates/incan_stdlib/src/testing.rs b/crates/incan_stdlib/src/testing.rs index 9a203fb4f..4fbd07091 100644 --- a/crates/incan_stdlib/src/testing.rs +++ b/crates/incan_stdlib/src/testing.rs @@ -3,6 +3,12 @@ //! `crates/incan_stdlib/stdlib/testing.incn` is the source-of-truth surface API for `std.testing`. //! This Rust module implements only host-boundary functions referenced by `@rust.extern` declarations in `std.testing`. +pub use incan_core::lang::testing::{ + RUNNER_ONLY_MARKER_NAMES, TESTING_MARKER_FIXTURE, TESTING_MARKER_MARK, TESTING_MARKER_PARAMETRIZE, + TESTING_MARKER_RESOURCE, TESTING_MARKER_SERIAL, TESTING_MARKER_SKIP, TESTING_MARKER_SKIPIF, TESTING_MARKER_SLOW, + TESTING_MARKER_TEST, TESTING_MARKER_TIMEOUT, TESTING_MARKER_XFAIL, TESTING_MARKER_XFAILIF, +}; + /// Generic panic primitive used by `std.testing` helpers with non-`None` return types. /// /// # Panics @@ -12,45 +18,48 @@ pub fn fail_t(msg: String) -> T { crate::errors::__private::raise_runtime_misuse(&msg) } +/// Return the canonical runtime misuse message for a runner-only `std.testing` marker. +pub fn testing_marker_runtime_misuse_message(marker: &str) -> String { + format!("std.testing.{marker} is marker metadata for `incan test` and is not executable runtime logic") +} + fn marker_runtime_misuse(marker: &str) -> ! { - crate::errors::__private::raise_runtime_misuse(&format!( - "std.testing.{marker} is marker metadata for `incan test` and is not executable runtime logic" - )); + crate::errors::__private::raise_runtime_misuse(&testing_marker_runtime_misuse_message(marker)); } /// Marker runtime for `@std.testing.skip`. /// /// `incan test` handles skip semantics during test discovery. Calling this at runtime is a misuse. pub fn skip(_reason: String) { - marker_runtime_misuse("skip"); + marker_runtime_misuse(TESTING_MARKER_SKIP); } /// Marker runtime for `@std.testing.skipif`. /// /// `incan test` evaluates skipif conditions during discovery. Calling this at runtime is a misuse. pub fn skipif(_condition: bool, _reason: String) { - marker_runtime_misuse("skipif"); + marker_runtime_misuse(TESTING_MARKER_SKIPIF); } /// Marker runtime for `@std.testing.test`. /// /// `incan test` handles explicit test discovery. Calling this at runtime is a misuse. pub fn test() { - marker_runtime_misuse("test"); + marker_runtime_misuse(TESTING_MARKER_TEST); } /// Marker runtime for `@std.testing.xfail`. /// /// `incan test` handles xfail semantics during test discovery/execution. Calling this at runtime is a misuse. pub fn xfail(_reason: String) { - marker_runtime_misuse("xfail"); + marker_runtime_misuse(TESTING_MARKER_XFAIL); } /// Marker runtime for `@std.testing.xfailif`. /// /// `incan test` evaluates xfailif conditions during discovery. Calling this at runtime is a misuse. pub fn xfailif(_condition: bool, _reason: String) { - marker_runtime_misuse("xfailif"); + marker_runtime_misuse(TESTING_MARKER_XFAILIF); } /// Return the host platform identifier used by collection-time marker probes. @@ -69,14 +78,14 @@ pub fn feature(_name: String) -> bool { /// /// `incan test` handles slow-test filtering. Calling this at runtime is a misuse. pub fn slow() { - marker_runtime_misuse("slow"); + marker_runtime_misuse(TESTING_MARKER_SLOW); } /// Marker runtime for `@std.testing.mark`. /// /// `incan test` handles marker selection during discovery. Calling this at runtime is a misuse. pub fn mark(_name: String) { - marker_runtime_misuse("mark"); + marker_runtime_misuse(TESTING_MARKER_MARK); } /// Marker runtime for `@std.testing.resource`. @@ -84,35 +93,35 @@ pub fn mark(_name: String) { /// `incan test` uses resource metadata to avoid overlapping generated test batches that declare the same resource. /// Calling this at runtime is a misuse. pub fn resource(_name: String) { - marker_runtime_misuse("resource"); + marker_runtime_misuse(TESTING_MARKER_RESOURCE); } /// Marker runtime for `@std.testing.serial`. /// /// `incan test` uses serial metadata to run a generated test batch alone. Calling this at runtime is a misuse. pub fn serial() { - marker_runtime_misuse("serial"); + marker_runtime_misuse(TESTING_MARKER_SERIAL); } /// Marker runtime for `@std.testing.timeout`. /// /// `incan test` uses timeout metadata when running generated test batches. Calling this at runtime is a misuse. pub fn timeout(_duration: String) { - marker_runtime_misuse("timeout"); + marker_runtime_misuse(TESTING_MARKER_TIMEOUT); } /// Marker runtime for `@std.testing.fixture`. /// /// `incan test` consumes fixture metadata during discovery. Calling this at runtime is a misuse. pub fn fixture() { - marker_runtime_misuse("fixture"); + marker_runtime_misuse(TESTING_MARKER_FIXTURE); } /// Marker runtime for `@std.testing.parametrize`. /// /// Parameter expansion is handled by `incan test`; calling this at runtime is a misuse. pub fn parametrize(_argnames: String, _argvalues: Vec) { - marker_runtime_misuse("parametrize"); + marker_runtime_misuse(TESTING_MARKER_PARAMETRIZE); } /// Parameter case wrapper for decorator metadata. @@ -185,7 +194,15 @@ mod tests { use std::any::Any; use std::panic; - use super::{fail_t, fixture, skip}; + use std::collections::HashSet; + + use super::{ + RUNNER_ONLY_MARKER_NAMES, TESTING_MARKER_FIXTURE, TESTING_MARKER_MARK, TESTING_MARKER_PARAMETRIZE, + TESTING_MARKER_RESOURCE, TESTING_MARKER_SERIAL, TESTING_MARKER_SKIP, TESTING_MARKER_SKIPIF, + TESTING_MARKER_SLOW, TESTING_MARKER_TEST, TESTING_MARKER_TIMEOUT, TESTING_MARKER_XFAIL, TESTING_MARKER_XFAILIF, + fail_t, fixture, mark, parametrize, resource, serial, skip, skipif, slow, test, + testing_marker_runtime_misuse_message, timeout, xfail, xfailif, + }; fn panic_message(payload: &(dyn Any + Send)) -> Option<&str> { if let Some(message) = payload.downcast_ref::() { @@ -195,48 +212,60 @@ mod tests { } } - #[test] - fn fail_t_panics_with_the_given_message() -> Result<(), Box> { - let result = panic::catch_unwind(|| fail_t::<()>("custom failure".to_string())); + fn assert_marker_runtime_misuse(marker: &str, call: F) -> Result<(), Box> + where + F: FnOnce() + panic::UnwindSafe, + { + let result = panic::catch_unwind(call); + let expected_message = testing_marker_runtime_misuse_message(marker); match result { - Ok(()) => Err(std::io::Error::other("fail_t returned instead of panicking").into()), + Ok(()) => Err(std::io::Error::other(format!("{marker} marker returned instead of panicking")).into()), Err(payload) => { - assert_eq!(panic_message(payload.as_ref()), Some("custom failure")); + assert_eq!(panic_message(payload.as_ref()), Some(expected_message.as_str())); Ok(()) } } } #[test] - fn marker_runtime_panics_explain_runner_only_usage() -> Result<(), Box> { - let result = panic::catch_unwind(|| skip("not implemented".to_string())); + fn fail_t_panics_with_the_given_message() -> Result<(), Box> { + let result = panic::catch_unwind(|| fail_t::<()>("custom failure".to_string())); match result { - Ok(()) => Err(std::io::Error::other("skip marker returned instead of panicking").into()), + Ok(()) => Err(std::io::Error::other("fail_t returned instead of panicking").into()), Err(payload) => { - assert_eq!( - panic_message(payload.as_ref()), - Some("std.testing.skip is marker metadata for `incan test` and is not executable runtime logic"), - ); + assert_eq!(panic_message(payload.as_ref()), Some("custom failure")); Ok(()) } } } #[test] - fn fixture_runtime_panics_explain_runner_only_usage() -> Result<(), Box> { - let result = panic::catch_unwind(fixture); + fn marker_runtime_panics_explain_runner_only_usage() -> Result<(), Box> { + assert_marker_runtime_misuse(TESTING_MARKER_TEST, test)?; + assert_marker_runtime_misuse(TESTING_MARKER_FIXTURE, fixture)?; + assert_marker_runtime_misuse(TESTING_MARKER_SKIP, || skip("not implemented".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_SKIPIF, || skipif(true, "not implemented".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_XFAIL, || xfail("known issue".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_XFAILIF, || xfailif(true, "known issue".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_SLOW, slow)?; + assert_marker_runtime_misuse(TESTING_MARKER_MARK, || mark("db".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_RESOURCE, || resource("db".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_SERIAL, serial)?; + assert_marker_runtime_misuse(TESTING_MARKER_TIMEOUT, || timeout("5s".to_string()))?; + assert_marker_runtime_misuse(TESTING_MARKER_PARAMETRIZE, || { + parametrize("value".to_string(), vec![1]); + })?; + Ok(()) + } - match result { - Ok(()) => Err(std::io::Error::other("fixture marker returned instead of panicking").into()), - Err(payload) => { - assert_eq!( - panic_message(payload.as_ref()), - Some("std.testing.fixture is marker metadata for `incan test` and is not executable runtime logic"), - ); - Ok(()) - } + #[test] + fn runner_only_marker_names_are_unique() { + let mut seen = HashSet::new(); + + for marker in RUNNER_ONLY_MARKER_NAMES { + assert!(seen.insert(marker), "duplicate std.testing marker name `{marker}`"); } } } diff --git a/crates/incan_stdlib/stdlib/compression/_auto.incn b/crates/incan_stdlib/stdlib/compression/_auto.incn index 5327b9c0f..b11e91add 100644 --- a/crates/incan_stdlib/stdlib/compression/_auto.incn +++ b/crates/incan_stdlib/stdlib/compression/_auto.incn @@ -10,11 +10,11 @@ the generic boundary can express owned reader adapters directly. """ from rust::std::io import Cursor, Read -from rust::bzip2::read @ "0.6" import BzDecoder -from rust::flate2::read @ "1" import GzDecoder, ZlibDecoder -from rust::snap::read @ "1" import FrameDecoder -from rust::xz2::read @ "0.1" import XzDecoder -from rust::zstd::stream::read @ "0.13" import Decoder as ZstdReadDecoder +from rust::bzip2::read import BzDecoder +from rust::flate2::read import GzDecoder, ZlibDecoder +from rust::snap::read import FrameDecoder +from rust::xz2::read import XzDecoder +from rust::zstd::stream::read import Decoder as ZstdReadDecoder from std.compression._core import ( Codec, CompressionError, diff --git a/crates/incan_stdlib/stdlib/compression/bz2.incn b/crates/incan_stdlib/stdlib/compression/bz2.incn index 70e3c41fb..c2abf3068 100644 --- a/crates/incan_stdlib/stdlib/compression/bz2.incn +++ b/crates/incan_stdlib/stdlib/compression/bz2.incn @@ -5,8 +5,8 @@ This module owns the byte-oriented bzip2 surface and translates portable levels """ from rust::std::io import Cursor, Read -from rust::bzip2 @ "0.6" import Compression as BzCompression -from rust::bzip2::read @ "0.6" import BzDecoder, BzEncoder +from rust::bzip2 import Compression as BzCompression +from rust::bzip2::read import BzDecoder, BzEncoder from std.compression._core import Codec, CompressionError, _codec_error, _level_or_default, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/compression/deflate.incn b/crates/incan_stdlib/stdlib/compression/deflate.incn index 3701c7904..e9ad713b5 100644 --- a/crates/incan_stdlib/stdlib/compression/deflate.incn +++ b/crates/incan_stdlib/stdlib/compression/deflate.incn @@ -6,8 +6,8 @@ from autodetection. """ from rust::std::io import Cursor, Read -from rust::flate2 @ "1" import Compression as FlateCompression -from rust::flate2::read @ "1" import DeflateDecoder, DeflateEncoder +from rust::flate2 import Compression as FlateCompression +from rust::flate2::read import DeflateDecoder, DeflateEncoder from std.compression._core import Codec, CompressionError, _codec_error, _level_or_default, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/compression/gzip.incn b/crates/incan_stdlib/stdlib/compression/gzip.incn index 15de2028c..25a7ea95f 100644 --- a/crates/incan_stdlib/stdlib/compression/gzip.incn +++ b/crates/incan_stdlib/stdlib/compression/gzip.incn @@ -6,8 +6,8 @@ Rust `flate2` reader adapters as the codec boundary. """ from rust::std::io import Cursor, Read -from rust::flate2 @ "1" import Compression as FlateCompression -from rust::flate2::read @ "1" import GzDecoder, GzEncoder +from rust::flate2 import Compression as FlateCompression +from rust::flate2::read import GzDecoder, GzEncoder from std.compression._core import Codec, CompressionError, _codec_error, _level_or_default, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/compression/lzma.incn b/crates/incan_stdlib/stdlib/compression/lzma.incn index 565a390b0..b81ad7e13 100644 --- a/crates/incan_stdlib/stdlib/compression/lzma.incn +++ b/crates/incan_stdlib/stdlib/compression/lzma.incn @@ -5,7 +5,7 @@ The public `std.compression.lzma` name exposes XZ-framed LZMA-family data throug """ from rust::std::io import Cursor, Read -from rust::xz2::read @ "0.1" import XzDecoder, XzEncoder +from rust::xz2::read import XzDecoder, XzEncoder from std.compression._core import Codec, CompressionError, _codec_error, _level_or_default, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/compression/snappy.incn b/crates/incan_stdlib/stdlib/compression/snappy.incn index a34b52f71..08c12895b 100644 --- a/crates/incan_stdlib/stdlib/compression/snappy.incn +++ b/crates/incan_stdlib/stdlib/compression/snappy.incn @@ -6,7 +6,7 @@ autodetection. Raw block helpers live under `std.compression.snappy.raw`. """ from rust::std::io import Cursor, Read -from rust::snap::read @ "1" import FrameDecoder, FrameEncoder +from rust::snap::read import FrameDecoder, FrameEncoder from std.compression._core import Codec, CompressionError, _codec_error, _reject_level, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/compression/snappy/raw.incn b/crates/incan_stdlib/stdlib/compression/snappy/raw.incn index 879e6f7e5..a4c06c6d0 100644 --- a/crates/incan_stdlib/stdlib/compression/snappy/raw.incn +++ b/crates/incan_stdlib/stdlib/compression/snappy/raw.incn @@ -5,7 +5,7 @@ Raw Snappy is an advanced interop surface for systems that store individual Snap from `std.compression` autodetection because raw blocks have no stable stream signature. """ -from rust::snap::raw @ "1" import Decoder as RawDecoder, Encoder as RawEncoder +from rust::snap::raw import Decoder as RawDecoder, Encoder as RawEncoder from std.compression._core import Codec, CompressionError, _codec_error, _reject_level diff --git a/crates/incan_stdlib/stdlib/compression/zlib.incn b/crates/incan_stdlib/stdlib/compression/zlib.incn index 9940b91be..d0178f549 100644 --- a/crates/incan_stdlib/stdlib/compression/zlib.incn +++ b/crates/incan_stdlib/stdlib/compression/zlib.incn @@ -6,8 +6,8 @@ backend errors into `CompressionError`. """ from rust::std::io import Cursor, Read -from rust::flate2 @ "1" import Compression as FlateCompression -from rust::flate2::read @ "1" import ZlibDecoder, ZlibEncoder +from rust::flate2 import Compression as FlateCompression +from rust::flate2::read import ZlibDecoder, ZlibEncoder from std.compression._core import Codec, CompressionError, _codec_error, _level_or_default, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/compression/zstd.incn b/crates/incan_stdlib/stdlib/compression/zstd.incn index 44bdcc46a..08fa8a651 100644 --- a/crates/incan_stdlib/stdlib/compression/zstd.incn +++ b/crates/incan_stdlib/stdlib/compression/zstd.incn @@ -6,8 +6,8 @@ This module exposes zstd frames through one-shot byte helpers and keeps backend- """ from rust::std::io import Cursor, Read -from rust::zstd::stream @ "0.13" import decode_all, encode_all -from rust::zstd::stream::read @ "0.13" import Decoder as ZstdReadDecoder, Encoder as ZstdReadEncoder +from rust::zstd::stream import decode_all, encode_all +from rust::zstd::stream::read import Decoder as ZstdReadDecoder, Encoder as ZstdReadEncoder from std.compression._core import Codec, CompressionError, _codec_error, _level_or_default, _validate_chunk_size, _write_sink_bytes from std.fs import File from std.io import _BytesIO diff --git a/crates/incan_stdlib/stdlib/hash/_core.incn b/crates/incan_stdlib/stdlib/hash/_core.incn index 6e2eaf9f4..c720b0f63 100644 --- a/crates/incan_stdlib/stdlib/hash/_core.incn +++ b/crates/incan_stdlib/stdlib/hash/_core.incn @@ -6,16 +6,16 @@ This module owns the algorithm wrappers and value hashing paths. File and reader """ from rust::incan_stdlib::errors import raise_value_error -from rust::blake2 @ "0.10" import Blake2b512, Blake2s256 -from rust::blake3 @ "1" import Hasher as Blake3Hasher, hash as blake3_hash -from rust::md5 @ "0.10" import Md5 -from rust::sha1 @ "0.10" import Sha1 -from rust::sha2 @ "0.10" import Digest, Sha224, Sha256, Sha384, Sha512 -from rust::sha3 @ "0.10" import Sha3_224, Sha3_256, Sha3_384, Sha3_512, Shake128, Shake256 -from rust::sha3::digest @ "0.10" import ExtendableOutputReset, Update, XofReader -from rust::xxhash_rust::xxh3 @ "0.8" with ["xxh3"] import Xxh3Default, xxh3_64 as rust_xxh3_64, xxh3_128 as rust_xxh3_128 -from rust::xxhash_rust::xxh32 @ "0.8" with ["xxh32"] import Xxh32 -from rust::xxhash_rust::xxh64 @ "0.8" with ["xxh64"] import Xxh64 +from rust::blake2 import Blake2b512, Blake2s256 +from rust::blake3 import Hasher as Blake3Hasher, hash as blake3_hash +from rust::md5 import Md5 +from rust::sha1 import Sha1 +from rust::sha2 import Digest, Sha224, Sha256, Sha384, Sha512 +from rust::sha3 import Sha3_224, Sha3_256, Sha3_384, Sha3_512, Shake128, Shake256 +from rust::sha3::digest import ExtendableOutputReset, Update, XofReader +from rust::xxhash_rust::xxh3 with ["xxh3"] import Xxh3Default, xxh3_64 as rust_xxh3_64, xxh3_128 as rust_xxh3_128 +from rust::xxhash_rust::xxh32 with ["xxh32"] import Xxh32 +from rust::xxhash_rust::xxh64 with ["xxh64"] import Xxh64 from std.traits.error import Error diff --git a/crates/incan_syntax/src/ast/imports.rs b/crates/incan_syntax/src/ast/imports.rs index b01c5efc8..558041c18 100644 --- a/crates/incan_syntax/src/ast/imports.rs +++ b/crates/incan_syntax/src/ast/imports.rs @@ -61,7 +61,7 @@ pub enum ImportKind { path: Vec, /// Optional version requirement string (Cargo semver syntax). version: Option, - /// Optional feature list (only valid when `version` is provided). + /// Optional feature list. features: Vec, }, /// `from rust::time import Instant, Duration` - Rust crate with specific items @@ -71,7 +71,7 @@ pub enum ImportKind { path: Vec, /// Optional version requirement string (Cargo semver syntax). version: Option, - /// Optional feature list (only valid when `version` is provided). + /// Optional feature list. features: Vec, items: Vec, }, diff --git a/crates/incan_syntax/src/parser/decl/imports.rs b/crates/incan_syntax/src/parser/decl/imports.rs index 284ad7247..694f61c38 100644 --- a/crates/incan_syntax/src/parser/decl/imports.rs +++ b/crates/incan_syntax/src/parser/decl/imports.rs @@ -159,8 +159,8 @@ impl<'a> Parser<'a> { if self.match_keyword(KeywordId::With) { features = self.string_list()?; } - } else if self.check_keyword(KeywordId::With) { - return Err(errors::rust_import_features_require_version(self.current_span())); + } else if self.match_keyword(KeywordId::With) { + features = self.string_list()?; } Ok((version, features)) diff --git a/crates/incan_syntax/src/parser/tests.rs b/crates/incan_syntax/src/parser/tests.rs index 85d3da308..46bec96f7 100644 --- a/crates/incan_syntax/src/parser/tests.rs +++ b/crates/incan_syntax/src/parser/tests.rs @@ -2223,16 +2223,26 @@ def identity( } #[test] - fn test_parse_rust_import_with_features_requires_version() { + fn test_parse_rust_import_with_features_without_inline_version() -> Result<(), Vec> { let source = r#"import rust::tokio with ["full"]"#; - let Err(err) = parse_str(source) else { - panic!("Expected rust import features to require version"); - }; - assert!( - err[0].message.contains("features require a version"), - "Unexpected error: {}", - err[0].message - ); + let program = parse_str(source)?; + match &program.declarations[0].node { + Declaration::Import(import) => match &import.kind { + ImportKind::RustCrate { + crate_name, + version, + features, + .. + } => { + assert_eq!(crate_name, "tokio"); + assert_eq!(version, &None); + assert_eq!(features, &vec!["full".to_string()]); + } + _ => panic!("Expected rust module import"), + }, + _ => panic!("Expected import"), + } + Ok(()) } #[test] diff --git a/crates/rust_inspect/src/cache.rs b/crates/rust_inspect/src/cache.rs index f5880a8e0..b3c8954d0 100644 --- a/crates/rust_inspect/src/cache.rs +++ b/crates/rust_inspect/src/cache.rs @@ -91,7 +91,7 @@ struct DiskCacheEnvelope { } // Bump when extracted metadata semantics change in a way that makes previously persisted items unsafe to reuse. -const DISK_CACHE_FORMAT: u32 = 6; +const DISK_CACHE_FORMAT: u32 = 7; const DISK_CACHE_FILE: &str = ".incan_rust_inspect_cache.json"; // Backward-compatibility read path for caches written before the crate/module rename. const LEGACY_DISK_CACHE_FILE: &str = ".incan_rust_metadata_cache.json"; diff --git a/crates/rust_inspect/src/extractor.rs b/crates/rust_inspect/src/extractor.rs index 1bc194d11..30e43fa0c 100644 --- a/crates/rust_inspect/src/extractor.rs +++ b/crates/rust_inspect/src/extractor.rs @@ -5,7 +5,8 @@ use std::collections::BTreeMap; use incan_core::interop::{ RustFieldInfo, RustFunctionSig, RustImplementedTrait, RustItemKind, RustItemMetadata, RustMethodSig, RustModuleChild, RustModuleChildKind, RustModuleInfo, RustParam, RustTraitAssoc, RustTraitInfo, RustTypeInfo, - RustTypeShape, RustVariantInfo, RustVisibility, + RustTypeShape, RustVariantInfo, RustVisibility, render_rust_type_shape, split_top_level_rust_args, + strip_rust_borrow_lifetimes, }; use ra_ap_hir::{ Adt, AssocItem, Crate, DisplayTarget, Enum, FieldSource, Function, HasSource, HasVisibility, HirDisplay, Impl, @@ -116,33 +117,30 @@ fn canonical_adt_path(adt: Adt, db: &RootDatabase) -> Option { canonical_module_def_path(ModuleDef::Adt(adt), db) } -fn render_shape_display(shape: &RustTypeShape) -> String { - match shape { - RustTypeShape::Bool => "bool".to_string(), - RustTypeShape::Float => "f64".to_string(), - RustTypeShape::Int => "i64".to_string(), - RustTypeShape::Str => "String".to_string(), - RustTypeShape::Bytes => "Vec".to_string(), - RustTypeShape::Unit => "()".to_string(), - RustTypeShape::Option(inner) => format!("Option<{}>", render_shape_display(inner)), - RustTypeShape::Result(ok, err) => { - format!("Result<{}, {}>", render_shape_display(ok), render_shape_display(err)) - } - RustTypeShape::Tuple(items) => { - let rendered: Vec = items.iter().map(render_shape_display).collect(); - format!("({})", rendered.join(", ")) - } - RustTypeShape::Ref(inner) => format!("&{}", render_shape_display(inner)), - RustTypeShape::RustPath { path, args } => { - if args.is_empty() { - path.clone() - } else { - let rendered_args: Vec = args.iter().map(render_shape_display).collect(); - format!("{path}<{}>", rendered_args.join(", ")) - } - } - RustTypeShape::TypeParam(name) => name.clone(), - RustTypeShape::Unknown => "?".to_string(), +fn normalize_source_type_text(text: &str) -> String { + strip_rust_borrow_lifetimes(text).trim().replace(' ', "") +} + +fn borrowed_builtin_source_display(text: &str) -> Option { + let normalized = normalize_source_type_text(text); + let (prefix, inner) = if let Some(inner) = normalized.strip_prefix("&mut") { + ("&mut", inner) + } else if let Some(inner) = normalized.strip_prefix('&') { + ("&", inner) + } else { + return None; + }; + match inner { + "str" + | "[u8]" + | "String" + | "std::string::String" + | "alloc::string::String" + | "Vec" + | "std::vec::Vec" + | "alloc::vec::Vec" => Some(format!("{prefix}{inner}")), + _ if is_exact_numeric_display(inner) => Some(format!("{prefix}{inner}")), + _ => None, } } @@ -232,36 +230,8 @@ fn resolve_source_path(text: &str, crate_name: &str, module: Module, db: &RootDa None } -fn split_top_level_args(text: &str) -> Vec<&str> { - let mut args = Vec::new(); - let mut start = 0usize; - let mut angle = 0usize; - let mut paren = 0usize; - let mut bracket = 0usize; - for (idx, ch) in text.char_indices() { - match ch { - '<' => angle += 1, - '>' => angle = angle.saturating_sub(1), - '(' => paren += 1, - ')' => paren = paren.saturating_sub(1), - '[' => bracket += 1, - ']' => bracket = bracket.saturating_sub(1), - ',' if angle == 0 && paren == 0 && bracket == 0 => { - args.push(text[start..idx].trim()); - start = idx + 1; - } - _ => {} - } - } - let tail = text[start..].trim(); - if !tail.is_empty() { - args.push(tail); - } - args -} - fn source_type_shape(text: &str, crate_name: &str, module: Module, db: &RootDatabase) -> RustTypeShape { - let text = text.trim().replace(' ', ""); + let text = normalize_source_type_text(text); if text.is_empty() { return RustTypeShape::Unknown; } @@ -281,7 +251,7 @@ fn source_type_shape(text: &str, crate_name: &str, module: Module, db: &RootData return RustTypeShape::Ref(Box::new(source_type_shape(inner, crate_name, module, db))); } - if text == "[u8]" || text == "&[u8]" { + if text == "[u8]" { return RustTypeShape::Bytes; } @@ -291,7 +261,7 @@ fn source_type_shape(text: &str, crate_name: &str, module: Module, db: &RootData return RustTypeShape::Unit; } return RustTypeShape::Tuple( - split_top_level_args(inner) + split_top_level_rust_args(inner) .into_iter() .map(|arg| source_type_shape(arg, crate_name, module, db)) .collect(), @@ -304,7 +274,7 @@ fn source_type_shape(text: &str, crate_name: &str, module: Module, db: &RootData let base = resolve_source_path(&text[..start], crate_name, module, db).unwrap_or_else(|| text[..start].to_string()); let inner = &text[start + 1..text.len() - 1]; - let args: Vec = split_top_level_args(inner) + let args: Vec = split_top_level_rust_args(inner) .into_iter() .map(|arg| source_type_shape(arg, crate_name, module, db)) .collect(); @@ -456,7 +426,7 @@ fn function_sig_type_display(ty: &Type<'_>, db: &RootDatabase, dt: DisplayTarget } match rust_type_shape(ty, db, dt) { RustTypeShape::Unknown => raw, - other => render_shape_display(&other), + other => render_rust_type_shape(&other), } } @@ -469,6 +439,9 @@ fn function_sig_type_display(ty: &Type<'_>, db: &RootDatabase, dt: DisplayTarget fn source_function_return_type_display(f: Function, db: &RootDatabase) -> Option { let source = f.source(db)?; let text = source.value.ret_type()?.ty()?.to_string(); + if let Some(display) = borrowed_builtin_source_display(text.as_str()) { + return Some(display); + } let module = f.module(db); let crate_name = module .krate(db) @@ -480,7 +453,7 @@ fn source_function_return_type_display(f: Function, db: &RootDatabase) -> Option } Some(match shape { RustTypeShape::Unknown => normalize_display_path(text.as_str()), - other => render_shape_display(&other), + other => render_rust_type_shape(&other), }) } @@ -600,6 +573,9 @@ fn source_function_param_type_display(f: Function, param: &ra_ap_hir::Param<'_>, } let source_param = param_list.params().nth(param.index() - self_offset)?; let text = source_param.ty()?.to_string(); + if let Some(display) = borrowed_builtin_source_display(text.as_str()) { + return Some(display); + } if let Some(imported_display) = canonicalize_imported_single_segment_type_display(text.as_str(), f, db) { return Some(imported_display); } @@ -619,7 +595,7 @@ fn source_function_param_type_display(f: Function, param: &ra_ap_hir::Param<'_>, } let rendered = match shape { RustTypeShape::Unknown => normalize_display_path(text.as_str()), - other => render_shape_display(&other), + other => render_rust_type_shape(&other), }; if rendered.contains('?') && let Some(imported_display) = canonicalize_imported_single_segment_type_display(text.as_str(), f, db) @@ -636,7 +612,12 @@ fn extract_function_sig(f: Function, db: &RootDatabase, dt: DisplayTarget) -> Ru .map(|p| { let shape = rust_type_shape(p.ty(), db, dt); let mut type_display = function_sig_type_display(p.ty(), db, dt); - if (type_shape_contains_unknown(&shape) || p.ty().contains_unknown() || type_display.contains('?')) + if (type_shape_contains_unknown(&shape) + || p.ty().contains_unknown() + || type_display.contains('?') + || source_function_param_type_display(f, &p, db).is_some_and(|source_type_display| { + source_type_display.starts_with('&') && !type_display.starts_with('&') + })) && let Some(source_type_display) = source_function_param_type_display(f, &p, db) { type_display = source_type_display; @@ -648,8 +629,12 @@ fn extract_function_sig(f: Function, db: &RootDatabase, dt: DisplayTarget) -> Ru }) .collect(); let output_type = f.async_ret_type(db).unwrap_or_else(|| f.ret_type(db)); + let output_shape = rust_type_shape(&output_type, db, dt); let mut return_type = function_sig_type_display(&output_type, db, dt); - if return_type.starts_with("impl ") + if (return_type.starts_with("impl ") + || type_shape_contains_unknown(&output_shape) + || output_type.contains_unknown() + || return_type.contains('?')) && let Some(source_return_type) = source_function_return_type_display(f, db) { return_type = source_return_type; @@ -1074,4 +1059,57 @@ edition = "2021" assert_eq!(fields, ["zeta", "alpha"]); Ok(()) } + + #[test] + fn type_metadata_preserves_borrowed_slice_params_and_borrowed_option_returns() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + fs::create_dir_all(tmp.path().join("src"))?; + fs::write( + tmp.path().join("Cargo.toml"), + r#"[package] +name = "demo_borrow_probe" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + tmp.path().join("src/lib.rs"), + r#"pub struct Codec; + +pub static CODEC: Codec = Codec; + +impl Codec { + pub fn for_label(label: &[u8]) -> Option<&'static Codec> { + let _ = label; + Some(&CODEC) + } + + pub fn decode<'a>(&'static self, bytes: &'a [u8]) -> (&'a [u8], &'static Codec, bool) { + (bytes, self, false) + } +} +"#, + )?; + + let workspace = RustWorkspace::load(tmp.path(), &|_| ())?; + let metadata = extract_rust_item(&workspace, "demo_borrow_probe::Codec")?; + let RustItemKind::Type(info) = metadata.kind else { + return Err(std::io::Error::other("expected type metadata").into()); + }; + let for_label = info + .methods + .iter() + .find(|method| method.name == "for_label") + .ok_or_else(|| std::io::Error::other("expected for_label metadata"))?; + assert_eq!(for_label.signature.params[0].type_display, "&[u8]"); + assert_eq!(for_label.signature.return_type, "Option<&demo_borrow_probe::Codec>"); + let decode = info + .methods + .iter() + .find(|method| method.name == "decode") + .ok_or_else(|| std::io::Error::other("expected decode metadata"))?; + assert_eq!(decode.signature.params[1].type_display, "&[u8]"); + Ok(()) + } } diff --git a/examples/pro/vocab_querykit/consumer/incan.lock b/examples/pro/vocab_querykit/consumer/incan.lock index 9dbbbe516..45532b2b7 100644 --- a/examples/pro/vocab_querykit/consumer/incan.lock +++ b/examples/pro/vocab_querykit/consumer/incan.lock @@ -3,7 +3,7 @@ [incan] format = 1 -incan-version = "0.3.0-dev.51" +incan-version = "0.3.0-rc6" deps-fingerprint = "sha256:d66866eca21aa7a29b265ef932049fe5b6da692cbe734cd4f7d300ce7163b359" cargo-features = [] cargo-no-default-features = false @@ -17,14 +17,14 @@ version = 4 [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_derive", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "querykit_consumer" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "querykit_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", diff --git a/examples/pro/vocab_querykit/producer/incan.lock b/examples/pro/vocab_querykit/producer/incan.lock index 615fe6444..593a53823 100644 --- a/examples/pro/vocab_querykit/producer/incan.lock +++ b/examples/pro/vocab_querykit/producer/incan.lock @@ -3,7 +3,7 @@ [incan] format = 1 -incan-version = "0.3.0-dev.51" +incan-version = "0.3.0-rc6" deps-fingerprint = "sha256:17f122844d2fa1c9756f9a1976d222f15255557e74d975b8d8ff46536ea82b87" cargo-features = [] cargo-no-default-features = false @@ -17,14 +17,14 @@ version = 4 [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_derive", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "querykit_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", diff --git a/examples/pro/vocab_routekit/consumer/incan.lock b/examples/pro/vocab_routekit/consumer/incan.lock index 7e9a1589c..4b2181778 100644 --- a/examples/pro/vocab_routekit/consumer/incan.lock +++ b/examples/pro/vocab_routekit/consumer/incan.lock @@ -3,7 +3,7 @@ [incan] format = 1 -incan-version = "0.3.0-dev.51" +incan-version = "0.3.0-rc6" deps-fingerprint = "sha256:316bf142e6f8ea3b5838746eabec99c7e77d0acbcca01f8890c489b63498a743" cargo-features = [] cargo-no-default-features = false @@ -17,14 +17,14 @@ version = 4 [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_derive", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "routekit_consumer" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", @@ -68,7 +68,7 @@ dependencies = [ [[package]] name = "routekit_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", diff --git a/examples/pro/vocab_routekit/producer/incan.lock b/examples/pro/vocab_routekit/producer/incan.lock index 971820470..12b833d16 100644 --- a/examples/pro/vocab_routekit/producer/incan.lock +++ b/examples/pro/vocab_routekit/producer/incan.lock @@ -3,7 +3,7 @@ [incan] format = 1 -incan-version = "0.3.0-dev.51" +incan-version = "0.3.0-rc6" deps-fingerprint = "sha256:17f122844d2fa1c9756f9a1976d222f15255557e74d975b8d8ff46536ea82b87" cargo-features = [] cargo-no-default-features = false @@ -17,14 +17,14 @@ version = 4 [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_derive", @@ -59,7 +59,7 @@ dependencies = [ [[package]] name = "routekit_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", diff --git a/examples/pro/vocab_studiokit/consumer/incan.lock b/examples/pro/vocab_studiokit/consumer/incan.lock index 04ee44ea8..0232e3728 100644 --- a/examples/pro/vocab_studiokit/consumer/incan.lock +++ b/examples/pro/vocab_studiokit/consumer/incan.lock @@ -3,7 +3,7 @@ [incan] format = 1 -incan-version = "0.3.0-dev.51" +incan-version = "0.3.0-rc6" deps-fingerprint = "sha256:e434303c58e58e0d05c2ffbd9b4c3b5a5984c4d74d64978e203d295f87495eae" cargo-features = [] cargo-no-default-features = false @@ -17,14 +17,14 @@ version = 4 [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_derive", @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "studiokit_consumer" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", @@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "studiokit_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", diff --git a/examples/pro/vocab_studiokit/producer/incan.lock b/examples/pro/vocab_studiokit/producer/incan.lock index 2ecb8140b..6fa7107b7 100644 --- a/examples/pro/vocab_studiokit/producer/incan.lock +++ b/examples/pro/vocab_studiokit/producer/incan.lock @@ -3,7 +3,7 @@ [incan] format = 1 -incan-version = "0.3.0-dev.51" +incan-version = "0.3.0-rc6" deps-fingerprint = "sha256:17f122844d2fa1c9756f9a1976d222f15255557e74d975b8d8ff46536ea82b87" cargo-features = [] cargo-no-default-features = false @@ -17,14 +17,14 @@ version = 4 [[package]] name = "incan_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "proc-macro2", "quote", @@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_core", "incan_derive", @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "studiokit_core" -version = "0.3.0-dev.51" +version = "0.3.0-rc6" dependencies = [ "incan_derive", "incan_stdlib", diff --git a/scripts/check_changed_rustdocs.py b/scripts/check_changed_rustdocs.py index 111fc9c4d..55ea0afc0 100644 --- a/scripts/check_changed_rustdocs.py +++ b/scripts/check_changed_rustdocs.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Fail when touched Rust source files contain undocumented non-test functions or methods. +"""Fail when changed Rust source files contain undocumented non-test functions or methods. -This script is intentionally scoped to changed `.rs` files so the branch enforces a boyscout-style documentation -standard without requiring an immediate repo-wide documentation migration. +By default, this checks both staged and unstaged `.rs` changes. Pass `--base ` or set `INCAN_RUSTDOC_GATE_BASE` +when a release or review branch needs to be checked against a comparison base such as `origin/release/v0.2`. Eventually, we can replace this script with the following clippy rules: #![warn(missing_docs)] @@ -11,6 +11,8 @@ from __future__ import annotations +import argparse +import os import re import subprocess import sys @@ -27,10 +29,16 @@ HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P\d+)(?:,(?P\d+))? @@") -def changed_rust_files() -> dict[Path, set[int]]: - """Return changed Rust source files and their changed current-file line numbers.""" +def merge_changed_lines(target: dict[Path, set[int]], source: dict[Path, set[int]]) -> None: + """Merge changed-line data from one parsed diff into `target`.""" + for path, lines in source.items(): + target.setdefault(path, set()).update(lines) + + +def changed_rust_files_from_diff_args(args: list[str]) -> dict[Path, set[int]]: + """Return changed Rust source files and current-file line numbers for one `git diff` invocation.""" result = subprocess.run( - ["git", "diff", "--unified=0", "--", "*.rs"], + args, cwd=ROOT, capture_output=True, text=True, @@ -64,7 +72,24 @@ def changed_rust_files() -> dict[Path, set[int]]: count = int(match.group("count") or "1") if count == 0: continue - files[current_path].update(range(start, start + count)) + files[current_path].update(range(start, start + count)) + return files + + +def changed_rust_files(base_ref: str | None) -> dict[Path, set[int]]: + """Return changed Rust source files and their changed current-file line numbers.""" + if base_ref: + return changed_rust_files_from_diff_args(["git", "diff", "--unified=0", base_ref, "--", "*.rs"]) + + files: dict[Path, set[int]] = {} + merge_changed_lines( + files, + changed_rust_files_from_diff_args(["git", "diff", "--unified=0", "--", "*.rs"]), + ) + merge_changed_lines( + files, + changed_rust_files_from_diff_args(["git", "diff", "--cached", "--unified=0", "--", "*.rs"]), + ) return files @@ -191,10 +216,22 @@ def missing_docs(path: Path, changed_lines: set[int]) -> list[tuple[int, str]]: return misses -def main() -> int: +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse command-line options for the rustdoc gate.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base", + default=os.environ.get("INCAN_RUSTDOC_GATE_BASE"), + help="optional git ref to diff against instead of staged plus unstaged changes", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: """Run the touched-file rustdoc gate and print failures in `path:line:name` form.""" + args = parse_args(sys.argv[1:] if argv is None else argv) misses: list[tuple[Path, int, str]] = [] - for path, changed_lines in changed_rust_files().items(): + for path, changed_lines in changed_rust_files(args.base).items(): for line, name in missing_docs(path, changed_lines): misses.append((path, line, name)) diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index ccad9d95c..9c15a228f 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -33,719 +33,26 @@ use std::env; use std::path::PathBuf; use std::sync::Arc; -use crate::frontend::ast::{self, Declaration, Expr, ImportKind, ImportPath, Program}; -use crate::frontend::decorator_resolution; +use crate::frontend::ast::Program; use crate::frontend::diagnostics::CompileError; use crate::frontend::library_manifest_index::LibraryManifestIndex; -use crate::frontend::module::canonicalize_source_module_segments; -use crate::frontend::typechecker::stdlib_loader::StdlibAstCache; -use crate::library_manifest::{EnumValueExport, EnumValueTypeExport}; -use incan_core::lang::decorators::{self, DecoratorId}; -use incan_core::lang::traits::{self as core_traits, TraitId}; -use incan_core::lang::{stdlib, trait_capabilities}; - -use super::emit::{ExternalOrdinalCustomKey, ExternalOrdinalValueEnum}; + use super::scanners::{ check_for_this_import as scan_check_for_this_import, collect_rust_crates as scan_collect_rust_crates, detect_serde_usage, }; use super::{AstLowering, EmitError, EmitService, IrEmitter, LoweringErrors}; -const SERDE_SERIALIZE_DERIVE: &str = "serde::Serialize"; -const SERDE_DESERIALIZE_DERIVE: &str = "serde::Deserialize"; - -fn collect_model_field_aliases(main: &Program, deps: &[(&str, &Program)]) -> HashMap> { - use crate::frontend::ast::Declaration; - - let mut out: HashMap> = HashMap::new(); - - let mut visit = |p: &Program| { - for decl in &p.declarations { - let Declaration::Model(m) = &decl.node else { - continue; - }; - - let mut map: HashMap = HashMap::new(); - for f in &m.fields { - if let Some(alias) = &f.node.metadata.alias { - map.insert(alias.clone(), f.node.name.clone()); - } - } - - if !map.is_empty() { - out.entry(m.name.clone()).or_default().extend(map); - } - } - }; - - visit(main); - for (_, dep) in deps { - visit(dep); - } - - out -} - -/// Resolve a source import path to the generated Rust module path used for dependency emission. -fn generated_module_path_for_source_import(path: &ImportPath, current_module_path: &[String]) -> Option> { - let resolved_segments = if path.parent_levels > 0 { - let keep = current_module_path.len().checked_sub(path.parent_levels)?; - let mut resolved = current_module_path[..keep].to_vec(); - resolved.extend(path.segments.clone()); - resolved - } else { - path.segments.clone() - }; - let mut segments = canonicalize_source_module_segments(&resolved_segments); - - if segments.first().map(String::as_str) == Some(stdlib::STDLIB_ROOT) { - segments[0] = stdlib::INCAN_STD_NAMESPACE.to_string(); - } - - Some(segments) -} - -/// True when a dependency module should keep its public API even if the main module does not import every item. -fn should_preserve_dependency_public_items(module_path: &[String], preserve_non_stdlib_public_items: bool) -> bool { - if matches!( - module_path.first().map(String::as_str), - Some(stdlib::STDLIB_ROOT | stdlib::INCAN_STD_NAMESPACE) - ) { - return true; - } - preserve_non_stdlib_public_items -} - -/// Return whether a function carries the stdlib-backed web route decorator that lowers to a Rust proc-macro attribute. -/// -/// Binary-style dependency emission prunes otherwise-unreferenced private items. Route handlers are different because -/// their Rust attribute expands into inventory registration after IR emission, so the function itself is a generated -/// entrypoint even when no Incan expression calls it directly. -fn has_web_route_passthrough_decorator( - func: &ast::FunctionDecl, - aliases: &HashMap>, - stdlib_cache: &mut StdlibAstCache, -) -> bool { - func.decorators.iter().any(|decorator| { - let resolved = decorator_resolution::resolve_decorator_path(&decorator.node, aliases); - if resolved.len() < 2 { - return false; - } - let module_segments = &resolved[..resolved.len() - 1]; - let name = &resolved[resolved.len() - 1]; - if name != "route" { - return false; - } - let Some(meta) = stdlib_cache.lookup_function_meta(module_segments, name) else { - return false; - }; - meta.is_rust_extern && meta.rust_module_path.as_deref() == Some("incan_web_macros") - }) -} - -/// Collect dependency-module declarations that are referenced through imports. -fn collect_externally_reachable_items_by_module( - main: &Program, - dependency_modules: &[(&str, &Program, Option>)], -) -> HashMap, HashSet> { - let module_paths: HashSet> = dependency_modules - .iter() - .map(|(name, _, path_segments)| path_segments.clone().unwrap_or_else(|| vec![(*name).to_string()])) - .collect(); - - /// Record imported item names against the generated dependency module that owns them. - fn record_imports( - reachable: &mut HashMap, HashSet>, - program: &Program, - current_module_path: &[String], - module_paths: &HashSet>, - ) { - if crate::frontend::surface_semantics::uses_ambient_log_surface(program) { - reachable - .entry(vec!["std".to_string(), "logging".to_string()]) - .or_default() - .insert("get_logger".to_string()); - } - let mut module_import_bindings: HashMap> = HashMap::new(); - for decl in &program.declarations { - let Declaration::Import(import) = &decl.node else { - continue; - }; - match &import.kind { - ImportKind::From { module, items } => { - let Some(module_path) = generated_module_path_for_source_import(module, current_module_path) else { - continue; - }; - let reachable_items = reachable.entry(module_path).or_default(); - for item in items { - reachable_items.insert(item.name.clone()); - } - } - ImportKind::Module(path) => { - let Some(segments) = generated_module_path_for_source_import(path, current_module_path) else { - continue; - }; - if module_paths.contains(&segments) { - if let Some(binding) = import.alias.clone().or_else(|| path.segments.last().cloned()) { - module_import_bindings.insert(binding, segments); - } - continue; - } - let Some(item_name) = segments.last() else { - continue; - }; - for module_path in module_paths { - if segments.len() == module_path.len() + 1 && segments.starts_with(module_path) { - reachable - .entry(module_path.clone()) - .or_default() - .insert(item_name.clone()); - break; - } - } - } - ImportKind::PubLibrary { .. } - | ImportKind::PubFrom { .. } - | ImportKind::RustCrate { .. } - | ImportKind::RustFrom { .. } - | ImportKind::Python(_) => {} - } - } - if !module_import_bindings.is_empty() { - let _ = crate::frontend::ast_walk::any_expr_in_program(program, |expr| { - if let Expr::Field(object, field) = expr - && let Expr::Ident(binding) = &object.node - && let Some(module_path) = module_import_bindings.get(binding) - { - reachable.entry(module_path.clone()).or_default().insert(field.clone()); - } - if let Expr::MethodCall(object, method, _, _) = expr - && let Expr::Ident(binding) = &object.node - && let Some(module_path) = module_import_bindings.get(binding) - { - reachable.entry(module_path.clone()).or_default().insert(method.clone()); - } - false - }); - } - if module_paths.contains(current_module_path) { - let aliases = decorator_resolution::collect_import_aliases(program); - let mut stdlib_cache = StdlibAstCache::new(); - for decl in &program.declarations { - let Declaration::Function(func) = &decl.node else { - continue; - }; - if has_web_route_passthrough_decorator(func, &aliases, &mut stdlib_cache) { - reachable - .entry(current_module_path.to_vec()) - .or_default() - .insert(func.name.clone()); - } - } - } - } - - let mut reachable = HashMap::new(); - record_imports(&mut reachable, main, &[String::from("main")], &module_paths); - for (name, program, path_segments) in dependency_modules { - let module_path = path_segments.clone().unwrap_or_else(|| vec![(*name).to_string()]); - record_imports(&mut reachable, program, &module_path, &module_paths); - } - reachable -} - -/// Dependency type facts gathered during codegen setup and reused by module emission. -/// -/// Multi-file consumers only carry short nominal type names after typechecking/lowering, so emission cannot infer -/// imported-enum ownership rules from local IR alone. This metadata keeps a single codegen-owned source of truth for: -/// - dependency module qualification (`module_paths`) -/// - short-name collisions that must not be auto-qualified (`ambiguous_type_names`) -/// - imported enum names that are safe to treat as enum loop elements (`enum_type_names`) -/// - imported stdlib error types whose trait methods require Rust trait imports (`error_trait_type_names`) -#[derive(Debug, Clone, Default)] -struct DependencyTypeMetadata { - module_paths: HashMap>, - ambiguous_type_names: HashSet, - enum_type_names: HashSet, - error_trait_type_names: HashSet, -} - -/// Collect dependency type metadata needed by IR emission for cross-module nominal types. -/// -/// Enum loop ownership is the subtle case: imported enums lower to nominal `Struct(name)` references in consumer -/// modules, so the emitter cannot rely on local enum declarations when deciding whether `list[T]` loops should emit -/// `.iter().cloned()`. This helper records enum names from dependency modules while excluding ambiguous short names and -/// short names that are also used by non-enum dependency types. -fn collect_dependency_type_metadata(deps: &[(&str, &Program, Option>)]) -> DependencyTypeMetadata { - let mut paths: HashMap> = HashMap::new(); - let mut ambiguous: HashSet = HashSet::new(); - let mut enum_type_names: HashSet = HashSet::new(); - let mut non_enum_type_names: HashSet = HashSet::new(); - let mut error_trait_type_names: HashSet = HashSet::new(); - let error_trait_name = core_traits::as_str(TraitId::Error); - - for (_name, program, path_segments) in deps { - for decl in &program.declarations { - let type_name = match &decl.node { - Declaration::Model(m) => { - if m.traits.iter().any(|bound| bound.node.name == error_trait_name) { - error_trait_type_names.insert(m.name.clone()); - } - Some((&m.name, false)) - } - Declaration::Class(c) => { - if c.traits.iter().any(|bound| bound.node.name == error_trait_name) { - error_trait_type_names.insert(c.name.clone()); - } - Some((&c.name, false)) - } - Declaration::Enum(e) => Some((&e.name, true)), - Declaration::TypeAlias(a) => Some((&a.name, false)), - Declaration::Newtype(n) => Some((&n.name, false)), - _ => None, - }; - let Some((name, is_enum)) = type_name else { - continue; - }; - - if is_enum { - enum_type_names.insert(name.clone()); - } else { - non_enum_type_names.insert(name.clone()); - } +mod dependency_metadata; +mod ordinal_bridge; +mod serde_activation; - let Some(segs) = path_segments.as_ref() else { - continue; - }; - - if let Some(existing) = paths.get(name) { - if existing != segs { - ambiguous.insert(name.clone()); - } - } else { - paths.insert(name.clone(), segs.clone()); - } - } - } - - for name in &ambiguous { - paths.remove(name); - } - enum_type_names.retain(|name| !ambiguous.contains(name) && !non_enum_type_names.contains(name)); - - DependencyTypeMetadata { - module_paths: paths, - ambiguous_type_names: ambiguous, - enum_type_names, - error_trait_type_names, - } -} - -/// Return whether a program imports the stdlib ordinal-map contract. -fn imports_std_ordinal_contract(program: &Program) -> bool { - let capability = trait_capabilities::stable_ordinal_key(); - program.declarations.iter().any(|decl| { - let Declaration::Import(import) = &decl.node else { - return false; - }; - match &import.kind { - ImportKind::Module(_) => false, - ImportKind::From { module, items } if import_path_matches_capability(module, capability) => items - .iter() - .any(|item| trait_capabilities::import_triggers_capability(capability, item.name.as_str())), - _ => false, - } - }) -} - -/// Return whether an import path names the module that owns a temporary capability contract. -fn import_path_matches_capability(path: &ImportPath, capability: &trait_capabilities::TraitCapabilityInfo) -> bool { - trait_capabilities::module_path_matches(capability, &path.segments) -} - -/// Return whether any module in the current compilation needs value-enum `OrdinalKey` impls. -fn compilation_imports_std_ordinal_contract(main: &Program, deps: &[(&str, &Program, Option>)]) -> bool { - imports_std_ordinal_contract(main) || deps.iter().any(|(_, program, _)| imports_std_ordinal_contract(program)) -} - -/// Collect public scalar value enums from loaded `.incnlib` dependencies. -fn external_ordinal_value_enums(index: Option<&Arc>) -> Vec { - let Some(index) = index else { - return Vec::new(); - }; - let mut out = Vec::new(); - for dependency_key in index.known_libraries() { - let Some(crate::frontend::library_manifest_index::LibraryManifestIndexEntry::Loaded { manifest, metadata }) = - index.get(&dependency_key) - else { - continue; - }; - for enum_export in &manifest.exports.enums { - let Some(value_type) = enum_export.value_type else { - continue; - }; - let value_type = match value_type { - EnumValueTypeExport::Str => super::decl::IrEnumValueType::String, - EnumValueTypeExport::Int => super::decl::IrEnumValueType::Int, - }; - let mut values = Vec::new(); - let mut complete = true; - for variant in &enum_export.variants { - let Some(value) = &variant.value else { - complete = false; - break; - }; - values.push(match value { - EnumValueExport::Str(value) => super::decl::IrEnumValue::String(value.clone()), - EnumValueExport::Int(value) => super::decl::IrEnumValue::Int(*value), - }); - } - if !complete { - continue; - } - out.push(ExternalOrdinalValueEnum { - dependency_key: dependency_key.clone(), - name: enum_export.name.clone(), - type_identity: enum_export - .ordinal_type_identity - .clone() - .unwrap_or_else(|| format!("{}.{}", metadata.manifest_name, enum_export.name)), - value_type, - values, - }); - } - } - out -} - -/// Return whether a serialized trait bound names the std `OrdinalKey` capability. -fn type_bound_matches_ordinal_key(bound: &crate::library_manifest::TypeBoundExport) -> bool { - let capability = trait_capabilities::stable_ordinal_key(); - let trait_name = bound - .source_name - .as_deref() - .unwrap_or_else(|| bound.name.rsplit('.').next().unwrap_or(bound.name.as_str())); - if trait_name != capability.trait_name { - return false; - } - let Some(module_path) = &bound.module_path else { - return false; - }; - trait_capabilities::module_path_matches(capability, module_path) -} - -/// Return whether any exported trait adoption satisfies the std `OrdinalKey` contract. -fn export_adopts_ordinal_key( - trait_adoptions: &[crate::library_manifest::TypeBoundExport], - traits: &HashMap, -) -> bool { - trait_adoptions - .iter() - .any(|bound| type_bound_matches_ordinal_key(bound) || trait_bound_extends_ordinal_key(bound, traits)) -} - -/// Return whether a serialized trait bound resolves transitively to std `OrdinalKey`. -fn trait_bound_extends_ordinal_key( - bound: &crate::library_manifest::TypeBoundExport, - traits: &HashMap, -) -> bool { - let mut seen = HashSet::new(); - let mut work = vec![bound.name.as_str()]; - while let Some(name) = work.pop() { - if !seen.insert(name.to_string()) { - continue; - } - let Some(trait_export) = traits.get(name) else { - continue; - }; - for supertrait in &trait_export.supertraits { - if type_bound_matches_ordinal_key(supertrait) { - return true; - } - work.push(supertrait.name.as_str()); - } - } - false -} - -/// Return lookup keys for a manifest trait export, including its original source name when reexported under an alias. -fn trait_export_lookup_keys(trait_export: &crate::library_manifest::TraitExport) -> Vec { - let mut keys = vec![trait_export.name.clone()]; - if let Some(source_name) = &trait_export.source_name - && source_name != &trait_export.name - { - keys.push(source_name.clone()); - } - keys -} - -/// Return whether a manifest method set exposes a source method or its generated alias. -fn export_methods_include(methods: &[crate::library_manifest::MethodExport], name: &str) -> bool { - methods - .iter() - .any(|method| method.name == name || method.alias_of.as_deref() == Some(name)) -} - -/// Build custom-key bridge metadata for one exported concrete type when it adopts `OrdinalKey`. -fn external_ordinal_custom_key( - dependency_key: &str, - name: &str, - type_params: &[crate::library_manifest::TypeParamExport], - trait_adoptions: &[crate::library_manifest::TypeBoundExport], - methods: &[crate::library_manifest::MethodExport], - traits: &HashMap, -) -> Option { - if !type_params.is_empty() || !export_adopts_ordinal_key(trait_adoptions, traits) { - return None; - } - let hooks = trait_capabilities::stable_ordinal_key().bridge_hooks?; - Some(ExternalOrdinalCustomKey { - dependency_key: dependency_key.to_string(), - name: name.to_string(), - has_ordinal_hash: export_methods_include(methods, hooks.hash_method), - has_ordinal_bytes_equal: export_methods_include(methods, hooks.bytes_equal_method), - }) -} - -/// Collect public user-authored `OrdinalKey` adopters from loaded `.incnlib` dependencies. -fn external_ordinal_custom_keys(index: Option<&Arc>) -> Vec { - let Some(index) = index else { - return Vec::new(); - }; - let mut out = Vec::new(); - for dependency_key in index.known_libraries() { - let Some(crate::frontend::library_manifest_index::LibraryManifestIndexEntry::Loaded { manifest, .. }) = - index.get(&dependency_key) - else { - continue; - }; - let traits = manifest - .exports - .traits - .iter() - .flat_map(|trait_export| { - trait_export_lookup_keys(trait_export) - .into_iter() - .map(move |key| (key, trait_export)) - }) - .collect::>(); - for model in &manifest.exports.models { - if let Some(key) = external_ordinal_custom_key( - &dependency_key, - &model.name, - &model.type_params, - &model.trait_adoptions, - &model.methods, - &traits, - ) { - out.push(key); - } - } - for class in &manifest.exports.classes { - if let Some(key) = external_ordinal_custom_key( - &dependency_key, - &class.name, - &class.type_params, - &class.trait_adoptions, - &class.methods, - &traits, - ) { - out.push(key); - } - } - for newtype in &manifest.exports.newtypes { - if let Some(key) = external_ordinal_custom_key( - &dependency_key, - &newtype.name, - &newtype.type_params, - &newtype.trait_adoptions, - &newtype.methods, - &traits, - ) { - out.push(key); - } - } - for enum_export in &manifest.exports.enums { - if enum_export.value_type.is_some() { - continue; - } - if let Some(key) = external_ordinal_custom_key( - &dependency_key, - &enum_export.name, - &enum_export.type_params, - &enum_export.trait_adoptions, - &enum_export.methods, - &traits, - ) { - out.push(key); - } - } - } - out -} - -#[derive(Debug, Clone)] -struct OrdinalBridgeConfig { - emit_std_ordinal_value_enum_impls: bool, - external_value_enums: Vec, - external_custom_keys: Vec, -} - -impl OrdinalBridgeConfig { - /// Build a bridge configuration for generated internal modules. - fn for_internal_module(uses_std_ordinal_contract: bool) -> Self { - Self { - emit_std_ordinal_value_enum_impls: uses_std_ordinal_contract, - external_value_enums: Vec::new(), - external_custom_keys: Vec::new(), - } - } - - /// Build a bridge configuration for crate-root emission where dependency adapters live. - fn for_crate_root(uses_std_ordinal_contract: bool, index: Option<&Arc>) -> Self { - if !uses_std_ordinal_contract { - return Self::for_internal_module(false); - } - Self { - emit_std_ordinal_value_enum_impls: true, - external_value_enums: external_ordinal_value_enums(index), - external_custom_keys: external_ordinal_custom_keys(index), - } - } -} - -/// Return whether any loaded module derives serde serialize or deserialize through resolved JSON derive imports. -fn collect_serde_derives(main: &Program, deps: &[(&str, &Program)]) -> (bool, bool) { - let mut has_serialize = false; - let mut has_deserialize = false; - - let mut visit = |program: &Program| { - let import_aliases = decorator_resolution::collect_import_aliases(program); - for decl in &program.declarations { - let decorators = match &decl.node { - Declaration::Model(m) => Some(&m.decorators), - Declaration::Class(c) => Some(&c.decorators), - Declaration::Enum(e) => Some(&e.decorators), - _ => None, - }; - let Some(decorators) = decorators else { - continue; - }; - for dec in decorators { - if decorators::from_str(dec.node.name.as_str()) != Some(DecoratorId::Derive) { - continue; - } - for arg in &dec.node.args { - let crate::frontend::ast::DecoratorArg::Positional(expr) = arg else { - continue; - }; - let crate::frontend::ast::Expr::Ident(name) = &expr.node else { - continue; - }; - let resolved = import_aliases - .get(name) - .cloned() - .unwrap_or_else(|| vec![name.to_string()]); - match resolved.as_slice() { - [std, serde, json] if std == "std" && serde == "serde" && json == "json" => { - has_serialize = true; - has_deserialize = true; - } - [std, serde, json, trait_name] - if std == "std" && serde == "serde" && json == "json" && trait_name == "Serialize" => - { - has_serialize = true; - } - [std, serde, json, trait_name] - if std == "std" && serde == "serde" && json == "json" && trait_name == "Deserialize" => - { - has_deserialize = true; - } - [serde, trait_name] if serde == "serde" && trait_name == "Serialize" => { - has_serialize = true; - } - [serde, trait_name] if serde == "serde" && trait_name == "Deserialize" => { - has_deserialize = true; - } - _ => {} - } - } - } - } - }; - - visit(main); - for (_, dep) in deps { - visit(dep); - } - - // Fallback: if no explicit serde derive was found but serde usage is detected (e.g. `json_stringify()` builtin), we - // conservatively enable Serialize only. - // Deserialize is NOT enabled here because implicit serde usage (like `json_stringify`) - // only needs serialization, not deserialization. - if !has_serialize && !has_deserialize { - let serde_used = super::scanners::detect_serde_usage(main) - || deps - .iter() - .any(|(_, program)| super::scanners::detect_serde_usage(program)); - if serde_used { - has_serialize = true; - } - } - - (has_serialize, has_deserialize) -} - -/// Add serde derives to generated newtypes when the current program needs serde support. -fn add_serde_to_newtypes(ir_program: &mut super::IrProgram, add_serialize: bool, add_deserialize: bool) { - use super::decl::IrDeclKind; - use super::types::IrType; - - /// Return whether a newtype inner type can safely receive derived serde support. - fn is_conservative_serde_safe_newtype_inner(ty: &IrType) -> bool { - match ty { - IrType::Unit - | IrType::Bool - | IrType::Int - | IrType::Float - | IrType::String - | IrType::Bytes - | IrType::StaticStr - | IrType::StaticBytes - | IrType::FrozenStr - | IrType::FrozenBytes - | IrType::StrRef => true, - IrType::List(inner) | IrType::Set(inner) | IrType::Option(inner) => { - is_conservative_serde_safe_newtype_inner(inner) - } - IrType::Dict(key, value) | IrType::Result(key, value) => { - is_conservative_serde_safe_newtype_inner(key) && is_conservative_serde_safe_newtype_inner(value) - } - IrType::Tuple(items) => items.iter().all(is_conservative_serde_safe_newtype_inner), - _ => false, - } - } - - for decl in &mut ir_program.declarations { - if let IrDeclKind::Struct(s) = &mut decl.kind - && s.fields.len() == 1 - && s.fields[0].name == "0" - { - if !s.type_params.is_empty() { - continue; - } - if !is_conservative_serde_safe_newtype_inner(&s.fields[0].ty) { - continue; - } - if add_serialize && !s.derives.iter().any(|d| d == SERDE_SERIALIZE_DERIVE) { - s.derives.push(SERDE_SERIALIZE_DERIVE.to_string()); - } - if add_deserialize && !s.derives.iter().any(|d| d == SERDE_DESERIALIZE_DERIVE) { - s.derives.push(SERDE_DESERIALIZE_DERIVE.to_string()); - } - } - } -} +use dependency_metadata::{ + collect_dependency_type_metadata, collect_externally_reachable_items_by_module, collect_model_field_aliases, + should_preserve_dependency_public_items, +}; +use ordinal_bridge::{OrdinalBridgeConfig, compilation_imports_std_ordinal_contract, imports_std_ordinal_contract}; +use serde_activation::{add_serde_to_newtypes, collect_serde_derives}; /// Error during Rust code generation. /// diff --git a/src/backend/ir/codegen/dependency_metadata.rs b/src/backend/ir/codegen/dependency_metadata.rs new file mode 100644 index 000000000..5ae5bbf2e --- /dev/null +++ b/src/backend/ir/codegen/dependency_metadata.rs @@ -0,0 +1,286 @@ +//! Dependency metadata planning for IR code generation. + +use std::collections::{HashMap, HashSet}; + +use crate::frontend::ast::{self, Declaration, Expr, ImportKind, ImportPath, Program}; +use crate::frontend::decorator_resolution; +use crate::frontend::module::canonicalize_source_module_segments; +use crate::frontend::typechecker::stdlib_loader::StdlibAstCache; +use incan_core::lang::stdlib; +use incan_core::lang::traits::{self as core_traits, TraitId}; + +pub(super) fn collect_model_field_aliases( + main: &Program, + deps: &[(&str, &Program)], +) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + + let mut visit = |p: &Program| { + for decl in &p.declarations { + let Declaration::Model(m) = &decl.node else { + continue; + }; + + let mut map: HashMap = HashMap::new(); + for f in &m.fields { + if let Some(alias) = &f.node.metadata.alias { + map.insert(alias.clone(), f.node.name.clone()); + } + } + + if !map.is_empty() { + out.entry(m.name.clone()).or_default().extend(map); + } + } + }; + + visit(main); + for (_, dep) in deps { + visit(dep); + } + + out +} + +/// Resolve a source import path to the generated Rust module path used for dependency emission. +fn generated_module_path_for_source_import(path: &ImportPath, current_module_path: &[String]) -> Option> { + let resolved_segments = if path.parent_levels > 0 { + let keep = current_module_path.len().checked_sub(path.parent_levels)?; + let mut resolved = current_module_path[..keep].to_vec(); + resolved.extend(path.segments.clone()); + resolved + } else { + path.segments.clone() + }; + let mut segments = canonicalize_source_module_segments(&resolved_segments); + + if segments.first().map(String::as_str) == Some(stdlib::STDLIB_ROOT) { + segments[0] = stdlib::INCAN_STD_NAMESPACE.to_string(); + } + + Some(segments) +} + +/// True when a dependency module should keep its public API even if the main module does not import every item. +pub(super) fn should_preserve_dependency_public_items( + module_path: &[String], + preserve_non_stdlib_public_items: bool, +) -> bool { + if matches!( + module_path.first().map(String::as_str), + Some(stdlib::STDLIB_ROOT | stdlib::INCAN_STD_NAMESPACE) + ) { + return true; + } + preserve_non_stdlib_public_items +} + +/// Return whether a function carries the stdlib-backed web route decorator that lowers to a Rust proc-macro attribute. +fn has_web_route_passthrough_decorator( + func: &ast::FunctionDecl, + aliases: &HashMap>, + stdlib_cache: &mut StdlibAstCache, +) -> bool { + func.decorators.iter().any(|decorator| { + let resolved = decorator_resolution::resolve_decorator_path(&decorator.node, aliases); + if resolved.len() < 2 { + return false; + } + let module_segments = &resolved[..resolved.len() - 1]; + let name = &resolved[resolved.len() - 1]; + if name != "route" { + return false; + } + let Some(meta) = stdlib_cache.lookup_function_meta(module_segments, name) else { + return false; + }; + meta.is_rust_extern && meta.rust_module_path.as_deref() == Some("incan_web_macros") + }) +} + +/// Collect dependency-module declarations that are referenced through imports. +pub(super) fn collect_externally_reachable_items_by_module( + main: &Program, + dependency_modules: &[(&str, &Program, Option>)], +) -> HashMap, HashSet> { + let module_paths: HashSet> = dependency_modules + .iter() + .map(|(name, _, path_segments)| path_segments.clone().unwrap_or_else(|| vec![(*name).to_string()])) + .collect(); + + fn record_imports( + reachable: &mut HashMap, HashSet>, + program: &Program, + current_module_path: &[String], + module_paths: &HashSet>, + ) { + if crate::frontend::surface_semantics::uses_ambient_log_surface(program) { + reachable + .entry(vec!["std".to_string(), "logging".to_string()]) + .or_default() + .insert("get_logger".to_string()); + } + let mut module_import_bindings: HashMap> = HashMap::new(); + for decl in &program.declarations { + let Declaration::Import(import) = &decl.node else { + continue; + }; + match &import.kind { + ImportKind::From { module, items } => { + let Some(module_path) = generated_module_path_for_source_import(module, current_module_path) else { + continue; + }; + let reachable_items = reachable.entry(module_path).or_default(); + for item in items { + reachable_items.insert(item.name.clone()); + } + } + ImportKind::Module(path) => { + let Some(segments) = generated_module_path_for_source_import(path, current_module_path) else { + continue; + }; + if module_paths.contains(&segments) { + if let Some(binding) = import.alias.clone().or_else(|| path.segments.last().cloned()) { + module_import_bindings.insert(binding, segments); + } + continue; + } + let Some(item_name) = segments.last() else { + continue; + }; + for module_path in module_paths { + if segments.len() == module_path.len() + 1 && segments.starts_with(module_path) { + reachable + .entry(module_path.clone()) + .or_default() + .insert(item_name.clone()); + break; + } + } + } + ImportKind::PubLibrary { .. } + | ImportKind::PubFrom { .. } + | ImportKind::RustCrate { .. } + | ImportKind::RustFrom { .. } + | ImportKind::Python(_) => {} + } + } + if !module_import_bindings.is_empty() { + let _ = crate::frontend::ast_walk::any_expr_in_program(program, |expr| { + if let Expr::Field(object, field) = expr + && let Expr::Ident(binding) = &object.node + && let Some(module_path) = module_import_bindings.get(binding) + { + reachable.entry(module_path.clone()).or_default().insert(field.clone()); + } + if let Expr::MethodCall(object, method, _, _) = expr + && let Expr::Ident(binding) = &object.node + && let Some(module_path) = module_import_bindings.get(binding) + { + reachable.entry(module_path.clone()).or_default().insert(method.clone()); + } + false + }); + } + if module_paths.contains(current_module_path) { + let aliases = decorator_resolution::collect_import_aliases(program); + let mut stdlib_cache = StdlibAstCache::new(); + for decl in &program.declarations { + let Declaration::Function(func) = &decl.node else { + continue; + }; + if has_web_route_passthrough_decorator(func, &aliases, &mut stdlib_cache) { + reachable + .entry(current_module_path.to_vec()) + .or_default() + .insert(func.name.clone()); + } + } + } + } + + let mut reachable = HashMap::new(); + record_imports(&mut reachable, main, &[String::from("main")], &module_paths); + for (name, program, path_segments) in dependency_modules { + let module_path = path_segments.clone().unwrap_or_else(|| vec![(*name).to_string()]); + record_imports(&mut reachable, program, &module_path, &module_paths); + } + reachable +} + +/// Dependency type facts gathered during codegen setup and reused by module emission. +#[derive(Debug, Clone, Default)] +pub(super) struct DependencyTypeMetadata { + pub(super) module_paths: HashMap>, + pub(super) ambiguous_type_names: HashSet, + pub(super) enum_type_names: HashSet, + pub(super) error_trait_type_names: HashSet, +} + +/// Collect dependency type metadata needed by IR emission for cross-module nominal types. +pub(super) fn collect_dependency_type_metadata( + deps: &[(&str, &Program, Option>)], +) -> DependencyTypeMetadata { + let mut paths: HashMap> = HashMap::new(); + let mut ambiguous: HashSet = HashSet::new(); + let mut enum_type_names: HashSet = HashSet::new(); + let mut non_enum_type_names: HashSet = HashSet::new(); + let mut error_trait_type_names: HashSet = HashSet::new(); + let error_trait_name = core_traits::as_str(TraitId::Error); + + for (_name, program, path_segments) in deps { + for decl in &program.declarations { + let type_name = match &decl.node { + Declaration::Model(m) => { + if m.traits.iter().any(|bound| bound.node.name == error_trait_name) { + error_trait_type_names.insert(m.name.clone()); + } + Some((&m.name, false)) + } + Declaration::Class(c) => { + if c.traits.iter().any(|bound| bound.node.name == error_trait_name) { + error_trait_type_names.insert(c.name.clone()); + } + Some((&c.name, false)) + } + Declaration::Enum(e) => Some((&e.name, true)), + Declaration::TypeAlias(a) => Some((&a.name, false)), + Declaration::Newtype(n) => Some((&n.name, false)), + _ => None, + }; + let Some((name, is_enum)) = type_name else { + continue; + }; + + if is_enum { + enum_type_names.insert(name.clone()); + } else { + non_enum_type_names.insert(name.clone()); + } + + let Some(segs) = path_segments.as_ref() else { + continue; + }; + + if let Some(existing) = paths.get(name) { + if existing != segs { + ambiguous.insert(name.clone()); + } + } else { + paths.insert(name.clone(), segs.clone()); + } + } + } + + for name in &ambiguous { + paths.remove(name); + } + enum_type_names.retain(|name| !ambiguous.contains(name) && !non_enum_type_names.contains(name)); + + DependencyTypeMetadata { + module_paths: paths, + ambiguous_type_names: ambiguous, + enum_type_names, + error_trait_type_names, + } +} diff --git a/src/backend/ir/codegen/ordinal_bridge.rs b/src/backend/ir/codegen/ordinal_bridge.rs new file mode 100644 index 000000000..5512cfff0 --- /dev/null +++ b/src/backend/ir/codegen/ordinal_bridge.rs @@ -0,0 +1,284 @@ +//! OrdinalKey bridge planning for generated IR emission. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::frontend::ast::{Declaration, ImportKind, ImportPath, Program}; +use crate::frontend::library_manifest_index::{LibraryManifestIndex, LibraryManifestIndexEntry}; +use crate::library_manifest::{EnumValueExport, EnumValueTypeExport}; +use incan_core::lang::trait_capabilities; + +use crate::backend::ir::decl::{IrEnumValue, IrEnumValueType}; +use crate::backend::ir::emit::{ExternalOrdinalCustomKey, ExternalOrdinalValueEnum}; + +/// Return whether a program imports the stdlib ordinal-map contract. +pub(super) fn imports_std_ordinal_contract(program: &Program) -> bool { + let capability = trait_capabilities::stable_ordinal_key(); + program.declarations.iter().any(|decl| { + let Declaration::Import(import) = &decl.node else { + return false; + }; + match &import.kind { + ImportKind::Module(_) => false, + ImportKind::From { module, items } if import_path_matches_capability(module, capability) => items + .iter() + .any(|item| trait_capabilities::import_triggers_capability(capability, item.name.as_str())), + _ => false, + } + }) +} + +/// Return whether an import path names the module that owns a temporary capability contract. +fn import_path_matches_capability(path: &ImportPath, capability: &trait_capabilities::TraitCapabilityInfo) -> bool { + trait_capabilities::module_path_matches(capability, &path.segments) +} + +/// Return whether any module in the current compilation needs value-enum `OrdinalKey` impls. +pub(super) fn compilation_imports_std_ordinal_contract( + main: &Program, + deps: &[(&str, &Program, Option>)], +) -> bool { + imports_std_ordinal_contract(main) || deps.iter().any(|(_, program, _)| imports_std_ordinal_contract(program)) +} + +/// Collect public scalar value enums from loaded `.incnlib` dependencies. +fn external_ordinal_value_enums(index: Option<&Arc>) -> Vec { + let Some(index) = index else { + return Vec::new(); + }; + let mut out = Vec::new(); + for dependency_key in index.known_libraries() { + let Some(LibraryManifestIndexEntry::Loaded { manifest, metadata }) = index.get(&dependency_key) else { + continue; + }; + for enum_export in &manifest.exports.enums { + let Some(value_type) = enum_export.value_type else { + continue; + }; + let value_type = match value_type { + EnumValueTypeExport::Str => IrEnumValueType::String, + EnumValueTypeExport::Int => IrEnumValueType::Int, + }; + let mut values = Vec::new(); + let mut complete = true; + for variant in &enum_export.variants { + let Some(value) = &variant.value else { + complete = false; + break; + }; + values.push(match value { + EnumValueExport::Str(value) => IrEnumValue::String(value.clone()), + EnumValueExport::Int(value) => IrEnumValue::Int(*value), + }); + } + if !complete { + continue; + } + out.push(ExternalOrdinalValueEnum { + dependency_key: dependency_key.clone(), + name: enum_export.name.clone(), + type_identity: enum_export + .ordinal_type_identity + .clone() + .unwrap_or_else(|| format!("{}.{}", metadata.manifest_name, enum_export.name)), + value_type, + values, + }); + } + } + out +} + +/// Return whether a serialized trait bound names the std `OrdinalKey` capability. +fn type_bound_matches_ordinal_key(bound: &crate::library_manifest::TypeBoundExport) -> bool { + let capability = trait_capabilities::stable_ordinal_key(); + let trait_name = bound + .source_name + .as_deref() + .unwrap_or_else(|| bound.name.rsplit('.').next().unwrap_or(bound.name.as_str())); + if trait_name != capability.trait_name { + return false; + } + let Some(module_path) = &bound.module_path else { + return false; + }; + trait_capabilities::module_path_matches(capability, module_path) +} + +/// Return whether any exported trait adoption satisfies the std `OrdinalKey` contract. +fn export_adopts_ordinal_key( + trait_adoptions: &[crate::library_manifest::TypeBoundExport], + traits: &HashMap, +) -> bool { + trait_adoptions + .iter() + .any(|bound| type_bound_matches_ordinal_key(bound) || trait_bound_extends_ordinal_key(bound, traits)) +} + +/// Return whether a serialized trait bound resolves transitively to std `OrdinalKey`. +fn trait_bound_extends_ordinal_key( + bound: &crate::library_manifest::TypeBoundExport, + traits: &HashMap, +) -> bool { + let mut seen = HashSet::new(); + let mut work = vec![bound.name.as_str()]; + while let Some(name) = work.pop() { + if !seen.insert(name.to_string()) { + continue; + } + let Some(trait_export) = traits.get(name) else { + continue; + }; + for supertrait in &trait_export.supertraits { + if type_bound_matches_ordinal_key(supertrait) { + return true; + } + work.push(supertrait.name.as_str()); + } + } + false +} + +/// Return lookup keys for a manifest trait export, including its original source name when reexported under an alias. +fn trait_export_lookup_keys(trait_export: &crate::library_manifest::TraitExport) -> Vec { + let mut keys = vec![trait_export.name.clone()]; + if let Some(source_name) = &trait_export.source_name + && source_name != &trait_export.name + { + keys.push(source_name.clone()); + } + keys +} + +/// Return whether a manifest method set exposes a source method or its generated alias. +fn export_methods_include(methods: &[crate::library_manifest::MethodExport], name: &str) -> bool { + methods + .iter() + .any(|method| method.name == name || method.alias_of.as_deref() == Some(name)) +} + +/// Build custom-key bridge metadata for one exported concrete type when it adopts `OrdinalKey`. +fn external_ordinal_custom_key( + dependency_key: &str, + name: &str, + type_params: &[crate::library_manifest::TypeParamExport], + trait_adoptions: &[crate::library_manifest::TypeBoundExport], + methods: &[crate::library_manifest::MethodExport], + traits: &HashMap, +) -> Option { + if !type_params.is_empty() || !export_adopts_ordinal_key(trait_adoptions, traits) { + return None; + } + let hooks = trait_capabilities::stable_ordinal_key().bridge_hooks?; + Some(ExternalOrdinalCustomKey { + dependency_key: dependency_key.to_string(), + name: name.to_string(), + has_ordinal_hash: export_methods_include(methods, hooks.hash_method), + has_ordinal_bytes_equal: export_methods_include(methods, hooks.bytes_equal_method), + }) +} + +/// Collect public user-authored `OrdinalKey` adopters from loaded `.incnlib` dependencies. +fn external_ordinal_custom_keys(index: Option<&Arc>) -> Vec { + let Some(index) = index else { + return Vec::new(); + }; + let mut out = Vec::new(); + for dependency_key in index.known_libraries() { + let Some(LibraryManifestIndexEntry::Loaded { manifest, .. }) = index.get(&dependency_key) else { + continue; + }; + let traits = manifest + .exports + .traits + .iter() + .flat_map(|trait_export| { + trait_export_lookup_keys(trait_export) + .into_iter() + .map(move |key| (key, trait_export)) + }) + .collect::>(); + for model in &manifest.exports.models { + if let Some(key) = external_ordinal_custom_key( + &dependency_key, + &model.name, + &model.type_params, + &model.trait_adoptions, + &model.methods, + &traits, + ) { + out.push(key); + } + } + for class in &manifest.exports.classes { + if let Some(key) = external_ordinal_custom_key( + &dependency_key, + &class.name, + &class.type_params, + &class.trait_adoptions, + &class.methods, + &traits, + ) { + out.push(key); + } + } + for newtype in &manifest.exports.newtypes { + if let Some(key) = external_ordinal_custom_key( + &dependency_key, + &newtype.name, + &newtype.type_params, + &newtype.trait_adoptions, + &newtype.methods, + &traits, + ) { + out.push(key); + } + } + for enum_export in &manifest.exports.enums { + if enum_export.value_type.is_some() { + continue; + } + if let Some(key) = external_ordinal_custom_key( + &dependency_key, + &enum_export.name, + &enum_export.type_params, + &enum_export.trait_adoptions, + &enum_export.methods, + &traits, + ) { + out.push(key); + } + } + } + out +} + +#[derive(Debug, Clone)] +pub(super) struct OrdinalBridgeConfig { + pub(super) emit_std_ordinal_value_enum_impls: bool, + pub(super) external_value_enums: Vec, + pub(super) external_custom_keys: Vec, +} + +impl OrdinalBridgeConfig { + /// Build a bridge configuration for generated internal modules. + pub(super) fn for_internal_module(uses_std_ordinal_contract: bool) -> Self { + Self { + emit_std_ordinal_value_enum_impls: uses_std_ordinal_contract, + external_value_enums: Vec::new(), + external_custom_keys: Vec::new(), + } + } + + /// Build a bridge configuration for crate-root emission where dependency adapters live. + pub(super) fn for_crate_root(uses_std_ordinal_contract: bool, index: Option<&Arc>) -> Self { + if !uses_std_ordinal_contract { + return Self::for_internal_module(false); + } + Self { + emit_std_ordinal_value_enum_impls: true, + external_value_enums: external_ordinal_value_enums(index), + external_custom_keys: external_ordinal_custom_keys(index), + } + } +} diff --git a/src/backend/ir/codegen/serde_activation.rs b/src/backend/ir/codegen/serde_activation.rs new file mode 100644 index 000000000..04ae5c47f --- /dev/null +++ b/src/backend/ir/codegen/serde_activation.rs @@ -0,0 +1,139 @@ +//! Serde derive and JSON activation planning for IR code generation. + +use crate::frontend::ast::{Declaration, Program}; +use crate::frontend::decorator_resolution; +use incan_core::lang::decorators::{self, DecoratorId}; +use incan_core::lang::stdlib; + +const SERDE_SERIALIZE_DERIVE: &str = "serde::Serialize"; +const SERDE_DESERIALIZE_DERIVE: &str = "serde::Deserialize"; + +/// Return whether any loaded module derives serde serialize or deserialize through resolved JSON derive imports. +pub(super) fn collect_serde_derives(main: &Program, deps: &[(&str, &Program)]) -> (bool, bool) { + let mut has_serialize = false; + let mut has_deserialize = false; + + let mut visit = |program: &Program| { + let import_aliases = decorator_resolution::collect_import_aliases(program); + for decl in &program.declarations { + let decorators = match &decl.node { + Declaration::Model(m) => Some(&m.decorators), + Declaration::Class(c) => Some(&c.decorators), + Declaration::Enum(e) => Some(&e.decorators), + _ => None, + }; + let Some(decorators) = decorators else { + continue; + }; + for dec in decorators { + if decorators::from_str(dec.node.name.as_str()) != Some(DecoratorId::Derive) { + continue; + } + for arg in &dec.node.args { + let crate::frontend::ast::DecoratorArg::Positional(expr) = arg else { + continue; + }; + let crate::frontend::ast::Expr::Ident(name) = &expr.node else { + continue; + }; + let resolved = import_aliases + .get(name) + .cloned() + .unwrap_or_else(|| vec![name.to_string()]); + match stdlib::stdlib_json_trait_id_from_path(&resolved) { + Some(stdlib::StdlibJsonTraitId::Serialize) => { + has_serialize = true; + } + Some(stdlib::StdlibJsonTraitId::Deserialize) => { + has_deserialize = true; + } + None if stdlib::is_stdlib_json_trait_module_path(&resolved) => { + has_serialize = true; + has_deserialize = true; + } + None => match resolved.as_slice() { + [serde, trait_name] if serde == "serde" && trait_name == "Serialize" => { + has_serialize = true; + } + [serde, trait_name] if serde == "serde" && trait_name == "Deserialize" => { + has_deserialize = true; + } + _ => {} + }, + } + } + } + } + }; + + visit(main); + for (_, dep) in deps { + visit(dep); + } + + if !has_serialize && !has_deserialize { + let serde_used = crate::backend::ir::scanners::detect_serde_usage(main) + || deps + .iter() + .any(|(_, program)| crate::backend::ir::scanners::detect_serde_usage(program)); + if serde_used { + has_serialize = true; + } + } + + (has_serialize, has_deserialize) +} + +/// Add serde derives to generated newtypes when the current program needs serde support. +pub(super) fn add_serde_to_newtypes( + ir_program: &mut crate::backend::ir::IrProgram, + add_serialize: bool, + add_deserialize: bool, +) { + use crate::backend::ir::decl::IrDeclKind; + use crate::backend::ir::types::IrType; + + fn is_conservative_serde_safe_newtype_inner(ty: &IrType) -> bool { + match ty { + IrType::Unit + | IrType::Bool + | IrType::Int + | IrType::Float + | IrType::String + | IrType::Bytes + | IrType::StaticStr + | IrType::StaticBytes + | IrType::FrozenStr + | IrType::FrozenBytes + | IrType::StrRef => true, + IrType::List(inner) | IrType::Set(inner) | IrType::Option(inner) => { + is_conservative_serde_safe_newtype_inner(inner) + } + IrType::Dict(key, value) | IrType::Result(key, value) => { + is_conservative_serde_safe_newtype_inner(key) && is_conservative_serde_safe_newtype_inner(value) + } + IrType::Tuple(items) => items.iter().all(is_conservative_serde_safe_newtype_inner), + _ => false, + } + } + + for decl in &mut ir_program.declarations { + if let IrDeclKind::Struct(s) = &mut decl.kind + && s.fields.len() == 1 + && s.fields[0].name == "0" + { + if !s.type_params.is_empty() { + continue; + } + if !is_conservative_serde_safe_newtype_inner(&s.fields[0].ty) { + continue; + } + if add_serialize && !s.derives.iter().any(|d| d == SERDE_SERIALIZE_DERIVE) { + s.derives.push(SERDE_SERIALIZE_DERIVE.to_string()); + } + if add_deserialize && !s.derives.iter().any(|d| d == SERDE_DESERIALIZE_DERIVE) { + s.derives.push(SERDE_DESERIALIZE_DERIVE.to_string()); + } + } + } +} diff --git a/src/backend/ir/conversions.rs b/src/backend/ir/conversions.rs index 368a092c1..c86ad6c71 100644 --- a/src/backend/ir/conversions.rs +++ b/src/backend/ir/conversions.rs @@ -171,6 +171,7 @@ use super::decl::FunctionParam; use super::expr::{BinOp, VarAccess}; +use super::reference_shape::expr_has_rust_reference_shape; use super::types::Mutability; use super::{IrExpr, IrExprKind, IrType, TypedExpr}; use crate::numeric_adapters::{ir_type_to_numeric_ty, numeric_op_from_ir, pow_exponent_kind_from_ir}; @@ -793,14 +794,22 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: (IrExprKind::Field { .. }, Some(IrType::String)) if matches!(expr.ty, IrType::String) => { Conversion::Clone } - (IrExprKind::Var { .. }, _) if matches!(expr.ty, IrType::String) => Conversion::Borrow, - (IrExprKind::Field { .. }, None) if matches!(expr.ty, IrType::String) => Conversion::Borrow, - (_, Some(IrType::Ref(_))) if !matches!(expr.ty, IrType::Ref(_) | IrType::RefMut(_)) => { - Conversion::Borrow + (IrExprKind::Var { .. }, _) if matches!(expr.ty, IrType::String) => { + if expr_has_rust_reference_shape(expr) { + Conversion::None + } else { + Conversion::Borrow + } } - (_, Some(IrType::RefMut(_))) if !matches!(expr.ty, IrType::Ref(_) | IrType::RefMut(_)) => { - Conversion::MutBorrow + (IrExprKind::Field { .. }, None) if matches!(expr.ty, IrType::String) => { + if expr_has_rust_reference_shape(expr) { + Conversion::None + } else { + Conversion::Borrow + } } + (_, Some(IrType::Ref(_))) if !expr_has_rust_reference_shape(expr) => Conversion::Borrow, + (_, Some(IrType::RefMut(_))) if !expr_has_rust_reference_shape(expr) => Conversion::MutBorrow, // Rust adapter leaves commonly accept borrowed handles (`&Sender`, `&Mutex`, ...). // When metadata is unavailable, do not move non-Copy wrapper fields out of `&self`. (IrExprKind::Field { .. }, None) @@ -925,11 +934,11 @@ pub(crate) fn determine_conversion_for_incan_call( ) { match target_ty { Some(IrType::Ref(_)) => match &expr.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Conversion::None, + _ if expr_has_rust_reference_shape(expr) => return Conversion::None, _ => return Conversion::Borrow, }, Some(IrType::RefMut(_)) => match &expr.ty { - IrType::Ref(_) | IrType::RefMut(_) => return Conversion::None, + _ if expr_has_rust_reference_shape(expr) => return Conversion::None, _ => return Conversion::MutBorrow, }, _ => {} @@ -949,7 +958,7 @@ pub(crate) fn determine_conversion_for_incan_call( mod tests { use super::*; use crate::backend::ir::decl::FunctionParam; - use crate::backend::ir::expr::{VarAccess, VarRefKind}; + use crate::backend::ir::expr::{MethodCallArgPolicy, VarAccess, VarRefKind}; use crate::backend::ir::types::Mutability; // === IncanFunctionArg Tests === @@ -1345,6 +1354,44 @@ mod tests { assert_eq!(conv, Conversion::Borrow); } + #[test] + fn test_external_function_as_slice_arg_does_not_double_borrow() { + let expr = IrExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(IrExpr::new( + IrExprKind::Var { + name: "data".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Bytes, + )), + method: "as_slice".to_string(), + dispatch: None, + type_args: Vec::new(), + args: Vec::new(), + callable_signature: None, + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Bytes, + ); + + let conv = determine_conversion(&expr, None, ConversionContext::ExternalFunctionArg); + assert_eq!( + conv, + Conversion::None, + "an explicit as_slice() argument is already a Rust borrow boundary" + ); + + let target = IrType::Ref(Box::new(IrType::Bytes)); + let conv = determine_conversion(&expr, Some(&target), ConversionContext::ExternalFunctionArg); + assert_eq!( + conv, + Conversion::None, + "an explicit as_slice() argument must not become &&[u8] for ref targets" + ); + } + #[test] fn test_external_function_string_var_with_by_value_target_does_not_borrow() { let expr = IrExpr::new( diff --git a/src/backend/ir/emit/decls/impls.rs b/src/backend/ir/emit/decls/impls.rs index 63c34f745..08a262abe 100644 --- a/src/backend/ir/emit/decls/impls.rs +++ b/src/backend/ir/emit/decls/impls.rs @@ -176,7 +176,7 @@ impl<'a> IrEmitter<'a> { }) .map(|m| self.emit_trait_method(m)) .collect::>()?; - if Self::is_serde_serialize_trait_name(trait_name) + if incan_core::lang::stdlib::is_stdlib_json_serialize_trait_name(trait_name) && !impl_block.methods.iter().any(|method| method.name == "to_json") { trait_methods.push(quote! { @@ -185,7 +185,7 @@ impl<'a> IrEmitter<'a> { } }); } - if Self::is_serde_deserialize_trait_name(trait_name) + if incan_core::lang::stdlib::is_stdlib_json_deserialize_trait_name(trait_name) && !impl_block.methods.iter().any(|method| method.name == "from_json") { trait_methods.push(quote! { @@ -250,22 +250,6 @@ impl<'a> IrEmitter<'a> { }) } - /// Return whether a trait impl target names the stdlib JSON serialization trait or an imported alias of it. - fn is_serde_serialize_trait_name(trait_name: &str) -> bool { - matches!( - trait_name, - "Serialize" | "JsonSerialize" | "json.Serialize" | "std.serde.json.Serialize" - ) - } - - /// Return whether a trait impl target names the stdlib JSON deserialization trait or an imported alias of it. - fn is_serde_deserialize_trait_name(trait_name: &str) -> bool { - matches!( - trait_name, - "Deserialize" | "JsonDeserialize" | "json.Deserialize" | "std.serde.json.Deserialize" - ) - } - /// Return the final path segment of a trait name. fn trait_short_name(trait_name: &str) -> &str { trait_name diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index df1878e8d..8d7781030 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -2,6 +2,8 @@ //! //! This module handles emission of regular function calls (user-defined functions) and binary operator expressions. +mod testing_asserts; + use proc_macro2::TokenStream; use quote::quote; @@ -793,308 +795,6 @@ impl<'a> IrEmitter<'a> { Ok(quote! { #f #turbofish (#(#arg_tokens),*) }) } - /// Emit canonical RFC 018 assertion helper calls without requiring a source-level `std.testing` import. - /// - /// Plain `assert` is a language primitive, so its lowered helper calls must remain available even when the - /// explicit stdlib testing module was not imported into the user's source file. - fn try_emit_testing_assert_call( - &self, - canonical_path: Option<&[String]>, - args: &[IrCallArg], - ) -> Result, EmitError> { - let Some(path) = canonical_path else { - return Ok(None); - }; - if path.len() != 3 - || path.first().map(String::as_str) != Some(stdlib::STDLIB_ROOT) - || path.get(1).map(String::as_str) != Some("testing") - { - return Ok(None); - } - let Some(name) = path.last().map(String::as_str) else { - return Ok(None); - }; - - match name { - "assert" => { - let condition = Self::canonical_assert_arg(name, args, 0)?; - let condition_tokens = self.emit_expr(condition)?; - let failure = self.emit_assert_failure("AssertionError", args.get(1).map(|arg| &arg.expr))?; - Ok(Some(quote! { - if !(#condition_tokens) { - #failure - } - })) - } - "assert_false" => { - let condition = Self::canonical_assert_arg(name, args, 0)?; - let condition_tokens = self.emit_expr(condition)?; - let failure = self.emit_assert_failure("AssertionError", args.get(1).map(|arg| &arg.expr))?; - Ok(Some(quote! { - if #condition_tokens { - #failure - } - })) - } - "assert_eq" | "assert_ne" => self.emit_assert_comparison(name, args).map(Some), - "assert_is_some" => self.emit_assert_option_some(args).map(Some), - "assert_is_none" => self.emit_assert_option_none(args).map(Some), - "assert_is_ok" => self.emit_assert_result_ok(args).map(Some), - "assert_is_err" => self.emit_assert_result_err(args).map(Some), - "assert_raises" => self.emit_assert_raises(args).map(Some), - _ => Ok(None), - } - } - - fn canonical_assert_arg<'b>( - helper_name: &str, - args: &'b [IrCallArg], - index: usize, - ) -> Result<&'b TypedExpr, EmitError> { - args.get(index).map(|arg| &arg.expr).ok_or_else(|| { - EmitError::Unsupported(format!( - "canonical std.testing.{helper_name} call missing argument {}", - index + 1 - )) - }) - } - - fn result_constructor_payload(expr: &TypedExpr, constructor: ConstructorId) -> Option<&TypedExpr> { - let expr = match &expr.kind { - IrExprKind::InteropCoerce { expr, .. } => expr.as_ref(), - _ => expr, - }; - if let IrExprKind::Struct { name, fields } = &expr.kind - && name == constructors::as_str(constructor) - { - return fields.first().map(|(_, payload)| payload); - } - let IrExprKind::Call { func, args, .. } = &expr.kind else { - return None; - }; - let IrExprKind::Var { name, .. } = &func.kind else { - return None; - }; - if name != constructors::as_str(constructor) { - return None; - } - args.first().map(|arg| &arg.expr) - } - - fn emit_assert_failure( - &self, - default_message: &'static str, - message: Option<&TypedExpr>, - ) -> Result { - if let Some(message) = message { - let message_tokens = self.emit_expr(message)?; - return Ok(quote! {{ - let __incan_assert_msg = #message_tokens; - if __incan_assert_msg.is_empty() { - panic!(#default_message); - } else { - panic!("AssertionError: {}", __incan_assert_msg); - } - }}); - } - Ok(quote! { panic!(#default_message); }) - } - - fn emit_assert_raises_failure( - &self, - default_message: TokenStream, - message: Option<&TypedExpr>, - ) -> Result { - if let Some(message) = message { - let message_tokens = self.emit_expr(message)?; - return Ok(quote! {{ - let __incan_assert_msg = #message_tokens; - if __incan_assert_msg.is_empty() { - #default_message - } else { - panic!("AssertionError: {}", __incan_assert_msg); - } - }}); - } - Ok(default_message) - } - - fn emit_assert_comparison_failure( - &self, - failure_kind: &'static str, - message: Option<&TypedExpr>, - ) -> Result { - let default_message = format!("AssertionError: {failure_kind}"); - if let Some(message) = message { - let message_tokens = self.emit_expr(message)?; - return Ok(quote! {{ - let __incan_assert_msg = #message_tokens; - if __incan_assert_msg.is_empty() { - panic!(#default_message); - } else { - panic!("AssertionError: {}; {}", __incan_assert_msg, #failure_kind); - } - }}); - } - Ok(quote! { panic!(#default_message); }) - } - - /// Emit canonical `std.testing.assert_eq` / `assert_ne` calls with expression operands isolated. - fn emit_assert_comparison(&self, name: &str, args: &[IrCallArg]) -> Result { - let left = Self::canonical_assert_arg(name, args, 0)?; - let right = Self::canonical_assert_arg(name, args, 1)?; - let left_tokens = self.emit_expr(left)?; - let right_tokens = self.emit_expr(right)?; - let message = args.get(2).map(|arg| &arg.expr); - if name == "assert_eq" { - let failure = self.emit_assert_comparison_failure("left != right", message)?; - Ok(quote! { - if (#left_tokens) != (#right_tokens) { - #failure - } - }) - } else { - let failure = self.emit_assert_comparison_failure("left == right", message)?; - Ok(quote! { - if (#left_tokens) == (#right_tokens) { - #failure - } - }) - } - } - - fn emit_assert_option_some(&self, args: &[IrCallArg]) -> Result { - let option = Self::canonical_assert_arg("assert_is_some", args, 0)?; - let option_tokens = self.emit_expr(option)?; - let failure = self.emit_assert_failure( - "AssertionError: expected Some, got None", - args.get(1).map(|arg| &arg.expr), - )?; - Ok(quote! {{ - let __incan_assert_value = #option_tokens; - match __incan_assert_value { - Some(__incan_assert_inner) => __incan_assert_inner, - None => { - #failure - } - } - }}) - } - - fn emit_assert_option_none(&self, args: &[IrCallArg]) -> Result { - let option = Self::canonical_assert_arg("assert_is_none", args, 0)?; - if matches!(option.kind, IrExprKind::None) { - return Ok(quote! { () }); - } - let option_tokens = self.emit_expr(option)?; - let failure = self.emit_assert_failure( - "AssertionError: expected None, got Some", - args.get(1).map(|arg| &arg.expr), - )?; - Ok(quote! {{ - let __incan_assert_value = #option_tokens; - if __incan_assert_value.is_some() { - #failure - } - }}) - } - - fn emit_assert_result_ok(&self, args: &[IrCallArg]) -> Result { - let result = Self::canonical_assert_arg("assert_is_ok", args, 0)?; - if let Some(payload) = Self::result_constructor_payload(result, ConstructorId::Ok) { - let payload_tokens = Self::emit_result_payload_tokens(payload, self.emit_expr(payload)?); - return Ok(quote! { #payload_tokens }); - } - let result_tokens = self.emit_expr(result)?; - let failure = - self.emit_assert_failure("AssertionError: expected Ok, got Err", args.get(1).map(|arg| &arg.expr))?; - Ok(quote! {{ - let __incan_assert_value = #result_tokens; - match __incan_assert_value { - Ok(__incan_assert_inner) => __incan_assert_inner, - Err(_) => { - #failure - } - } - }}) - } - - fn emit_assert_result_err(&self, args: &[IrCallArg]) -> Result { - let result = Self::canonical_assert_arg("assert_is_err", args, 0)?; - if let Some(payload) = Self::result_constructor_payload(result, ConstructorId::Err) { - let payload_tokens = Self::emit_result_payload_tokens(payload, self.emit_expr(payload)?); - return Ok(quote! { #payload_tokens }); - } - let result_tokens = self.emit_expr(result)?; - let failure = - self.emit_assert_failure("AssertionError: expected Err, got Ok", args.get(1).map(|arg| &arg.expr))?; - Ok(quote! {{ - let __incan_assert_value = #result_tokens; - match __incan_assert_value { - Err(__incan_assert_inner) => __incan_assert_inner, - Ok(_) => { - #failure - } - } - }}) - } - - fn emit_assert_raises(&self, args: &[IrCallArg]) -> Result { - let call = Self::canonical_assert_arg("assert_raises", args, 0)?; - let expected = Self::canonical_assert_arg("assert_raises", args, 1)?; - let call_tokens = self.emit_expr(call)?; - let invocation_tokens = if matches!( - &call.ty, - IrType::Function { params, ret } if params.is_empty() && matches!(ret.as_ref(), IrType::Unit) - ) { - quote! { #call_tokens() } - } else { - quote! { #call_tokens } - }; - let expected_tokens = self.emit_expr(expected)?; - let no_raise = self.emit_assert_raises_failure( - quote! { panic!("AssertionError: expected {} to be raised", __incan_expected_error); }, - args.get(2).map(|arg| &arg.expr), - )?; - let wrong_error = self.emit_assert_raises_failure( - quote! { - panic!( - "AssertionError: expected {} to be raised, got {}", - __incan_expected_error, - __incan_panic_message - ); - }, - args.get(2).map(|arg| &arg.expr), - )?; - - Ok(quote! {{ - let __incan_expected_error = #expected_tokens; - let __incan_raises_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - #invocation_tokens; - })); - match __incan_raises_result { - Ok(_) => { - #no_raise - } - Err(__incan_payload) => { - let __incan_panic_message = if let Some(message) = __incan_payload.downcast_ref::() { - message.as_str() - } else if let Some(message) = __incan_payload.downcast_ref::<&str>() { - *message - } else { - "" - }; - let __incan_expected_prefix = format!("{}:", __incan_expected_error); - if __incan_panic_message != __incan_expected_error - && !__incan_panic_message.starts_with(&__incan_expected_prefix) - { - #wrong_error - } - } - } - }}) - } - pub(in super::super) fn emit_rest_aware_call_args( &self, func: &TypedExpr, diff --git a/src/backend/ir/emit/expressions/calls/testing_asserts.rs b/src/backend/ir/emit/expressions/calls/testing_asserts.rs new file mode 100644 index 000000000..0202d30f7 --- /dev/null +++ b/src/backend/ir/emit/expressions/calls/testing_asserts.rs @@ -0,0 +1,335 @@ +use proc_macro2::TokenStream; +use quote::quote; + +use crate::backend::ir::emit::{EmitError, IrEmitter}; +use crate::backend::ir::expr::{IrCallArg, IrExprKind, TypedExpr}; +use crate::backend::ir::types::IrType; +use incan_core::lang::surface::constructors::{self, ConstructorId}; +use incan_core::lang::testing::{self, TestingAssertHelperId}; + +impl<'a> IrEmitter<'a> { + /// Emit canonical RFC 018 assertion helper calls without requiring a source-level `std.testing` import. + /// + /// Plain `assert` is a language primitive, so its lowered helper calls must remain available even when the explicit + /// stdlib testing module was not imported into the user's source file. + pub(super) fn try_emit_testing_assert_call( + &self, + canonical_path: Option<&[String]>, + args: &[IrCallArg], + ) -> Result, EmitError> { + let Some(path) = canonical_path else { + return Ok(None); + }; + let Some(helper_id) = testing::assert_helper_id_from_std_path(path) else { + return Ok(None); + }; + + match helper_id { + TestingAssertHelperId::Assert => { + let condition = Self::canonical_assert_arg(helper_id, args, 0)?; + let condition_tokens = self.emit_expr(condition)?; + let failure = self.emit_assert_failure( + Self::assert_failure_message(helper_id)?, + args.get(1).map(|arg| &arg.expr), + )?; + Ok(Some(quote! { + if !(#condition_tokens) { + #failure + } + })) + } + TestingAssertHelperId::AssertFalse => { + let condition = Self::canonical_assert_arg(helper_id, args, 0)?; + let condition_tokens = self.emit_expr(condition)?; + let failure = self.emit_assert_failure( + Self::assert_failure_message(helper_id)?, + args.get(1).map(|arg| &arg.expr), + )?; + Ok(Some(quote! { + if #condition_tokens { + #failure + } + })) + } + TestingAssertHelperId::AssertEq | TestingAssertHelperId::AssertNe => { + self.emit_assert_comparison(helper_id, args).map(Some) + } + TestingAssertHelperId::AssertIsSome => self.emit_assert_option_some(args).map(Some), + TestingAssertHelperId::AssertIsNone => self.emit_assert_option_none(args).map(Some), + TestingAssertHelperId::AssertIsOk => self.emit_assert_result_ok(args).map(Some), + TestingAssertHelperId::AssertIsErr => self.emit_assert_result_err(args).map(Some), + TestingAssertHelperId::AssertRaises => self.emit_assert_raises(args).map(Some), + } + } + + fn canonical_assert_arg( + helper_id: TestingAssertHelperId, + args: &[IrCallArg], + index: usize, + ) -> Result<&TypedExpr, EmitError> { + let helper_name = testing::assert_helper_as_str(helper_id); + args.get(index).map(|arg| &arg.expr).ok_or_else(|| { + EmitError::Unsupported(format!( + "canonical std.testing.{helper_name} call missing argument {}", + index + 1 + )) + }) + } + + fn assert_failure_message(helper_id: TestingAssertHelperId) -> Result<&'static str, EmitError> { + testing::assert_helper_default_failure_message(helper_id).ok_or_else(|| { + EmitError::Unsupported(format!( + "std.testing.{} does not have a fixed assertion failure message", + testing::assert_helper_as_str(helper_id) + )) + }) + } + + fn result_constructor_payload(expr: &TypedExpr, constructor: ConstructorId) -> Option<&TypedExpr> { + let expr = match &expr.kind { + IrExprKind::InteropCoerce { expr, .. } => expr.as_ref(), + _ => expr, + }; + if let IrExprKind::Struct { name, fields } = &expr.kind + && name == constructors::as_str(constructor) + { + return fields.first().map(|(_, payload)| payload); + } + let IrExprKind::Call { func, args, .. } = &expr.kind else { + return None; + }; + let IrExprKind::Var { name, .. } = &func.kind else { + return None; + }; + if name != constructors::as_str(constructor) { + return None; + } + args.first().map(|arg| &arg.expr) + } + + fn emit_assert_failure( + &self, + default_message: &'static str, + message: Option<&TypedExpr>, + ) -> Result { + if let Some(message) = message { + let message_tokens = self.emit_expr(message)?; + return Ok(quote! {{ + let __incan_assert_msg = #message_tokens; + if __incan_assert_msg.is_empty() { + panic!(#default_message); + } else { + panic!("AssertionError: {}", __incan_assert_msg); + } + }}); + } + Ok(quote! { panic!(#default_message); }) + } + + fn emit_assert_raises_failure( + &self, + default_message: TokenStream, + message: Option<&TypedExpr>, + ) -> Result { + if let Some(message) = message { + let message_tokens = self.emit_expr(message)?; + return Ok(quote! {{ + let __incan_assert_msg = #message_tokens; + if __incan_assert_msg.is_empty() { + #default_message + } else { + panic!("AssertionError: {}", __incan_assert_msg); + } + }}); + } + Ok(default_message) + } + + fn emit_assert_comparison_failure( + &self, + failure_kind: &'static str, + message: Option<&TypedExpr>, + ) -> Result { + let default_message = format!("AssertionError: {failure_kind}"); + if let Some(message) = message { + let message_tokens = self.emit_expr(message)?; + return Ok(quote! {{ + let __incan_assert_msg = #message_tokens; + if __incan_assert_msg.is_empty() { + panic!(#default_message); + } else { + panic!("AssertionError: {}; {}", __incan_assert_msg, #failure_kind); + } + }}); + } + Ok(quote! { panic!(#default_message); }) + } + + /// Emit canonical `std.testing.assert_eq` / `assert_ne` calls with expression operands isolated. + fn emit_assert_comparison( + &self, + helper_id: TestingAssertHelperId, + args: &[IrCallArg], + ) -> Result { + let name = testing::assert_helper_as_str(helper_id); + let left = Self::canonical_assert_arg(helper_id, args, 0)?; + let right = Self::canonical_assert_arg(helper_id, args, 1)?; + let left_tokens = self.emit_expr(left)?; + let right_tokens = self.emit_expr(right)?; + let message = args.get(2).map(|arg| &arg.expr); + let failure_kind = testing::assert_comparison_failure_kind(helper_id).ok_or_else(|| { + EmitError::Unsupported(format!("std.testing.{name} is not a comparison assertion helper")) + })?; + if helper_id == TestingAssertHelperId::AssertEq { + let failure = self.emit_assert_comparison_failure(failure_kind, message)?; + Ok(quote! { + if (#left_tokens) != (#right_tokens) { + #failure + } + }) + } else { + let failure = self.emit_assert_comparison_failure(failure_kind, message)?; + Ok(quote! { + if (#left_tokens) == (#right_tokens) { + #failure + } + }) + } + } + + fn emit_assert_option_some(&self, args: &[IrCallArg]) -> Result { + let option = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsSome, args, 0)?; + let option_tokens = self.emit_expr(option)?; + let failure = self.emit_assert_failure( + Self::assert_failure_message(TestingAssertHelperId::AssertIsSome)?, + args.get(1).map(|arg| &arg.expr), + )?; + Ok(quote! {{ + let __incan_assert_value = #option_tokens; + match __incan_assert_value { + Some(__incan_assert_inner) => __incan_assert_inner, + None => { + #failure + } + } + }}) + } + + fn emit_assert_option_none(&self, args: &[IrCallArg]) -> Result { + let option = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsNone, args, 0)?; + if matches!(option.kind, IrExprKind::None) { + return Ok(quote! { () }); + } + let option_tokens = self.emit_expr(option)?; + let failure = self.emit_assert_failure( + Self::assert_failure_message(TestingAssertHelperId::AssertIsNone)?, + args.get(1).map(|arg| &arg.expr), + )?; + Ok(quote! {{ + let __incan_assert_value = #option_tokens; + if __incan_assert_value.is_some() { + #failure + } + }}) + } + + fn emit_assert_result_ok(&self, args: &[IrCallArg]) -> Result { + let result = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsOk, args, 0)?; + if let Some(payload) = Self::result_constructor_payload(result, ConstructorId::Ok) { + let payload_tokens = Self::emit_result_payload_tokens(payload, self.emit_expr(payload)?); + return Ok(quote! { #payload_tokens }); + } + let result_tokens = self.emit_expr(result)?; + let failure = self.emit_assert_failure( + Self::assert_failure_message(TestingAssertHelperId::AssertIsOk)?, + args.get(1).map(|arg| &arg.expr), + )?; + Ok(quote! {{ + let __incan_assert_value = #result_tokens; + match __incan_assert_value { + Ok(__incan_assert_inner) => __incan_assert_inner, + Err(_) => { + #failure + } + } + }}) + } + + fn emit_assert_result_err(&self, args: &[IrCallArg]) -> Result { + let result = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsErr, args, 0)?; + if let Some(payload) = Self::result_constructor_payload(result, ConstructorId::Err) { + let payload_tokens = Self::emit_result_payload_tokens(payload, self.emit_expr(payload)?); + return Ok(quote! { #payload_tokens }); + } + let result_tokens = self.emit_expr(result)?; + let failure = self.emit_assert_failure( + Self::assert_failure_message(TestingAssertHelperId::AssertIsErr)?, + args.get(1).map(|arg| &arg.expr), + )?; + Ok(quote! {{ + let __incan_assert_value = #result_tokens; + match __incan_assert_value { + Err(__incan_assert_inner) => __incan_assert_inner, + Ok(_) => { + #failure + } + } + }}) + } + + fn emit_assert_raises(&self, args: &[IrCallArg]) -> Result { + let call = Self::canonical_assert_arg(TestingAssertHelperId::AssertRaises, args, 0)?; + let expected = Self::canonical_assert_arg(TestingAssertHelperId::AssertRaises, args, 1)?; + let call_tokens = self.emit_expr(call)?; + let invocation_tokens = if matches!( + &call.ty, + IrType::Function { params, ret } if params.is_empty() && matches!(ret.as_ref(), IrType::Unit) + ) { + quote! { #call_tokens() } + } else { + quote! { #call_tokens } + }; + let expected_tokens = self.emit_expr(expected)?; + let no_raise = self.emit_assert_raises_failure( + quote! { panic!("AssertionError: expected {} to be raised", __incan_expected_error); }, + args.get(2).map(|arg| &arg.expr), + )?; + let wrong_error = self.emit_assert_raises_failure( + quote! { + panic!( + "AssertionError: expected {} to be raised, got {}", + __incan_expected_error, + __incan_panic_message + ); + }, + args.get(2).map(|arg| &arg.expr), + )?; + + Ok(quote! {{ + let __incan_expected_error = #expected_tokens; + let __incan_raises_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + #invocation_tokens; + })); + match __incan_raises_result { + Ok(_) => { + #no_raise + } + Err(__incan_payload) => { + let __incan_panic_message = if let Some(message) = __incan_payload.downcast_ref::() { + message.as_str() + } else if let Some(message) = __incan_payload.downcast_ref::<&str>() { + *message + } else { + "" + }; + let __incan_expected_prefix = format!("{}:", __incan_expected_error); + if __incan_panic_message != __incan_expected_error + && !__incan_panic_message.starts_with(&__incan_expected_prefix) + { + #wrong_error + } + } + } + }}) + } +} diff --git a/src/backend/ir/emit/expressions/interop_coercions.rs b/src/backend/ir/emit/expressions/interop_coercions.rs new file mode 100644 index 000000000..e89e3c5f9 --- /dev/null +++ b/src/backend/ir/emit/expressions/interop_coercions.rs @@ -0,0 +1,156 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::backend::ir::expr::{IrExprKind, Literal as IrLiteral, TypedExpr}; +use crate::backend::ir::types::IrType; + +/// Emit a typechecker-selected Rust borrow coercion without re-planning ownership at the call site. +pub(super) fn emit_builtin_borrow_coercion( + inner_expr: &TypedExpr, + inner_tokens: TokenStream, + target_ty: &IrType, +) -> TokenStream { + if let Some(emitted) = emit_structural_borrow_coercion(inner_tokens.clone(), target_ty) { + return emitted; + } + match target_ty { + IrType::StrRef => match &inner_expr.ty { + IrType::StaticStr | IrType::StrRef | IrType::FrozenStr | IrType::Ref(_) | IrType::RefMut(_) => { + quote! { #inner_tokens } + } + _ => quote! { &#inner_tokens }, + }, + IrType::Ref(inner) if matches!(inner.as_ref(), IrType::Bytes) => match &inner_expr.ty { + IrType::StaticBytes | IrType::FrozenBytes | IrType::Ref(_) | IrType::RefMut(_) => { + quote! { #inner_tokens } + } + _ => quote! { &#inner_tokens }, + }, + IrType::Ref(inner) | IrType::RefMut(inner) if is_owned_rust_string_target(inner) => { + if expr_already_materializes_owned_string(inner_expr) { + quote! { &#inner_tokens } + } else { + quote! { &(#inner_tokens).to_string() } + } + } + IrType::Ref(inner) | IrType::RefMut(inner) if is_owned_rust_bytes_target(inner) => { + if expr_already_materializes_owned_bytes(inner_expr) { + quote! { &#inner_tokens } + } else { + quote! { &(#inner_tokens).to_vec() } + } + } + IrType::Ref(_) | IrType::RefMut(_) => quote! { &#inner_tokens }, + _ => quote! { #inner_tokens }, + } +} + +/// Return whether an expression already emits an owned Rust `String` value. +fn expr_already_materializes_owned_string(expr: &TypedExpr) -> bool { + matches!(expr.ty, IrType::String) + && !matches!( + expr.kind, + IrExprKind::String(_) | IrExprKind::Literal(IrLiteral::StaticStr(_)) | IrExprKind::StaticRead { .. } + ) +} + +/// Return whether an expression already emits an owned Rust `Vec` value. +fn expr_already_materializes_owned_bytes(expr: &TypedExpr) -> bool { + matches!(expr.ty, IrType::Bytes) && !matches!(expr.kind, IrExprKind::Bytes(_) | IrExprKind::StaticRead { .. }) +} + +/// Return whether a Rust boundary target is an owned Rust string value. +fn is_owned_rust_string_target(ty: &IrType) -> bool { + matches!(ty, IrType::String) + || matches!( + ty, + IrType::Struct(name) if matches!( + name.as_str(), + "String" | "std::string::String" | "alloc::string::String" + ) + ) +} + +/// Return whether a Rust boundary target is an owned Rust byte vector. +fn is_owned_rust_bytes_target(ty: &IrType) -> bool { + matches!(ty, IrType::Bytes) + || matches!( + ty, + IrType::Struct(name) if matches!( + name.as_str(), + "Vec" | "std::vec::Vec" | "alloc::vec::Vec" + ) + ) +} + +/// Emit a projection from a referenced source item into a Rust-boundary target item. +/// +/// Structural borrow coercions iterate source containers so the element expression is usually `&T`. Exact scalar leaves +/// can be copied or cloned from that reference, while borrowed leaves project to the Rust borrow shape the frontend +/// recorded from metadata. +fn emit_structural_borrow_projection(source_tokens: TokenStream, target_ty: &IrType) -> TokenStream { + match target_ty { + IrType::StrRef => quote! { #source_tokens.as_str() }, + IrType::Ref(inner) if matches!(inner.as_ref(), IrType::Bytes) => { + quote! { #source_tokens.as_slice() } + } + IrType::Ref(_) | IrType::RefMut(_) => quote! { #source_tokens }, + IrType::List(inner) => { + let item_ident = format_ident!("__incan_item"); + let item_tokens = emit_structural_borrow_projection(quote! { #item_ident }, inner); + quote! { #source_tokens.iter().map(|#item_ident| #item_tokens).collect::>() } + } + IrType::Set(inner) => { + let item_ident = format_ident!("__incan_item"); + let item_tokens = emit_structural_borrow_projection(quote! { #item_ident }, inner); + quote! { + #source_tokens + .iter() + .map(|#item_ident| #item_tokens) + .collect::>() + } + } + IrType::Dict(key_ty, value_ty) => { + let key_ident = format_ident!("__incan_key"); + let value_ident = format_ident!("__incan_value"); + let key_tokens = emit_structural_borrow_projection(quote! { #key_ident }, key_ty); + let value_tokens = emit_structural_borrow_projection(quote! { #value_ident }, value_ty); + quote! { + #source_tokens + .iter() + .map(|(#key_ident, #value_ident)| (#key_tokens, #value_tokens)) + .collect::>() + } + } + IrType::Option(inner) => { + let item_ident = format_ident!("__incan_item"); + let item_tokens = emit_structural_borrow_projection(quote! { #item_ident }, inner); + quote! { #source_tokens.as_ref().map(|#item_ident| #item_tokens) } + } + IrType::Result(ok_ty, err_ty) => { + let ok_ident = format_ident!("__incan_ok"); + let err_ident = format_ident!("__incan_err"); + let ok_tokens = emit_structural_borrow_projection(quote! { #ok_ident }, ok_ty); + let err_tokens = emit_structural_borrow_projection(quote! { #err_ident }, err_ty); + quote! { + #source_tokens + .as_ref() + .map(|#ok_ident| #ok_tokens) + .map_err(|#err_ident| #err_tokens) + } + } + IrType::Bool | IrType::Int | IrType::Float | IrType::Numeric(_) | IrType::Unit => { + quote! { *#source_tokens } + } + _ => quote! { (*#source_tokens).clone() }, + } +} + +fn emit_structural_borrow_coercion(inner_tokens: TokenStream, target_ty: &IrType) -> Option { + match target_ty { + IrType::List(_) | IrType::Set(_) | IrType::Dict(_, _) | IrType::Option(_) | IrType::Result(_, _) => { + Some(emit_structural_borrow_projection(inner_tokens, target_ty)) + } + _ => None, + } +} diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index d440cc87d..9937f6106 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -11,10 +11,16 @@ use super::super::super::expr::{ CollectionMethodKind, InternalMethodKind, IrCallArg, IrExprKind, IrMethodDispatch, MethodCallArgPolicy, MethodKind, TypedExpr, VarAccess, VarRefKind, }; -use super::super::super::ownership::{ArgumentPassingPlan, ValueUseSite}; +use super::super::super::ownership::{ + ArgumentPassingPlan, RegularMethodArgumentContext, ValueUseSite, regular_method_argument_use_site, +}; +use super::super::super::reference_shape::{expr_has_rust_reference_shape, type_has_rust_reference_shape}; use super::super::super::types::IrType; use super::super::{EmitError, IrEmitter}; -use incan_core::interop::RustCollectionFamily; +use incan_core::interop::{ + METADATA_FREE_METHOD_BORROW_RULES, MetadataFreeArgClass, MetadataFreeMethodArgBorrowPolicy, + MetadataFreeReceiverClass, RustCollectionFamily, +}; use incan_core::lang::surface::result_methods::{self, ResultMethodId}; mod collection_methods; @@ -205,6 +211,11 @@ impl<'a> IrEmitter<'a> { }) } } + ResultMethodId::Unwrap | ResultMethodId::UnwrapOr => { + return Err(EmitError::Unsupported(format!( + "Result.{method_name} is not a callback combinator" + ))); + } }; Ok(call) } @@ -227,15 +238,7 @@ impl<'a> IrEmitter<'a> { /// Return whether an argument already has Rust reference shape for a method parameter. fn method_arg_already_borrowed_for_ref_param(arg_ty: &IrType) -> bool { - matches!( - arg_ty, - IrType::Ref(_) | IrType::RefMut(_) | IrType::StrRef | IrType::StaticStr - ) - } - - /// Return whether an argument expression already has Rust reference shape in IR. - fn method_arg_already_has_reference_shape(arg: &TypedExpr) -> bool { - Self::method_arg_already_borrowed_for_ref_param(&arg.ty) + type_has_rust_reference_shape(arg_ty) } /// Emit method-call arguments with Rust-boundary borrowing and union wrapping applied from callable metadata. @@ -267,6 +270,7 @@ impl<'a> IrEmitter<'a> { let receiver_signature = self .method_signature_for_receiver(&receiver.ty, method) .or(specialized_signature.as_ref()); + let has_incan_receiver_signature = receiver_signature.is_some(); let callable_signature = match (callable_signature, receiver_signature) { (Some(call_sig), Some(method_sig)) if call_sig.params.iter().all(|param| param.default.is_none()) @@ -351,14 +355,27 @@ impl<'a> IrEmitter<'a> { } else { None }; - let arg_plan = ArgumentPassingPlan::for_use_site(arg, arg_use_site); let direct_mut_trait_receiver = external_method_shape && idx == 0 && Self::external_trait_first_arg_needs_mut_borrow(receiver, method); + let metadata_free_policy = if (external_method_shape || !has_incan_receiver_signature) + && idx == 0 + && !param.is_some_and(|param| Self::method_arg_already_borrowed_for_ref_param(¶m.ty)) + { + Self::metadata_free_method_arg_borrow_policy(receiver, method, &arg.ty) + } else { + None + }; + let effective_arg_use_site = if metadata_free_policy.is_some() { + ValueUseSite::MethodArg + } else { + arg_use_site + }; + let arg_plan = ArgumentPassingPlan::for_use_site(arg, effective_arg_use_site); let emitted = if direct_mut_trait_receiver { self.emit_expr(arg) } else { - self.emit_expr_for_use(arg, arg_use_site) + self.emit_expr_for_use(arg, effective_arg_use_site) }; if let Some(previous) = previous_qualify { self.qualify_internal_canonical_paths.replace(previous); @@ -391,17 +408,16 @@ impl<'a> IrEmitter<'a> { { return Ok(wrapped); } + if let Some(policy) = metadata_free_policy { + emitted = match policy { + MetadataFreeMethodArgBorrowPolicy::Shared if !expr_has_rust_reference_shape(arg) => { + quote! { &#emitted } + } + MetadataFreeMethodArgBorrowPolicy::Mutable => quote! { &mut #emitted }, + MetadataFreeMethodArgBorrowPolicy::Shared => emitted, + }; + } let Some(param) = param else { - if external_method_shape && idx == 0 && Self::method_arg_needs_fallback_mut_borrow(method, &arg.ty) - { - emitted = quote! { &mut #emitted }; - } else if external_method_shape - && idx == 0 - && Self::method_arg_needs_fallback_borrow(method, &arg.ty) - && !Self::method_arg_already_has_reference_shape(arg) - { - emitted = quote! { &#emitted }; - } return Ok(emitted); }; if let Some(wrapped) = self.emit_union_payload_arg(arg, ¶m.ty, None)? { @@ -412,15 +428,52 @@ impl<'a> IrEmitter<'a> { .collect() } - /// Return whether an external Rust method's first argument should be emitted as a mutable borrow. - fn method_arg_needs_fallback_mut_borrow(method: &str, arg_ty: &IrType) -> bool { - match method { - "read_to_string" => true, - "read" | "read_to_end" | "read_exact" | "read_buf" | "read_buf_exact" => Self::is_byte_buffer_type(arg_ty), - _ => false, + /// Return the explicitly registered compatibility borrow policy for a metadata-free external method argument. + /// + /// Signature metadata remains the source of truth for Rust-boundary borrowing. These policies are only for + /// default-build interop surfaces that v0.3 already emits without rust-inspect metadata. + fn metadata_free_method_arg_borrow_policy( + receiver: &TypedExpr, + method: &str, + arg_ty: &IrType, + ) -> Option { + METADATA_FREE_METHOD_BORROW_RULES.iter().find_map(|rule| { + if !rule.methods.contains(&method) { + return None; + } + if !Self::metadata_free_receiver_matches(receiver, rule.receiver) { + return None; + } + if !Self::metadata_free_arg_matches(arg_ty, rule.arg) { + return None; + } + Some(rule.policy) + }) + } + + fn metadata_free_receiver_matches(receiver: &TypedExpr, class: MetadataFreeReceiverClass) -> bool { + match class { + MetadataFreeReceiverClass::IoValue => Self::receiver_allows_io_method_fallback(receiver), + MetadataFreeReceiverClass::EncodingInstance => { + Self::receiver_type_matches_any(receiver, &["Encoding", "encoding_rs::Encoding"]) + } + MetadataFreeReceiverClass::ExternalAssociated => Self::is_external_associated_receiver(receiver), + } + } + + fn metadata_free_arg_matches(arg_ty: &IrType, class: MetadataFreeArgClass) -> bool { + match class { + MetadataFreeArgClass::StringBuffer => Self::is_string_buffer_type(arg_ty), + MetadataFreeArgClass::ByteBuffer => Self::is_byte_buffer_type(arg_ty), + MetadataFreeArgClass::Any => true, } } + /// Return whether a metadata-free receiver is eligible for std::io-style compatibility borrowing. + fn receiver_allows_io_method_fallback(receiver: &TypedExpr) -> bool { + !Self::expr_is_type_like(receiver) + } + /// Return whether an external Rust trait-style associated call needs `&mut` for its first argument. fn external_trait_first_arg_needs_mut_borrow(receiver: &TypedExpr, method: &str) -> bool { if !matches!(method, "update" | "finalize_xof_reset") { @@ -436,24 +489,55 @@ impl<'a> IrEmitter<'a> { ) } - /// Return whether an external Rust method's first argument should be emitted as a shared borrow. - fn method_arg_needs_fallback_borrow(method: &str, arg_ty: &IrType) -> bool { - match method { - "write_all" => true, - "for_label" | "decode" | "encode" => true, - "write" => Self::is_byte_buffer_type(arg_ty), - _ => false, - } + /// Return whether a metadata-free method receiver is an external Rust associated-call target. + fn is_external_associated_receiver(receiver: &TypedExpr) -> bool { + matches!( + &receiver.kind, + IrExprKind::Var { + ref_kind: VarRefKind::ExternalRustName, + .. + } + ) && Self::expr_is_type_like(receiver) + } + + /// Return whether the receiver's nominal type name matches one of the expected Rust compatibility surfaces. + fn receiver_type_matches_any(receiver: &TypedExpr, expected: &[&str]) -> bool { + Self::receiver_type_for_method_dispatch(&receiver.ty) + .nominal_type_name() + .is_some_and(|name| { + let short_name = name.rsplit("::").next().unwrap_or(name); + expected.iter().any(|expected_name| { + name == *expected_name || short_name == expected_name.rsplit("::").next().unwrap_or(expected_name) + }) + }) } /// Return whether an IR type can stand in for a mutable Rust byte buffer. fn is_byte_buffer_type(ty: &IrType) -> bool { matches!(ty, IrType::Bytes | IrType::FrozenBytes) + || matches!( + ty, + IrType::Struct(name) + if matches!(name.as_str(), "Vec" | "std::vec::Vec" | "alloc::vec::Vec") + ) || matches!( ty, IrType::NamedGeneric(name, args) - if matches!(name.as_str(), "Vec" | "std::vec::Vec") - && matches!(args.as_slice(), [IrType::Int]) + if matches!(name.as_str(), "Vec" | "std::vec::Vec" | "alloc::vec::Vec") + && matches!( + args.as_slice(), + [IrType::Int | IrType::Numeric(incan_core::lang::types::numerics::NumericTypeId::U8)] + ) + ) + } + + /// Return whether an IR type can stand in for a mutable Rust string buffer. + fn is_string_buffer_type(ty: &IrType) -> bool { + matches!(ty, IrType::String) + || matches!( + ty, + IrType::Struct(name) + if matches!(name.as_str(), "String" | "std::string::String" | "alloc::string::String") ) } @@ -635,6 +719,28 @@ impl<'a> IrEmitter<'a> { MethodKind::String(kind) => emit_string_method(self, &info, kind, &arg_exprs), MethodKind::Collection(kind) => emit_collection_method(self, receiver, &info, kind, &arg_exprs), MethodKind::Iterator(kind) => emit_iterator_method(self, receiver, &info, kind, &arg_exprs), + MethodKind::Result(ResultMethodId::Unwrap) => { + if !arg_exprs.is_empty() { + return Err(EmitError::Unsupported("Result.unwrap expects no arguments".to_string())); + } + let receiver_tokens = &info.r; + Ok(quote! { + match #receiver_tokens { + Ok(__incan_ok) => __incan_ok, + Err(_) => panic!("called Result.unwrap() on an Err value"), + } + }) + } + MethodKind::Result(ResultMethodId::UnwrapOr) => { + let Some(default) = arg_exprs.first() else { + return Err(EmitError::Unsupported( + "Result.unwrap_or expects one default argument".to_string(), + )); + }; + let default_tokens = self.emit_expr(default)?; + let receiver_tokens = &info.r; + Ok(quote! { #receiver_tokens.unwrap_or(#default_tokens) }) + } MethodKind::Result(kind) => { let Some(callback) = arg_exprs.first() else { return Err(EmitError::Unsupported(format!( @@ -890,30 +996,18 @@ impl<'a> IrEmitter<'a> { || rust_collection_family_for_ir_type(&receiver.ty) .is_some_and(|family| family.preserves_lookup_arg_shape(method)); let rusttype_alias_receiver = self.is_rusttype_alias_receiver(&receiver.ty); - let use_site = if receiver_ref_kind != Some(VarRefKind::ExternalRustName) - && (has_incan_method_signature - || (self.is_incan_owned_nominal_receiver(&receiver.ty) && !rusttype_alias_receiver)) - { - ValueUseSite::IncanCallArg { - target_ty: None, - callee_param: None, - in_return: false, - } - } else if receiver_ref_kind == Some(VarRefKind::ExternalName) { - // Module-qualified calls like `widgets.make_widget(...)` are function namespace lookups, not external Rust - // methods. They should keep ordinary Incan/public-function conversions instead of Rust interop coercions. - ValueUseSite::IncanCallArg { - target_ty: None, - callee_param: None, + let use_site = regular_method_argument_use_site( + RegularMethodArgumentContext { + arg_policy, + receiver_ref_kind, + has_incan_method_signature, + is_incan_owned_nominal_receiver: self.is_incan_owned_nominal_receiver(&receiver.ty), + is_rusttype_alias_receiver: rusttype_alias_receiver, + preserves_lookup_arg_shape: preserve_lookup_arg_shape, in_return, - } - } else if preserve_lookup_arg_shape { - // Borrow-sensitive collection lookups must keep the source argument shape instead of applying - // function-style coercions such as `.to_string()` / `.into()`. - ValueUseSite::MethodArg - } else { - ValueUseSite::ExternalCallArg { target_ty: None } - }; + }, + None, + ); let arg_tokens = self.emit_method_call_args(method, receiver, args, callable_signature, use_site, result_target_ty)?; Ok(quote! { #r.#m #method_turbofish (#(#arg_tokens),*) }) diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index b1b663086..7c1d3fb76 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -45,6 +45,7 @@ mod calls; mod comprehensions; mod format; mod indexing; +mod interop_coercions; mod lvalue; mod methods; mod structs_enums; @@ -251,57 +252,6 @@ impl<'a> IrEmitter<'a> { value_use_site_target_ty(site) } - /// Return whether an expression already emits an owned Rust `String` value. - fn expr_already_materializes_owned_string(expr: &TypedExpr) -> bool { - matches!(expr.ty, IrType::String) - && !matches!( - expr.kind, - IrExprKind::String(_) | IrExprKind::Literal(IrLiteral::StaticStr(_)) | IrExprKind::StaticRead { .. } - ) - } - - /// Return whether an expression already emits an owned Rust `Vec` value. - fn expr_already_materializes_owned_bytes(expr: &TypedExpr) -> bool { - matches!(expr.ty, IrType::Bytes) && !matches!(expr.kind, IrExprKind::Bytes(_) | IrExprKind::StaticRead { .. }) - } - - /// Emit a typechecker-selected Rust borrow coercion without re-planning ownership at the call site. - fn emit_builtin_borrow_coercion( - inner_expr: &TypedExpr, - inner_tokens: TokenStream, - rust_target: &str, - ) -> TokenStream { - match rust_target { - "&str" => match &inner_expr.ty { - IrType::StaticStr | IrType::StrRef | IrType::FrozenStr | IrType::Ref(_) | IrType::RefMut(_) => { - quote! { #inner_tokens } - } - _ => quote! { &#inner_tokens }, - }, - "&[u8]" => match &inner_expr.ty { - IrType::StaticBytes | IrType::FrozenBytes | IrType::Ref(_) | IrType::RefMut(_) => { - quote! { #inner_tokens } - } - _ => quote! { &#inner_tokens }, - }, - "&String" | "&std::string::String" | "&alloc::string::String" => { - if Self::expr_already_materializes_owned_string(inner_expr) { - quote! { &#inner_tokens } - } else { - quote! { &(#inner_tokens).to_string() } - } - } - "&Vec" | "&std::vec::Vec" | "&alloc::vec::Vec" => { - if Self::expr_already_materializes_owned_bytes(inner_expr) { - quote! { &#inner_tokens } - } else { - quote! { &(#inner_tokens).to_vec() } - } - } - _ => quote! { &#inner_tokens }, - } - } - /// Prefer the call-site target type for aggregate literal elements. /// /// Generic targets still matter for ownership conversion: a string literal passed into `list[K]` should materialize @@ -1060,25 +1010,21 @@ impl<'a> IrEmitter<'a> { IrExprKind::InteropCoerce { expr: inner, from_ty: _, - to_ty: _, + to_ty, kind, } => { let inner_tokens = self.emit_expr(inner)?; match kind { IrInteropCoercionKind::Builtin { policy, rust_target } => { - let rust_target = rust_target.replace(' ', ""); let emitted = match policy { - incan_core::interop::CoercionPolicy::Exact => match rust_target.as_str() { - "String" | "std::string::String" => { - quote! { (#inner_tokens).to_string() } - } - "Vec" | "std::vec::Vec" => { - quote! { (#inner_tokens).to_vec() } - } + incan_core::interop::CoercionPolicy::Exact => match to_ty { + IrType::String => quote! { (#inner_tokens).to_string() }, + IrType::Bytes => quote! { (#inner_tokens).to_vec() }, _ => quote! { #inner_tokens }, }, incan_core::interop::CoercionPolicy::Lossless => { - let target = syn::parse_str::(rust_target.as_str()).map_err(|err| { + let target = self.emit_type(to_ty); + let _: syn::Type = syn::parse2(target.clone()).map_err(|err| { EmitError::SynParse(format!( "invalid Rust boundary cast target `{rust_target}`: {err}" )) @@ -1086,7 +1032,7 @@ impl<'a> IrEmitter<'a> { quote! { (#inner_tokens) as #target } } incan_core::interop::CoercionPolicy::Borrow => { - Self::emit_builtin_borrow_coercion(inner, inner_tokens, rust_target.as_str()) + interop_coercions::emit_builtin_borrow_coercion(inner, inner_tokens, to_ty) } incan_core::interop::CoercionPolicy::Lossy => match rust_target.as_str() { "f32" => quote! { (#inner_tokens) as f32 }, @@ -1526,6 +1472,173 @@ mod tests { Ok(()) } + #[test] + fn encoding_decode_compatibility_policy_overrides_incomplete_by_value_signature() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let expr = TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "enc".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("encoding_rs::Encoding".to_string()), + )), + method: "decode".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: "data".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Bytes, + ), + }], + callable_signature: Some(FunctionSignature { + params: vec![FunctionParam { + name: "bytes".to_string(), + ty: IrType::Bytes, + mutability: Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }], + return_type: IrType::Unknown, + }), + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Unknown, + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("enc . decode (& data)"), + "encoding_rs decode should borrow bytes even when the recovered signature is incomplete, got `{rendered}`" + ); + Ok(()) + } + + #[test] + fn unregistered_decode_method_with_by_value_metadata_preserves_argument_shape() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let expr = TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "decoder".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("ExternalDecoder".to_string()), + )), + method: "decode".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: "data".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Bytes, + ), + }], + callable_signature: Some(FunctionSignature { + params: vec![FunctionParam { + name: "data".to_string(), + ty: IrType::Bytes, + mutability: Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }], + return_type: IrType::Unknown, + }), + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Unknown, + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("decoder . decode (data)"), + "explicit by-value metadata must preserve argument shape, got `{rendered}`" + ); + assert!( + !rendered.contains("decoder . decode (& data)") && !rendered.contains("decoder.decode(&data)"), + "explicit by-value metadata must not use the metadata-free byte borrow default, got `{rendered}`" + ); + Ok(()) + } + + #[test] + fn metadata_free_read_to_string_fallback_requires_string_buffer() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let expr = TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "reader".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("ExternalReader".to_string()), + )), + method: "read_to_string".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: "count".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Int, + ), + }], + callable_signature: None, + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Unknown, + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("reader . read_to_string (count)"), + "read_to_string fallback should preserve non-string argument shape, got `{rendered}`" + ); + assert!( + !rendered.contains("reader . read_to_string (& mut count)") + && !rendered.contains("reader.read_to_string(&mut count)"), + "read_to_string fallback must not mutably borrow non-string arguments, got `{rendered}`" + ); + Ok(()) + } + #[test] fn interop_try_adapter_emits_question_mark() -> Result<(), String> { let registry = FunctionRegistry::new(); @@ -1579,13 +1692,13 @@ mod tests { IrType::String, )), from_ty: IrType::String, - to_ty: IrType::Ref(Box::new(IrType::String)), + to_ty: IrType::Ref(Box::new(IrType::Struct("String".to_string()))), kind: IrInteropCoercionKind::Builtin { policy: incan_core::interop::CoercionPolicy::Borrow, - rust_target: "&String".to_string(), + rust_target: "&str".to_string(), }, }, - IrType::Ref(Box::new(IrType::String)), + IrType::Ref(Box::new(IrType::Struct("String".to_string()))), ); let emitted = emitter @@ -1618,13 +1731,13 @@ mod tests { IrType::String, )), from_ty: IrType::String, - to_ty: IrType::Ref(Box::new(IrType::String)), + to_ty: IrType::Ref(Box::new(IrType::Struct("String".to_string()))), kind: IrInteropCoercionKind::Builtin { policy: incan_core::interop::CoercionPolicy::Borrow, - rust_target: "&String".to_string(), + rust_target: "&str".to_string(), }, }, - IrType::Ref(Box::new(IrType::String)), + IrType::Ref(Box::new(IrType::Struct("String".to_string()))), ); let emitted = emitter @@ -1642,6 +1755,49 @@ mod tests { Ok(()) } + #[test] + fn interop_structural_list_borrow_coercion_projects_str_items() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let expr = TypedExpr::new( + IrExprKind::InteropCoerce { + expr: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "items".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::List(Box::new(IrType::String)), + )), + from_ty: IrType::List(Box::new(IrType::String)), + to_ty: IrType::List(Box::new(IrType::StrRef)), + kind: IrInteropCoercionKind::Builtin { + policy: incan_core::interop::CoercionPolicy::Borrow, + rust_target: "Vec<&str>".to_string(), + }, + }, + IrType::List(Box::new(IrType::StrRef)), + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains("items . iter ()"), + "expected structural borrow coercion to iterate source list, got `{rendered}`" + ); + assert!( + rendered.contains("as_str ()"), + "expected structural borrow coercion to project string items as &str, got `{rendered}`" + ); + assert!( + rendered.contains("collect :: < Vec < _ >> ()"), + "expected structural borrow coercion to collect a Rust Vec, got `{rendered}`" + ); + Ok(()) + } + #[test] fn interop_wrapped_dict_literal_keeps_call_site_value_target() -> Result<(), String> { let registry = FunctionRegistry::new(); @@ -1772,6 +1928,38 @@ mod tests { Ok(()) } + #[test] + fn interop_borrowed_vec_bytes_coercion_materializes_owned_bytes_before_borrow() -> Result<(), String> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let expr = TypedExpr::new( + IrExprKind::InteropCoerce { + expr: Box::new(TypedExpr::new(IrExprKind::Bytes(b"abc".to_vec()), IrType::StaticBytes)), + from_ty: IrType::StaticBytes, + to_ty: IrType::Ref(Box::new(IrType::Struct("Vec".to_string()))), + kind: IrInteropCoercionKind::Builtin { + policy: incan_core::interop::CoercionPolicy::Borrow, + rust_target: "&[u8]".to_string(), + }, + }, + IrType::Ref(Box::new(IrType::Struct("Vec".to_string()))), + ); + + let emitted = emitter + .emit_expr(&expr) + .map_err(|err| format!("expected successful expression emission, got {err:?}"))?; + let rendered = emitted.to_string(); + assert!( + rendered.contains(". to_vec ()"), + "expected borrowed Vec interop coercion to materialize owned bytes, got `{rendered}`" + ); + assert!( + rendered.starts_with("&"), + "expected borrowed Vec interop coercion to emit a borrow, got `{rendered}`" + ); + Ok(()) + } + #[test] fn non_string_method_call_join_stays_regular_method_call() -> Result<(), String> { let registry = FunctionRegistry::new(); diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 841a47320..1591d1bd1 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -96,6 +96,25 @@ pub(super) struct GeneratedUseAnalysis { pub(super) borrowed_function_adapters: HashSet<(String, Vec)>, } +impl GeneratedUseAnalysis { + /// Return whether generated Rust should retain an impl method under the current program-level preservation mode. + pub(super) fn should_retain_method( + &self, + preserve_public_items: bool, + target_type: &str, + method_name: &str, + visibility: &Visibility, + ) -> bool { + self.public_types.contains(target_type) + || (!preserve_public_items + && !matches!(visibility, Visibility::Private) + && self.reachable_items.contains(target_type)) + || self + .used_methods + .contains(&(target_type.to_string(), method_name.to_string())) + } +} + #[derive(Clone)] pub(super) struct StructConstructorMetadata { fields: Vec, @@ -628,14 +647,12 @@ impl<'a> IrEmitter<'a> { /// True when a method should be emitted for a preserved public surface or an observed generated-use call. pub(super) fn should_emit_method(&self, target_type: &str, method_name: &str, visibility: &Visibility) -> bool { - let analysis = self.generated_use_analysis.borrow(); - analysis.public_types.contains(target_type) - || (!self.preserve_public_items - && !matches!(visibility, Visibility::Private) - && analysis.reachable_items.contains(target_type)) - || analysis - .used_methods - .contains(&(target_type.to_string(), method_name.to_string())) + self.generated_use_analysis.borrow().should_retain_method( + self.preserve_public_items, + target_type, + method_name, + visibility, + ) } /// True when the generated free constructor function for a struct should be retained. diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index f1d16181a..b66b7917d 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -343,25 +343,23 @@ impl<'program> GeneratedUseAnalyzer<'program> { match magic_methods::from_str(method.name.as_str()) { Some(magic_methods::MagicMethodId::Eq | magic_methods::MagicMethodId::Str) => true, Some(magic_methods::MagicMethodId::ClassName | magic_methods::MagicMethodId::Fields) => { - self.method_is_needed(&impl_block.target_type, method) + self.analysis.should_retain_method( + self.preserve_public_items, + &impl_block.target_type, + &method.name, + &method.visibility, + ) } _ if impl_block.trait_name.is_some() => true, - _ => self.method_is_needed(&impl_block.target_type, method), + _ => self.analysis.should_retain_method( + self.preserve_public_items, + &impl_block.target_type, + &method.name, + &method.visibility, + ), } } - /// Mirror the emitter's method-retention predicate for generated-use analysis. - fn method_is_needed(&self, target_type: &str, method: &IrFunction) -> bool { - self.analysis.public_types.contains(target_type) - || (!self.preserve_public_items - && !matches!(method.visibility, Visibility::Private) - && self.analysis.reachable_items.contains(target_type)) - || self - .analysis - .used_methods - .contains(&(target_type.to_string(), method.name.clone())) - } - /// Scan a function signature, defaults, and body for generated Rust dependencies. fn scan_function(&mut self, func: &IrFunction) { let outer_variable_types = std::mem::take(&mut self.variable_types); @@ -916,16 +914,15 @@ impl<'program> GeneratedUseAnalyzer<'program> { method: &str, dispatch: Option<&IrMethodDispatch>, ) { - if let Some(IrMethodDispatch::RustExtensionTraitImport { binding }) = dispatch { - if self.rust_extension_trait_imports.contains_key(binding) { - self.analysis.used_extension_trait_imports.insert(binding.clone()); + let Some(IrMethodDispatch::RustExtensionTraitImport { binding }) = dispatch else { + if self.receiver_can_use_rust_extension_trait(receiver) { + self.mark_unambiguous_rust_extension_trait_import(method); } return; + }; + if self.rust_extension_trait_imports.contains_key(binding) { + self.analysis.used_extension_trait_imports.insert(binding.clone()); } - if !self.receiver_can_use_rust_extension_trait(receiver) { - return; - } - self.mark_unambiguous_rust_extension_trait_import(method); } /// Mark a trait import for metadata-free fallback only when the method has one possible imported trait. @@ -2273,16 +2270,20 @@ impl<'a> IrEmitter<'a> { matches!( &decl.kind, IrDeclKind::Impl(impl_block) - if impl_block.trait_name.as_deref() == Some("json.Serialize") - || impl_block.trait_name.as_deref() == Some("std.serde.json.Serialize") + if impl_block.trait_name + .as_deref() + .and_then(incan_core::lang::stdlib::stdlib_json_trait_scope_import_id) + == Some(incan_core::lang::stdlib::StdlibJsonTraitId::Serialize) ) }); let needs_json_deserialize_trait_scope = emitted_declarations.iter().any(|decl| { matches!( &decl.kind, IrDeclKind::Impl(impl_block) - if impl_block.trait_name.as_deref() == Some("json.Deserialize") - || impl_block.trait_name.as_deref() == Some("std.serde.json.Deserialize") + if impl_block.trait_name + .as_deref() + .and_then(incan_core::lang::stdlib::stdlib_json_trait_scope_import_id) + == Some(incan_core::lang::stdlib::StdlibJsonTraitId::Deserialize) ) }); match (needs_json_serialize_trait_scope, needs_json_deserialize_trait_scope) { diff --git a/src/backend/ir/expr.rs b/src/backend/ir/expr.rs index 13d21934f..48d172ad1 100644 --- a/src/backend/ir/expr.rs +++ b/src/backend/ir/expr.rs @@ -17,7 +17,9 @@ use super::decl::IrInteropAdapterKind; use super::{FunctionSignature, IrSpan, IrType, Ownership}; use incan_core::interop::CoercionPolicy; use incan_core::lang::builtins::{self as core_builtins, BuiltinFnId}; -use incan_core::lang::surface::{dict_methods, list_methods, result_methods, set_methods, string_methods}; +use incan_core::lang::surface::{ + dict_methods, iterator_methods, list_methods, result_methods, set_methods, string_methods, +}; use incan_core::lang::traits::{self as core_traits, TraitId}; use incan_core::lang::types::collections::{self as collection_types, CollectionTypeId}; @@ -788,7 +790,7 @@ impl MethodKind { iterator_method_kind(name).map(Self::Iterator) } - /// Try to resolve an RFC 070 result-combinator method name without considering a receiver type. + /// Try to resolve a Result method name without considering a receiver type. pub fn for_result_method_name(name: &str) -> Option { result_methods::from_str(name).map(Self::Result) } @@ -829,7 +831,7 @@ impl MethodKind { })) } IrType::List(_) => { - if name == "iter" { + if iterator_methods::from_str(name) == Some(iterator_methods::IteratorMethodId::Iter) { return Some(Self::Iterator(IteratorMethodKind::Iter)); } let id = list_methods::from_str(name)?; @@ -859,7 +861,7 @@ impl MethodKind { })) } IrType::Set(_) => { - if name == "iter" { + if iterator_methods::from_str(name) == Some(iterator_methods::IteratorMethodId::Iter) { return Some(Self::Iterator(IteratorMethodKind::Iter)); } if set_methods::from_str(name).is_some() { @@ -871,7 +873,7 @@ impl MethodKind { if matches!( collection_types::from_str(type_name), Some(CollectionTypeId::FrozenList | CollectionTypeId::FrozenSet) - ) && name == "iter" => + ) && iterator_methods::from_str(name) == Some(iterator_methods::IteratorMethodId::Iter) => { Some(Self::Iterator(IteratorMethodKind::Iter)) } @@ -895,28 +897,30 @@ fn is_iterator_protocol_type_name(name: &str) -> bool { /// Classify an RFC 088 iterator method name into the structured backend method family. fn iterator_method_kind(name: &str) -> Option { - Some(match name { - "map" => IteratorMethodKind::Map, - "filter" => IteratorMethodKind::Filter, - "enumerate" => IteratorMethodKind::Enumerate, - "zip" => IteratorMethodKind::Zip, - "take" => IteratorMethodKind::Take, - "skip" => IteratorMethodKind::Skip, - "take_while" => IteratorMethodKind::TakeWhile, - "skip_while" => IteratorMethodKind::SkipWhile, - "chain" => IteratorMethodKind::Chain, - "flat_map" => IteratorMethodKind::FlatMap, - "batch" => IteratorMethodKind::Batch, - "collect" => IteratorMethodKind::Collect, - "count" => IteratorMethodKind::Count, - "reduce" => IteratorMethodKind::Reduce, - "fold" => IteratorMethodKind::Fold, - "any" => IteratorMethodKind::Any, - "all" => IteratorMethodKind::All, - "find" => IteratorMethodKind::Find, - "for_each" => IteratorMethodKind::ForEach, - "sum" => IteratorMethodKind::Sum, - _ => return None, + let id = iterator_methods::from_str(name)?; + use iterator_methods::IteratorMethodId as M; + Some(match id { + M::Iter => IteratorMethodKind::Iter, + M::Map => IteratorMethodKind::Map, + M::Filter => IteratorMethodKind::Filter, + M::Enumerate => IteratorMethodKind::Enumerate, + M::Zip => IteratorMethodKind::Zip, + M::Take => IteratorMethodKind::Take, + M::Skip => IteratorMethodKind::Skip, + M::TakeWhile => IteratorMethodKind::TakeWhile, + M::SkipWhile => IteratorMethodKind::SkipWhile, + M::Chain => IteratorMethodKind::Chain, + M::FlatMap => IteratorMethodKind::FlatMap, + M::Batch => IteratorMethodKind::Batch, + M::Collect => IteratorMethodKind::Collect, + M::Count => IteratorMethodKind::Count, + M::Reduce => IteratorMethodKind::Reduce, + M::Fold => IteratorMethodKind::Fold, + M::Any => IteratorMethodKind::Any, + M::All => IteratorMethodKind::All, + M::Find => IteratorMethodKind::Find, + M::ForEach => IteratorMethodKind::ForEach, + M::Sum => IteratorMethodKind::Sum, }) } @@ -980,7 +984,7 @@ mod tests { } #[test] - fn result_method_kind_for_receiver_classifies_rfc070_surface() { + fn result_method_kind_for_receiver_classifies_result_surface() { let result_ty = IrType::Result(Box::new(IrType::Int), Box::new(IrType::String)); for (name, expected) in [ ("map", result_methods::ResultMethodId::Map), @@ -989,6 +993,8 @@ mod tests { ("or_else", result_methods::ResultMethodId::OrElse), ("inspect", result_methods::ResultMethodId::Inspect), ("inspect_err", result_methods::ResultMethodId::InspectErr), + ("unwrap", result_methods::ResultMethodId::Unwrap), + ("unwrap_or", result_methods::ResultMethodId::UnwrapOr), ] { assert_eq!( MethodKind::for_receiver(&result_ty, name), @@ -996,6 +1002,6 @@ mod tests { "expected Result method classification for `{name}`" ); } - assert_eq!(MethodKind::for_receiver(&result_ty, "unwrap"), None); + assert_eq!(MethodKind::for_receiver(&result_ty, "missing"), None); } } diff --git a/src/backend/ir/lower/decl/methods.rs b/src/backend/ir/lower/decl/methods.rs index 3f3544b8b..6e55c1e99 100644 --- a/src/backend/ir/lower/decl/methods.rs +++ b/src/backend/ir/lower/decl/methods.rs @@ -680,11 +680,16 @@ impl AstLowering { if let Some(trait_id) = core_traits::from_str(short_name) { return core_traits::method_names(trait_id); } - match short_name { - "Callable0" | "Callable1" | "Callable2" => &["__call__"], - "Serialize" | "JsonSerialize" => &["to_json"], - "Deserialize" | "JsonDeserialize" => &["from_json"], - _ => &[], + if matches!(short_name, "Callable0" | "Callable1" | "Callable2") { + &["__call__"] + } else { + match incan_core::lang::stdlib::stdlib_json_trait_id(trait_name) + .or_else(|| incan_core::lang::stdlib::stdlib_json_trait_id(short_name)) + { + Some(incan_core::lang::stdlib::StdlibJsonTraitId::Serialize) => &["to_json"], + Some(incan_core::lang::stdlib::StdlibJsonTraitId::Deserialize) => &["from_json"], + None => &[], + } } } @@ -698,13 +703,13 @@ impl AstLowering { .rsplit(['.', ':']) .find(|segment| !segment.is_empty()) .unwrap_or(trait_name); - matches!( - (short_name, method_name), - ("Serialize", "to_json") - | ("JsonSerialize", "to_json") - | ("Deserialize", "from_json") - | ("JsonDeserialize", "from_json") - ) + match incan_core::lang::stdlib::stdlib_json_trait_id(trait_name) + .or_else(|| incan_core::lang::stdlib::stdlib_json_trait_id(short_name)) + { + Some(incan_core::lang::stdlib::StdlibJsonTraitId::Serialize) => method_name == "to_json", + Some(incan_core::lang::stdlib::StdlibJsonTraitId::Deserialize) => method_name == "from_json", + None => false, + } } /// Return whether a method is safe to emit into an imported trait impl when the trait declaration is missing. diff --git a/src/backend/ir/lower/expr/calls.rs b/src/backend/ir/lower/expr/calls.rs index a79ca89c8..1dfbfd748 100644 --- a/src/backend/ir/lower/expr/calls.rs +++ b/src/backend/ir/lower/expr/calls.rs @@ -20,6 +20,8 @@ use incan_core::lang::stdlib; use incan_core::lang::stdlib::{STDLIB_BUILTINS, STDLIB_ROOT}; use incan_core::lang::surface::constructors::{self, ConstructorId}; use incan_core::lang::surface::types as surface_types; +use incan_core::lang::testing::{self, TestingAssertHelperId}; +use incan_core::lang::types::collections::{self, CollectionTypeId}; const TYPE_CONSTRUCTOR_HOOK: &str = "__incan_new"; @@ -1391,7 +1393,7 @@ impl AstLowering { let Some(coercion) = coercion else { return Ok(arg_expr); }; - let target_ty = self.lower_resolved_type(&coercion.target_type); + let target_ty = self.lower_rust_boundary_target_type(&coercion.target_type); let from_ty = arg_expr.ty.clone(); let kind = match coercion.kind { RustArgCoercionKind::Builtin(policy) => IrInteropCoercionKind::Builtin { @@ -1421,6 +1423,104 @@ impl AstLowering { )) } + /// Lower the typechecker-selected Rust boundary target without collapsing borrowed Rust slices into owned values. + /// + /// General source-level references lower as `Ref`, but Rust argument coercions use the target type as a backend + /// contract. A `&str` parameter therefore lowers to `StrRef`, while `&String` remains a reference to the owned Rust + /// string target recorded by the frontend. + fn lower_rust_boundary_target_type(&self, target_ty: &ResolvedType) -> IrType { + match target_ty { + ResolvedType::Ref(inner) if matches!(inner.as_ref(), ResolvedType::Str) => IrType::StrRef, + ResolvedType::Ref(inner) => IrType::Ref(Box::new(self.lower_rust_boundary_target_type(inner))), + ResolvedType::RefMut(inner) => IrType::RefMut(Box::new(self.lower_rust_boundary_target_type(inner))), + ResolvedType::Tuple(items) => IrType::Tuple( + items + .iter() + .map(|item| self.lower_rust_boundary_target_type(item)) + .collect(), + ), + ResolvedType::FrozenList(inner) => IrType::NamedGeneric( + collections::as_str(CollectionTypeId::FrozenList).to_string(), + vec![self.lower_rust_boundary_target_type(inner)], + ), + ResolvedType::FrozenSet(inner) => IrType::NamedGeneric( + collections::as_str(CollectionTypeId::FrozenSet).to_string(), + vec![self.lower_rust_boundary_target_type(inner)], + ), + ResolvedType::FrozenDict(key, value) => IrType::NamedGeneric( + collections::as_str(CollectionTypeId::FrozenDict).to_string(), + vec![ + self.lower_rust_boundary_target_type(key), + self.lower_rust_boundary_target_type(value), + ], + ), + ResolvedType::Generic(name, args) => match collections::from_str(name.as_str()) { + Some(CollectionTypeId::List) => IrType::List(Box::new( + args.first() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + )), + Some(CollectionTypeId::Dict) => IrType::Dict( + Box::new( + args.first() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + ), + Box::new( + args.get(1) + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + ), + ), + Some(CollectionTypeId::Set) => IrType::Set(Box::new( + args.first() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + )), + Some(CollectionTypeId::Option) => IrType::Option(Box::new( + args.first() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + )), + Some(CollectionTypeId::Result) => IrType::Result( + Box::new( + args.first() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + ), + Box::new( + args.get(1) + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .unwrap_or(IrType::Unknown), + ), + ), + Some(CollectionTypeId::Tuple) => IrType::Tuple( + args.iter() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .collect(), + ), + Some( + id @ (CollectionTypeId::FrozenList + | CollectionTypeId::FrozenSet + | CollectionTypeId::FrozenDict + | CollectionTypeId::Generator), + ) => IrType::NamedGeneric( + collections::as_str(id).to_string(), + args.iter() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .collect(), + ), + None => IrType::NamedGeneric( + name.clone(), + args.iter() + .map(|arg| self.lower_rust_boundary_target_type(arg)) + .collect(), + ), + }, + _ => self.lower_resolved_type(target_ty), + } + } + /// Lower a function/constructor call expression. /// /// Handles struct constructors, builtin functions, newtype checked construction, and regular function calls. @@ -1595,11 +1695,12 @@ impl AstLowering { let arg_span = Self::call_arg_expr(arg_ast).span; arg_ir.expr = self.wrap_with_rust_arg_coercion(arg_ir.expr.clone(), arg_span)?; } - if imported_callee_path.as_ref().is_some_and(|path| { - path.len() == 3 && path[0] == "std" && path[1] == "testing" && path[2] == "assert_raises" - }) && args_ir - .get(1) - .is_none_or(|arg| !matches!(arg.expr.kind, IrExprKind::Literal(IrLiteral::StaticStr(_)))) + if imported_callee_path + .as_ref() + .is_some_and(|path| testing::is_assert_helper_std_path(path, TestingAssertHelperId::AssertRaises)) + && args_ir + .get(1) + .is_none_or(|arg| !matches!(arg.expr.kind, IrExprKind::Literal(IrLiteral::StaticStr(_)))) { let Some(error_type) = type_args.first() else { return Err(LoweringError { @@ -2097,7 +2198,7 @@ mod tests { (arg_span.start, arg_span.end), RustArgCoercionInfo { rust_target_type: "&str".to_string(), - target_type: ResolvedType::Str, + target_type: ResolvedType::Ref(Box::new(ResolvedType::Str)), kind: RustArgCoercionKind::Builtin(CoercionPolicy::Borrow), }, ); @@ -2119,19 +2220,40 @@ mod tests { match lowered.kind { IrExprKind::MethodCall { args, .. } => { - assert!( - matches!( - args.first().map(|arg| &arg.expr.kind), - Some(IrExprKind::InteropCoerce { .. }) - ), - "expected first method arg to be wrapped in InteropCoerce, got {args:?}" - ); + let Some(first_arg) = args.first() else { + return Err("expected lowered method arg".to_string()); + }; + match &first_arg.expr.kind { + IrExprKind::InteropCoerce { to_ty, .. } => { + assert_eq!( + *to_ty, + IrType::StrRef, + "expected borrowed str target to lower to StrRef" + ); + } + other => { + return Err(format!( + "expected first method arg to be wrapped in InteropCoerce, got {other:?}" + )); + } + } } other => return Err(format!("expected MethodCall lowering, got {other:?}")), } Ok(()) } + #[test] + fn lower_rust_boundary_target_preserves_nested_borrowed_str_refs() { + let lowering = AstLowering::new(); + let target = ResolvedType::Generic("List".to_string(), vec![ResolvedType::Ref(Box::new(ResolvedType::Str))]); + + assert_eq!( + lowering.lower_rust_boundary_target_type(&target), + IrType::List(Box::new(IrType::StrRef)), + ); + } + #[test] fn lower_method_call_threads_arg_shape_hint_from_typechecker() -> Result<(), String> { let receiver_span = Span::new(0, 5); diff --git a/src/backend/ir/mod.rs b/src/backend/ir/mod.rs index 1e9af8614..0fa6d1f16 100644 --- a/src/backend/ir/mod.rs +++ b/src/backend/ir/mod.rs @@ -23,6 +23,7 @@ pub mod conversions; pub mod ownership; pub mod prelude; +pub(crate) mod reference_shape; pub mod codegen; pub mod decl; diff --git a/src/backend/ir/ownership.rs b/src/backend/ir/ownership.rs index 1866fc95f..3a279fba7 100644 --- a/src/backend/ir/ownership.rs +++ b/src/backend/ir/ownership.rs @@ -15,7 +15,7 @@ use super::conversions::{ incan_mutable_param_passed_as_rust_mut_ref, }; use super::decl::FunctionParam; -use super::expr::{IrExpr, IrExprKind, VarAccess}; +use super::expr::{IrExpr, IrExprKind, MethodCallArgPolicy, VarAccess, VarRefKind}; use super::types::IrType; /// A typed sink/source boundary that needs an ownership/coercion decision. @@ -71,6 +71,49 @@ pub enum ValueUseSite<'a> { MethodArg, } +/// Receiver and lookup facts needed to choose the value-use site for one ordinary method-call argument. +/// +/// This keeps clone-bound inference and method emission on the same method-argument boundary decision instead of +/// letting each phase classify receiver ownership independently. +#[derive(Debug, Clone, Copy)] +pub struct RegularMethodArgumentContext { + pub arg_policy: MethodCallArgPolicy, + pub receiver_ref_kind: Option, + pub has_incan_method_signature: bool, + pub is_incan_owned_nominal_receiver: bool, + pub is_rusttype_alias_receiver: bool, + pub preserves_lookup_arg_shape: bool, + pub in_return: bool, +} + +/// Choose the value-use site for an ordinary method-call argument from shared receiver facts. +pub fn regular_method_argument_use_site<'a>( + context: RegularMethodArgumentContext, + callee_param: Option<&'a FunctionParam>, +) -> ValueUseSite<'a> { + let target_ty = callee_param.map(|param| ¶m.ty); + if context.receiver_ref_kind != Some(VarRefKind::ExternalRustName) + && (context.has_incan_method_signature + || (context.is_incan_owned_nominal_receiver && !context.is_rusttype_alias_receiver)) + { + ValueUseSite::IncanCallArg { + target_ty, + callee_param, + in_return: false, + } + } else if context.receiver_ref_kind == Some(VarRefKind::ExternalName) { + ValueUseSite::IncanCallArg { + target_ty, + callee_param, + in_return: context.in_return, + } + } else if matches!(context.arg_policy, MethodCallArgPolicy::PreserveShape) || context.preserves_lookup_arg_shape { + ValueUseSite::MethodArg + } else { + ValueUseSite::ExternalCallArg { target_ty } + } +} + /// Plan how one IR expression should be emitted at a specific ownership boundary. pub fn plan_value_use(expr: &IrExpr, site: ValueUseSite<'_>) -> OwnershipPlan { match site { @@ -110,6 +153,16 @@ pub fn plan_value_use(expr: &IrExpr, site: ValueUseSite<'_>) -> OwnershipPlan { } } +/// Return whether the shared value-use planner requires a backend `.clone()` at this use site. +/// +/// Trait-bound inference uses this as a query-only view of the same ownership decision that expression emission uses +/// before applying a conversion. Keep clone-bound inference going through this API instead of duplicating conversion +/// heuristics in the inference pass. +#[must_use] +pub fn value_use_requires_clone_bound(expr: &IrExpr, site: ValueUseSite<'_>) -> bool { + matches!(plan_value_use(expr, site), OwnershipPlan::Clone) +} + /// Return the target type carried by a value-use site, if the site has one. pub fn value_use_site_target_ty<'a>(site: ValueUseSite<'a>) -> Option<&'a IrType> { match site { @@ -722,6 +775,35 @@ mod tests { assert!(rendered.contains("Into::into(__incan_item)")); } + #[test] + fn argument_plan_clone_bound_query_follows_shared_incan_arg_policy() { + let receiver = IrExpr::new( + IrExprKind::Var { + name: "other".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("Wrapper".to_string()), + ); + let expr = IrExpr::new( + IrExprKind::Field { + object: Box::new(receiver), + field: "_cursor".to_string(), + }, + IrType::Generic("T".to_string()), + ); + + assert!(value_use_requires_clone_bound( + &expr, + ValueUseSite::IncanCallArg { + target_ty: Some(&IrType::Generic("T".to_string())), + callee_param: None, + in_return: false, + } + )); + assert!(!value_use_requires_clone_bound(&expr, ValueUseSite::MethodArg)); + } + #[test] fn list_shared_receiver_borrows_plain_list() { assert_eq!( diff --git a/src/backend/ir/reference_shape.rs b/src/backend/ir/reference_shape.rs new file mode 100644 index 000000000..46b668100 --- /dev/null +++ b/src/backend/ir/reference_shape.rs @@ -0,0 +1,33 @@ +//! Predicates for IR expressions that already emit Rust reference-shaped values. +//! +//! Ownership and coercion planning may still see these expressions as ordinary Incan surface types. Keep the +//! reference-shape predicate here so conversions, method emission, and future argument planners do not drift. + +use super::expr::{IrExpr, IrExprKind}; +use super::types::IrType; + +/// Return whether an IR type is already represented as a Rust reference-like value. +#[must_use] +pub fn type_has_rust_reference_shape(ty: &IrType) -> bool { + matches!( + ty, + IrType::Ref(_) | IrType::RefMut(_) | IrType::StrRef | IrType::StaticStr + ) +} + +/// Return whether an expression already emits a Rust reference-shaped value despite carrying an owned Incan surface +/// type in IR. +#[must_use] +pub fn expr_has_rust_reference_shape(expr: &IrExpr) -> bool { + if type_has_rust_reference_shape(&expr.ty) { + return true; + } + matches!( + &expr.kind, + IrExprKind::MethodCall { + method, + args, + .. + } if args.is_empty() && matches!(method.as_str(), "as_slice" | "as_str") + ) +} diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index 8acc9f0a1..d55adb91b 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -33,9 +33,12 @@ use super::IrProgram; use super::decl::{FunctionParam, IrDeclKind, IrFunction, IrTraitBound, IrTypeParam}; use super::expr::{ BinOp, FormatPart, IrCallArg, IrDictEntry, IrExpr, IrExprKind, IrGeneratorClause, IrListEntry, MethodCallArgPolicy, - VarAccess, VarRefKind, + VarRefKind, +}; +use super::ownership::{ + RegularMethodArgumentContext, ValueUseSite, regular_method_argument_use_site, value_use_requires_clone_bound, + value_use_site_target_ty, }; -use super::ownership::{ValueUseSite, plan_value_use, value_use_site_target_ty}; use super::stmt::{IrStmt, IrStmtKind}; use super::types::IrType; @@ -62,6 +65,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { collect_inferred_bounds_for_callable( &func.name, func, + &func.type_params, &trait_decls, &mut function_bounds, &mut function_params, @@ -73,6 +77,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { collect_inferred_bounds_for_callable( &key, method, + &method.type_params, &trait_decls, &mut function_bounds, &mut function_params, @@ -81,6 +86,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { } IrDeclKind::Impl(impl_block) => { for (index, method) in impl_block.methods.iter().enumerate() { + let type_params = callable_inference_type_params(method, Some(&impl_block.type_params)); let key = format!( "impl:{}:{}:{}:{}", impl_block.target_type, @@ -91,6 +97,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { collect_inferred_bounds_for_callable( &key, method, + &type_params, &trait_decls, &mut function_bounds, &mut function_params, @@ -115,6 +122,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { propagate_bounds_for_callable( &func.name, func, + &func.type_params, &snapshot, &function_params, &mut function_bounds, @@ -127,6 +135,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { propagate_bounds_for_callable( &key, method, + &method.type_params, &snapshot, &function_params, &mut function_bounds, @@ -136,6 +145,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { } IrDeclKind::Impl(impl_block) => { for (index, method) in impl_block.methods.iter().enumerate() { + let type_params = callable_inference_type_params(method, Some(&impl_block.type_params)); let key = format!( "impl:{}:{}:{}:{}", impl_block.target_type, @@ -146,6 +156,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { propagate_bounds_for_callable( &key, method, + &type_params, &snapshot, &function_params, &mut function_bounds, @@ -163,38 +174,7 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { } // ---- Pass 3: write inferred bounds back into the IR ---- - for decl in &mut program.declarations { - match &mut decl.kind { - IrDeclKind::Function(func) => { - if let Some(inferred) = function_bounds.remove(&func.name) { - func.type_params = inferred; - } - } - IrDeclKind::Trait(trait_decl) => { - for (index, method) in trait_decl.methods.iter_mut().enumerate() { - let key = format!("trait:{}:{}:{}", trait_decl.name, index, method.name); - if let Some(inferred) = function_bounds.remove(&key) { - method.type_params = inferred; - } - } - } - IrDeclKind::Impl(impl_block) => { - for (index, method) in impl_block.methods.iter_mut().enumerate() { - let key = format!( - "impl:{}:{}:{}:{}", - impl_block.target_type, - impl_block.trait_name.as_deref().unwrap_or(""), - index, - method.name - ); - if let Some(inferred) = function_bounds.remove(&key) { - method.type_params = inferred; - } - } - } - _ => {} - } - } + write_back_callable_bounds(program, &mut function_bounds); // ---- Pass 4: backend-synthesized clone bounds ---- // @@ -212,12 +192,16 @@ pub fn infer_trait_bounds(program: &mut IrProgram) { /// boundaries. fn infer_backend_clone_bounds(program: &mut IrProgram) { let clone_derived_self_params = collect_clone_derived_self_params(program); + let clone_context = BackendCloneInferenceContext::from_program(program); for decl in &mut program.declarations { match &mut decl.kind { - IrDeclKind::Function(func) => { - augment_callable_type_params_for_backend_return_clones(&mut func.type_params, &func.body, None) - } + IrDeclKind::Function(func) => augment_callable_type_params_for_backend_return_clones( + &mut func.type_params, + &func.body, + None, + &clone_context, + ), IrDeclKind::Impl(impl_block) => { let self_clone_params = clone_derived_self_params.get(&impl_block.target_type); for method in &impl_block.methods { @@ -225,6 +209,7 @@ fn infer_backend_clone_bounds(program: &mut IrProgram) { &mut impl_block.type_params, &method.body, self_clone_params, + &clone_context, ); } } @@ -233,6 +218,16 @@ fn infer_backend_clone_bounds(program: &mut IrProgram) { } } +fn callable_inference_type_params(func: &IrFunction, owner_type_params: Option<&[IrTypeParam]>) -> Vec { + let mut type_params = owner_type_params.map_or_else(Vec::new, |params| params.to_vec()); + for type_param in &func.type_params { + if !type_params.iter().any(|existing| existing.name == type_param.name) { + type_params.push(type_param.clone()); + } + } + type_params +} + /// Propagate bounds into one program using already-inferred callable signatures from external programs. /// /// This is used after separate IR programs have already run local bound inference. Imported generic call targets can @@ -284,6 +279,7 @@ fn propagate_trait_bounds_from_signature_maps( propagate_bounds_for_callable( &func.name, func, + &func.type_params, &snapshot, &function_params, &mut function_bounds, @@ -296,6 +292,7 @@ fn propagate_trait_bounds_from_signature_maps( propagate_bounds_for_callable( &key, method, + &method.type_params, &snapshot, &function_params, &mut function_bounds, @@ -305,6 +302,7 @@ fn propagate_trait_bounds_from_signature_maps( } IrDeclKind::Impl(impl_block) => { for (index, method) in impl_block.methods.iter().enumerate() { + let type_params = callable_inference_type_params(method, Some(&impl_block.type_params)); let key = format!( "impl:{}:{}:{}:{}", impl_block.target_type, @@ -315,6 +313,7 @@ fn propagate_trait_bounds_from_signature_maps( propagate_bounds_for_callable( &key, method, + &type_params, &snapshot, &function_params, &mut function_bounds, @@ -501,10 +500,86 @@ fn collect_clone_derived_self_params(program: &IrProgram) -> HashMap, + rusttype_alias_names: HashSet, +} + +#[derive(Clone, Copy)] +struct BackendCallCloneContext<'a> { + callable_signature: Option<&'a super::FunctionSignature>, + in_return: bool, +} + +impl BackendCloneInferenceContext { + fn from_program(program: &IrProgram) -> Self { + let mut incan_nominal_names = HashSet::new(); + let mut rusttype_alias_names = HashSet::new(); + for decl in &program.declarations { + match &decl.kind { + IrDeclKind::Struct(s) => { + incan_nominal_names.insert(s.name.clone()); + } + IrDeclKind::Enum(e) => { + incan_nominal_names.insert(e.name.clone()); + } + IrDeclKind::Trait(trait_decl) => { + incan_nominal_names.insert(trait_decl.name.clone()); + } + IrDeclKind::TypeAlias { + name, + is_rusttype: true, + .. + } => { + incan_nominal_names.insert(name.clone()); + rusttype_alias_names.insert(name.clone()); + } + _ => {} + } + } + Self { + incan_nominal_names, + rusttype_alias_names, + } + } + + fn is_incan_owned_nominal_receiver(&self, receiver_ty: &IrType) -> bool { + match receiver_type_for_method_dispatch(receiver_ty) { + IrType::Struct(name) | IrType::NamedGeneric(name, _) | IrType::Enum(name) => { + self.name_matches(name, &self.incan_nominal_names) + } + IrType::Trait(_) => true, + _ => false, + } + } + + fn is_rusttype_alias_receiver(&self, receiver_ty: &IrType) -> bool { + match receiver_type_for_method_dispatch(receiver_ty) { + IrType::Struct(name) | IrType::NamedGeneric(name, _) => self.name_matches(name, &self.rusttype_alias_names), + _ => false, + } + } + + fn name_matches(&self, name: &str, names: &HashSet) -> bool { + let short_name = name.rsplit("::").next().unwrap_or(name); + names.contains(name) || names.contains(short_name) + } +} + +fn receiver_type_for_method_dispatch(receiver_ty: &IrType) -> &IrType { + let mut receiver_ty = receiver_ty; + while let IrType::Ref(inner) | IrType::RefMut(inner) = receiver_ty { + receiver_ty = inner.as_ref(); + } + receiver_ty +} + fn augment_callable_type_params_for_backend_return_clones( type_params: &mut [IrTypeParam], body: &[IrStmt], self_clone_params: Option<&HashSet>, + clone_context: &BackendCloneInferenceContext, ) { if type_params.is_empty() { return; @@ -513,7 +588,13 @@ fn augment_callable_type_params_for_backend_return_clones( let type_param_names: HashSet<&str> = type_params.iter().map(|tp| tp.name.as_str()).collect(); let mut clone_params = HashSet::new(); for stmt in body { - collect_backend_clone_bounds_in_stmt(stmt, &type_param_names, self_clone_params, &mut clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + &type_param_names, + self_clone_params, + clone_context, + &mut clone_params, + ); } for tp in type_params { @@ -533,6 +614,7 @@ fn collect_backend_clone_bounds_in_stmt( stmt: &IrStmt, type_param_names: &HashSet<&str>, self_clone_params: Option<&HashSet>, + clone_context: &BackendCloneInferenceContext, clone_params: &mut HashSet, ) { match &stmt.kind { @@ -544,6 +626,7 @@ fn collect_backend_clone_bounds_in_stmt( }, type_param_names, self_clone_params, + clone_context, clone_params, ); if let IrExprKind::Call { @@ -556,30 +639,63 @@ fn collect_backend_clone_bounds_in_stmt( collect_backend_clone_bounds_in_call( func, args, - callable_signature.as_ref(), - true, + BackendCallCloneContext { + callable_signature: callable_signature.as_ref(), + in_return: true, + }, type_param_names, self_clone_params, + clone_context, clone_params, ); } else { - collect_backend_clone_bounds_in_expr(expr, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrStmtKind::Expr(expr) => { - collect_backend_clone_bounds_in_expr(expr, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrStmtKind::Let { value, .. } | IrStmtKind::Assign { value, .. } | IrStmtKind::CompoundAssign { value, .. } => { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrStmtKind::While { body, .. } | IrStmtKind::Loop { body, .. } => { for stmt in body { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrStmtKind::For { body, .. } => { for stmt in body { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrStmtKind::If { @@ -588,11 +704,23 @@ fn collect_backend_clone_bounds_in_stmt( .. } => { for stmt in then_branch { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } if let Some(else_branch) = else_branch { for stmt in else_branch { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } @@ -604,23 +732,48 @@ fn collect_backend_clone_bounds_in_stmt( }, type_param_names, self_clone_params, + clone_context, clone_params, ); for arm in arms { if let IrExprKind::Block { stmts, .. } = &arm.body.kind { for stmt in stmts { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } - collect_backend_clone_bounds_in_expr(&arm.body, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + &arm.body, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); if let Some(guard) = &arm.guard { - collect_backend_clone_bounds_in_expr(guard, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + guard, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } IrStmtKind::Block(stmts) => { for stmt in stmts { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrStmtKind::Break { value: Some(expr), .. } => { @@ -631,9 +784,16 @@ fn collect_backend_clone_bounds_in_stmt( }, type_param_names, self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + expr, + type_param_names, + self_clone_params, + clone_context, clone_params, ); - collect_backend_clone_bounds_in_expr(expr, type_param_names, self_clone_params, clone_params); } IrStmtKind::Return(None) | IrStmtKind::Break { label: _, value: None } | IrStmtKind::Continue(_) => {} } @@ -649,9 +809,10 @@ fn collect_backend_clone_bounds_for_value_use<'a>( site: ValueUseSite<'a>, type_param_names: &HashSet<&str>, self_clone_params: Option<&HashSet>, + clone_context: &BackendCloneInferenceContext, clone_params: &mut HashSet, ) { - if value_use_requires_backend_clone(expr, site) { + if value_use_requires_clone_bound(expr, site) { add_backend_clone_bounds_for_cloned_expr(expr, type_param_names, self_clone_params, clone_params); } @@ -689,6 +850,7 @@ fn collect_backend_clone_bounds_for_value_use<'a>( item_site, type_param_names, self_clone_params, + clone_context, clone_params, ); } @@ -710,10 +872,17 @@ fn collect_backend_clone_bounds_for_value_use<'a>( }, type_param_names, self_clone_params, + clone_context, clone_params, ), IrListEntry::Spread(value) => { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } @@ -736,6 +905,7 @@ fn collect_backend_clone_bounds_for_value_use<'a>( }, type_param_names, self_clone_params, + clone_context, clone_params, ); collect_backend_clone_bounds_for_value_use( @@ -745,11 +915,18 @@ fn collect_backend_clone_bounds_for_value_use<'a>( }, type_param_names, self_clone_params, + clone_context, clone_params, ); } IrDictEntry::Spread(value) => { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } @@ -761,6 +938,7 @@ fn collect_backend_clone_bounds_for_value_use<'a>( ValueUseSite::StructField { target_ty: None }, type_param_names, self_clone_params, + clone_context, clone_params, ); } @@ -776,38 +954,50 @@ fn collect_backend_clone_bounds_for_value_use<'a>( fn collect_backend_clone_bounds_in_call( func: &IrExpr, args: &[IrCallArg], - callable_signature: Option<&super::FunctionSignature>, - in_return: bool, + call_context: BackendCallCloneContext<'_>, type_param_names: &HashSet<&str>, self_clone_params: Option<&HashSet>, + clone_context: &BackendCloneInferenceContext, clone_params: &mut HashSet, ) { if call_args_use_incan_clone_policy(func) { for (idx, arg) in args.iter().enumerate() { - let sig_param = callable_signature.and_then(|sig| sig.params.get(idx)); + let sig_param = call_context.callable_signature.and_then(|sig| sig.params.get(idx)); let target_ty = sig_param.map(|param| ¶m.ty).or_else(|| match &func.ty { IrType::Function { params, .. } => params.get(idx), _ => None, }); - let requires_clone = value_use_requires_backend_clone( + let requires_clone = value_use_requires_clone_bound( &arg.expr, ValueUseSite::IncanCallArg { target_ty, callee_param: sig_param, - in_return, + in_return: call_context.in_return, }, ); if requires_clone { add_backend_clone_bounds_for_cloned_expr(&arg.expr, type_param_names, self_clone_params, clone_params); } - collect_backend_clone_bounds_in_expr(&arg.expr, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + &arg.expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } else { for arg in args { - collect_backend_clone_bounds_in_expr(&arg.expr, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + &arg.expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } - collect_backend_clone_bounds_in_expr(func, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr(func, type_param_names, self_clone_params, clone_context, clone_params); } /// Walk an expression tree for backend-planned clones and explicit clone calls that affect generic bounds. @@ -815,6 +1005,7 @@ fn collect_backend_clone_bounds_in_expr( expr: &IrExpr, type_param_names: &HashSet<&str>, self_clone_params: Option<&HashSet>, + clone_context: &BackendCloneInferenceContext, clone_params: &mut HashSet, ) { match &expr.kind { @@ -822,31 +1013,64 @@ fn collect_backend_clone_bounds_in_expr( receiver, args, arg_policy, + callable_signature, .. } => { - if method_call_args_use_incan_clone_policy(receiver, *arg_policy) { - for arg in args { - if incan_call_arg_requires_backend_clone(&arg.expr) { - add_backend_clone_bounds_for_cloned_expr( - &arg.expr, - type_param_names, - self_clone_params, - clone_params, - ); - } - collect_backend_clone_bounds_in_expr(&arg.expr, type_param_names, self_clone_params, clone_params); - } - } else { - for arg in args { - collect_backend_clone_bounds_in_expr(&arg.expr, type_param_names, self_clone_params, clone_params); + let callable_signature = callable_signature.as_ref(); + for (idx, arg) in args.iter().enumerate() { + let sig_param = callable_signature.and_then(|sig| sig.params.get(idx)); + let use_site = regular_method_argument_use_site( + RegularMethodArgumentContext { + arg_policy: *arg_policy, + receiver_ref_kind: receiver_ref_kind(receiver), + has_incan_method_signature: callable_signature.is_some(), + is_incan_owned_nominal_receiver: clone_context.is_incan_owned_nominal_receiver(&receiver.ty), + is_rusttype_alias_receiver: clone_context.is_rusttype_alias_receiver(&receiver.ty), + preserves_lookup_arg_shape: matches!(arg_policy, MethodCallArgPolicy::PreserveShape), + in_return: false, + }, + sig_param, + ); + if value_use_requires_clone_bound(&arg.expr, use_site) { + add_backend_clone_bounds_for_cloned_expr( + &arg.expr, + type_param_names, + self_clone_params, + clone_params, + ); } + collect_backend_clone_bounds_in_expr( + &arg.expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } - collect_backend_clone_bounds_in_expr(receiver, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + receiver, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrExprKind::KnownMethodCall { receiver, args, .. } => { - collect_backend_clone_bounds_in_expr(receiver, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + receiver, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); for arg in args { - collect_backend_clone_bounds_in_expr(&arg.expr, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + &arg.expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Call { @@ -857,22 +1081,37 @@ fn collect_backend_clone_bounds_in_expr( } => collect_backend_clone_bounds_in_call( func, args, - callable_signature.as_ref(), - false, + BackendCallCloneContext { + callable_signature: callable_signature.as_ref(), + in_return: false, + }, type_param_names, self_clone_params, + clone_context, clone_params, ), IrExprKind::BuiltinCall { args, .. } | IrExprKind::Tuple(args) => { for arg in args { - collect_backend_clone_bounds_in_expr(arg, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + arg, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::List(args) => { for arg in args { match arg { IrListEntry::Element(value) | IrListEntry::Spread(value) => { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } @@ -881,23 +1120,53 @@ fn collect_backend_clone_bounds_in_expr( for entry in entries { match entry { IrDictEntry::Pair(key, value) => { - collect_backend_clone_bounds_in_expr(key, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + key, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrDictEntry::Spread(value) => { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } } IrExprKind::Set(items) => { for item in items { - collect_backend_clone_bounds_in_expr(item, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + item, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Struct { fields, .. } => { for (_, value) in fields { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Field { object, .. } @@ -907,15 +1176,33 @@ fn collect_backend_clone_bounds_in_expr( | IrExprKind::NumericResize { expr: object, .. } | IrExprKind::InteropCoerce { expr: object, .. } | IrExprKind::UnaryOp { operand: object, .. } => { - collect_backend_clone_bounds_in_expr(object, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + object, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrExprKind::BinOp { left, right, .. } | IrExprKind::Index { object: left, index: right, } => { - collect_backend_clone_bounds_in_expr(left, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(right, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + left, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + right, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrExprKind::Slice { target, @@ -923,15 +1210,39 @@ fn collect_backend_clone_bounds_in_expr( end, step, } => { - collect_backend_clone_bounds_in_expr(target, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + target, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); if let Some(start) = start { - collect_backend_clone_bounds_in_expr(start, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + start, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } if let Some(end) = end { - collect_backend_clone_bounds_in_expr(end, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + end, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } if let Some(step) = step { - collect_backend_clone_bounds_in_expr(step, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + step, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::If { @@ -939,29 +1250,77 @@ fn collect_backend_clone_bounds_in_expr( then_branch, else_branch, } => { - collect_backend_clone_bounds_in_expr(condition, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(then_branch, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + condition, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + then_branch, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); if let Some(else_branch) = else_branch { - collect_backend_clone_bounds_in_expr(else_branch, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + else_branch, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Block { stmts, value } => { for stmt in stmts { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } if let Some(value) = value { - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Loop { body } => { for stmt in body { - collect_backend_clone_bounds_in_stmt(stmt, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_stmt( + stmt, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Race { arms, .. } => { for arm in arms { - collect_backend_clone_bounds_in_expr(&arm.awaitable, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(&arm.body, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + &arm.awaitable, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + &arm.body, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Match { scrutinee, arms } => { @@ -972,17 +1331,36 @@ fn collect_backend_clone_bounds_in_expr( }, type_param_names, self_clone_params, + clone_context, clone_params, ); for arm in arms { - collect_backend_clone_bounds_in_expr(&arm.body, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + &arm.body, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); if let Some(guard) = &arm.guard { - collect_backend_clone_bounds_in_expr(guard, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + guard, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } IrExprKind::Closure { body, .. } => { - collect_backend_clone_bounds_in_expr(body, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + body, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } IrExprKind::ListComp { element, @@ -990,10 +1368,28 @@ fn collect_backend_clone_bounds_in_expr( filter, .. } => { - collect_backend_clone_bounds_in_expr(element, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(iterable, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + element, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + iterable, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); if let Some(filter) = filter { - collect_backend_clone_bounds_in_expr(filter, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + filter, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::DictComp { @@ -1003,15 +1399,39 @@ fn collect_backend_clone_bounds_in_expr( filter, .. } => { - collect_backend_clone_bounds_in_expr(key, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(value, type_param_names, self_clone_params, clone_params); - collect_backend_clone_bounds_in_expr(iterable, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr(key, type_param_names, self_clone_params, clone_context, clone_params); + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + collect_backend_clone_bounds_in_expr( + iterable, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); if let Some(filter) = filter { - collect_backend_clone_bounds_in_expr(filter, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + filter, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Generator { element, clauses } => { - collect_backend_clone_bounds_in_expr(element, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + element, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); for clause in clauses { match clause { IrGeneratorClause::For { iterable, .. } => { @@ -1019,6 +1439,7 @@ fn collect_backend_clone_bounds_in_expr( iterable, type_param_names, self_clone_params, + clone_context, clone_params, ); } @@ -1027,6 +1448,7 @@ fn collect_backend_clone_bounds_in_expr( condition, type_param_names, self_clone_params, + clone_context, clone_params, ); } @@ -1035,16 +1457,34 @@ fn collect_backend_clone_bounds_in_expr( } IrExprKind::Range { start, end, .. } => { if let Some(start) = start { - collect_backend_clone_bounds_in_expr(start, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + start, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } if let Some(end) = end { - collect_backend_clone_bounds_in_expr(end, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + end, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } IrExprKind::Format { parts } => { for part in parts { if let FormatPart::Expr { expr, .. } = part { - collect_backend_clone_bounds_in_expr(expr, type_param_names, self_clone_params, clone_params); + collect_backend_clone_bounds_in_expr( + expr, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); } } } @@ -1068,24 +1508,11 @@ fn collect_backend_clone_bounds_in_expr( } } -/// Return whether the shared ownership planner would emit `.clone()` for this exact use site. -fn value_use_requires_backend_clone(expr: &IrExpr, site: ValueUseSite<'_>) -> bool { - matches!(plan_value_use(expr, site), super::conversions::Conversion::Clone) -} - -/// Return whether method-call arguments should follow owned Incan call semantics. -/// -/// External Rust receivers and preserve-shape methods own their argument borrowing rules, so applying the generic Incan -/// clone heuristic there would over-constrain generated signatures. -fn method_call_args_use_incan_clone_policy(receiver: &IrExpr, arg_policy: MethodCallArgPolicy) -> bool { - !matches!(arg_policy, MethodCallArgPolicy::PreserveShape) - && !matches!( - &receiver.kind, - IrExprKind::Var { - ref_kind: VarRefKind::ExternalRustName, - .. - } - ) +fn receiver_ref_kind(receiver: &IrExpr) -> Option { + match &receiver.kind { + IrExprKind::Var { ref_kind, .. } => Some(*ref_kind), + _ => None, + } } /// Return whether a call expression targets an Incan callable rather than an external Rust symbol. @@ -1113,20 +1540,6 @@ fn borrowed_method_inner_ty(expr: &IrExpr) -> Option<&IrType> { } } -/// Lightweight predicate for Incan call arguments that may clone before the full use-site planner runs. -/// -/// This covers the common clone-producing shapes used for call arguments: non-last-use non-`Copy` variables, -/// non-`Copy` field reads, borrowed non-`Copy` values, and `as_ref()` results that expose non-`Copy` inner data. -fn incan_call_arg_requires_backend_clone(expr: &IrExpr) -> bool { - match &expr.kind { - IrExprKind::Var { access, .. } if !expr.ty.is_copy() => !matches!(access, VarAccess::Move), - IrExprKind::Field { .. } if !expr.ty.is_copy() => true, - _ if matches!(&expr.ty, IrType::Ref(inner) | IrType::RefMut(inner) if !inner.as_ref().is_copy()) => true, - _ if borrowed_method_inner_ty(expr).is_some_and(|inner| !inner.is_copy()) => true, - _ => false, - } -} - /// Rebuild a parent value-use site for one tuple item while preserving the parent ownership context. /// /// Tuple elements can be planned as call arguments, return values, collection elements, and match scrutinees. This @@ -1257,19 +1670,20 @@ fn collect_generic_type_param_names(ty: &IrType, type_param_names: &HashSet<&str fn collect_inferred_bounds_for_callable( key: &str, func: &IrFunction, + type_params: &[IrTypeParam], trait_decls: &HashMap, function_bounds: &mut HashMap>, function_params: &mut HashMap>, ) { - if func.type_params.is_empty() { + if type_params.is_empty() { return; } - let mut inferred = infer_function_bounds(func); + let mut inferred = infer_function_bounds(func, type_params); // Also check return types like `-> DataSet[T]` / `-> BoundedDataSet[T]`, which lower to `impl Trait` and // must carry through any bounds required by the returned trait's generic arguments. - add_bounds_from_return_type(&func.return_type, &func.type_params, trait_decls, &mut inferred); + add_bounds_from_return_type(&func.return_type, type_params, trait_decls, &mut inferred); function_bounds.insert(key.to_string(), inferred); function_params.insert(key.to_string(), func.params.clone()); @@ -1282,16 +1696,17 @@ fn collect_inferred_bounds_for_callable( fn propagate_bounds_for_callable( key: &str, func: &IrFunction, + type_params: &[IrTypeParam], snapshot: &HashMap>, function_params: &HashMap>, function_bounds: &mut HashMap>, changed: &mut bool, ) { - if func.type_params.is_empty() { + if type_params.is_empty() { return; } - let called_generics = collect_called_generic_functions(func, snapshot, function_params); + let called_generics = collect_called_generic_functions(func, type_params, snapshot, function_params); if let Some(current_bounds) = function_bounds.get_mut(key) { for (callee_name, type_arg_mapping) in &called_generics { if let Some(callee_bounds) = snapshot.get(callee_name) @@ -1304,12 +1719,12 @@ fn propagate_bounds_for_callable( } /// Infer trait bounds for a single function by scanning its body. -fn infer_function_bounds(func: &IrFunction) -> Vec { - let type_param_names: HashSet<&str> = func.type_params.iter().map(|tp| tp.name.as_str()).collect(); +fn infer_function_bounds(func: &IrFunction, type_params: &[IrTypeParam]) -> Vec { + let type_param_names: HashSet<&str> = type_params.iter().map(|tp| tp.name.as_str()).collect(); let mut bounds_map: HashMap> = HashMap::new(); // Start with explicit bounds from `with` clauses. - for tp in &func.type_params { + for tp in type_params { bounds_map.insert(tp.name.clone(), tp.bounds.clone()); } @@ -1319,7 +1734,7 @@ fn infer_function_bounds(func: &IrFunction) -> Vec { } // Rebuild type params with combined bounds. - func.type_params + type_params .iter() .map(|tp| { let bounds = bounds_map.remove(&tp.name).unwrap_or_default(); @@ -2073,10 +2488,11 @@ fn substitute_ir_type(ty: &IrType, subst: &HashMap<&str, &IrType>) -> IrType { /// the caller's type parameter names when the argument is a direct type parameter pass-through. fn collect_called_generic_functions( func: &IrFunction, + type_params: &[IrTypeParam], function_bounds: &HashMap>, function_params: &HashMap>, ) -> Vec<(String, HashMap)> { - let type_param_names: HashSet<&str> = func.type_params.iter().map(|tp| tp.name.as_str()).collect(); + let type_param_names: HashSet<&str> = type_params.iter().map(|tp| tp.name.as_str()).collect(); let mut result = Vec::new(); for stmt in &func.body { @@ -2333,8 +2749,9 @@ fn propagate_transitive_bounds( #[cfg(test)] mod tests { use super::*; - use crate::backend::ir::FunctionRegistry; - use crate::backend::ir::decl::{IrDecl, IrDeclKind, Visibility}; + use crate::backend::ir::decl::{FunctionParam, IrDecl, IrDeclKind, IrImpl, Visibility}; + use crate::backend::ir::expr::{FormatStyle, IrCallArgKind, MethodCallArgPolicy, VarAccess}; + use crate::backend::ir::{FunctionRegistry, FunctionSignature, Mutability, TypedExpr}; fn function(name: &str, type_params: Vec) -> IrFunction { IrFunction { @@ -2366,6 +2783,200 @@ mod tests { } } + #[test] + fn impl_owner_generic_bounds_are_written_to_impl_header() -> Result<(), Box> { + let method = IrFunction { + name: "render".to_string(), + params: Vec::new(), + return_type: IrType::Unit, + body: vec![IrStmt::new(IrStmtKind::Expr(TypedExpr::new( + IrExprKind::Format { + parts: vec![FormatPart::Expr { + expr: TypedExpr::new( + IrExprKind::Var { + name: "value".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Generic("T".to_string()), + ), + style: FormatStyle::Display, + }], + }, + IrType::String, + )))], + is_async: false, + is_generator: false, + visibility: Visibility::Public, + type_params: Vec::new(), + is_extern: false, + rust_attributes: Vec::new(), + lint_allows: Vec::new(), + }; + let mut program = IrProgram { + declarations: vec![IrDecl::new(IrDeclKind::Impl(IrImpl { + target_type: "Boxed".to_string(), + type_params: vec![IrTypeParam::bare("T")], + trait_name: None, + trait_type_args: Vec::new(), + associated_types: Vec::new(), + methods: vec![method], + }))], + source_module_name: None, + entry_point: None, + function_registry: FunctionRegistry::new(), + rust_module_path: None, + newtype_checked_ctor: Default::default(), + }; + + infer_trait_bounds(&mut program); + + let decl = program + .declarations + .first() + .ok_or_else(|| std::io::Error::other("expected impl declaration"))?; + let IrDecl { + kind: IrDeclKind::Impl(impl_block), + .. + } = decl + else { + return Err(std::io::Error::other("expected impl declaration").into()); + }; + let bounds = &impl_block.type_params[0].bounds; + assert!( + bounds.contains(&IrTraitBound::simple(tb::DISPLAY)), + "owner generic T should receive Display bound from impl method body, got {bounds:?}" + ); + assert!( + impl_block.methods[0].type_params.is_empty(), + "impl-owner generics must stay on the impl header, not the method signature" + ); + Ok(()) + } + + #[test] + fn backend_clone_bounds_do_not_use_incan_policy_for_external_nominal_methods() + -> Result<(), Box> { + let mut func = function("send", vec![IrTypeParam::bare("T")]); + func.body = vec![IrStmt::new(IrStmtKind::Expr(TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "client".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("external_crate::Client".to_string()), + )), + method: "send".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: "value".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Generic("T".to_string()), + ), + }], + callable_signature: None, + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Unit, + )))]; + let mut program = program(vec![func]); + + infer_trait_bounds(&mut program); + + let decl = program + .declarations + .first() + .ok_or_else(|| std::io::Error::other("expected function declaration"))?; + let IrDecl { + kind: IrDeclKind::Function(func), + .. + } = decl + else { + return Err(std::io::Error::other("expected function declaration").into()); + }; + assert!( + func.type_params[0].bounds.is_empty(), + "external nominal method args should not inherit Incan clone policy, got {:?}", + func.type_params[0].bounds + ); + Ok(()) + } + + #[test] + fn backend_clone_bounds_use_incan_policy_for_methods_with_signatures() -> Result<(), Box> { + let mut func = function("send", vec![IrTypeParam::bare("T")]); + func.body = vec![IrStmt::new(IrStmtKind::Expr(TypedExpr::new( + IrExprKind::MethodCall { + receiver: Box::new(TypedExpr::new( + IrExprKind::Var { + name: "client".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Struct("Client".to_string()), + )), + method: "send".to_string(), + dispatch: None, + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: "value".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Generic("T".to_string()), + ), + }], + callable_signature: Some(FunctionSignature { + params: vec![FunctionParam { + name: "value".to_string(), + ty: IrType::Generic("T".to_string()), + mutability: Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }], + return_type: IrType::Unit, + }), + arg_policy: MethodCallArgPolicy::Default, + }, + IrType::Unit, + )))]; + let mut program = program(vec![func]); + + infer_trait_bounds(&mut program); + + let decl = program + .declarations + .first() + .ok_or_else(|| std::io::Error::other("expected function declaration"))?; + let IrDecl { + kind: IrDeclKind::Function(func), + .. + } = decl + else { + return Err(std::io::Error::other("expected function declaration").into()); + }; + assert!( + func.type_params[0].bounds.contains(&IrTraitBound::simple(tb::CLONE)), + "Incan method signatures should keep clone-bound inference aligned with emission, got {:?}", + func.type_params[0].bounds + ); + Ok(()) + } + #[test] fn external_generic_bounds_do_not_rewrite_same_named_local_non_generic_function() -> Result<(), Box> { diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs index 597c0cc7e..6e982756a 100644 --- a/src/cli/commands/common.rs +++ b/src/cli/commands/common.rs @@ -31,7 +31,7 @@ use crate::project_lifecycle::toolchain::ToolchainConstraintSet; #[cfg(feature = "rust_inspect")] use crate::rust_inspect::{Inspector, InspectorConfig}; use incan_core::lang::{ - stdlib::{self, StdlibExtraCrateSource}, + stdlib::{self, StdlibExtraCrateDep, StdlibExtraCrateSource}, surface::result_methods, }; #[cfg(feature = "rust_inspect")] @@ -327,30 +327,7 @@ pub(crate) fn collect_project_requirements( continue; }; for dep in namespace.extra_crate_deps { - let spec = match dep.source { - StdlibExtraCrateSource::Version(version) => DependencySpec { - crate_name: dep.crate_name.to_string(), - version: Some(version.to_string()), - features: vec![], - default_features: true, - source: DependencySource::Registry, - optional: false, - package: None, - }, - StdlibExtraCrateSource::Path(relative_path) => DependencySpec { - crate_name: dep.crate_name.to_string(), - version: None, - features: vec![], - default_features: true, - source: DependencySource::Path { - path: workspace_root.join(relative_path), - }, - optional: false, - package: None, - }, - } - .normalized(); - + let spec = dependency_spec_from_stdlib_dep(dep, &workspace_root); merge_requirement_dependency( &mut requirements.dependencies, spec, @@ -361,16 +338,7 @@ pub(crate) fn collect_project_requirements( let needs_serde_runtime = needs_legacy_serde_runtime || stdlib_namespaces.contains("serde"); if needs_serde_runtime { - let serde = DependencySpec { - crate_name: "serde".to_string(), - version: Some("1.0".to_string()), - features: vec!["derive".to_string()], - default_features: true, - source: DependencySource::Registry, - optional: false, - package: None, - } - .normalized(); + let serde = dependency_spec_from_stdlib_extra_crate("serde")?; merge_requirement_dependency( &mut requirements.dependencies, serde, @@ -399,6 +367,42 @@ pub(crate) fn collect_project_requirements( Ok(requirements) } +fn dependency_spec_from_stdlib_extra_crate(crate_name: &str) -> CliResult { + let dep = stdlib::find_extra_crate_dep(crate_name).ok_or_else(|| { + CliError::failure(format!( + "stdlib dependency metadata for `{crate_name}` is missing from the registry" + )) + })?; + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + Ok(dependency_spec_from_stdlib_dep(dep, &workspace_root)) +} + +fn dependency_spec_from_stdlib_dep(dep: &StdlibExtraCrateDep, workspace_root: &Path) -> DependencySpec { + match dep.source { + StdlibExtraCrateSource::Version(version) => DependencySpec { + crate_name: dep.crate_name.to_string(), + version: Some(version.to_string()), + features: dep.features.iter().map(|feature| (*feature).to_string()).collect(), + default_features: true, + source: DependencySource::Registry, + optional: false, + package: stdlib::extra_crate_package_alias(dep.crate_name).map(str::to_string), + }, + StdlibExtraCrateSource::Path(relative_path) => DependencySpec { + crate_name: dep.crate_name.to_string(), + version: None, + features: dep.features.iter().map(|feature| (*feature).to_string()).collect(), + default_features: true, + source: DependencySource::Path { + path: workspace_root.join(relative_path), + }, + optional: false, + package: None, + }, + } + .normalized() +} + /// Merge a dependency requirement into a collection of requirements. /// /// Existing entries with the same crate name must be compatible. @@ -469,14 +473,32 @@ const RUST_INSPECT_WORKSPACE_FINGERPRINT_FILE: &str = ".incan_rust_inspect_finge #[cfg(feature = "rust_inspect")] const RUST_INSPECT_WORKSPACE_FINGERPRINT_PREFIX: &str = "v1:"; -/// Counts how many times the rust-inspect stub workspace is fully regenerated (not skipped via fingerprint). -/// Used by unit tests in this module; serialized with [`RUST_INSPECT_WORKSPACE_TEST_LOCK`]. +/// Counts how many times each rust-inspect stub workspace is fully regenerated instead of skipped via fingerprint. +/// +/// Full lib tests run in parallel and other tests can legitimately create unrelated rust-inspect workspaces, so this +/// instrumentation is keyed by generated workspace path instead of using one process-wide counter. +#[cfg(all(test, feature = "rust_inspect"))] +static TEST_RUST_INSPECT_WORKSPACE_GENERATIONS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); + +/// Records a full rust-inspect workspace regeneration for the generated workspace path under test. #[cfg(all(test, feature = "rust_inspect"))] -pub(crate) static TEST_RUST_INSPECT_WORKSPACE_GENERATIONS: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); +fn record_test_rust_inspect_workspace_generation(workspace_dir: &Path) { + let mut counts = TEST_RUST_INSPECT_WORKSPACE_GENERATIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *counts.entry(workspace_dir.to_path_buf()).or_default() += 1; +} +/// Returns the number of full rust-inspect workspace regenerations recorded for a generated workspace path. #[cfg(all(test, feature = "rust_inspect"))] -static RUST_INSPECT_WORKSPACE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +fn test_rust_inspect_workspace_generations(workspace_dir: &Path) -> u64 { + let counts = TEST_RUST_INSPECT_WORKSPACE_GENERATIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + counts.get(workspace_dir).copied().unwrap_or(0) +} #[cfg(feature = "rust_inspect")] fn normalized_stdlib_features_for_rust_inspect_fingerprint(features: &[String]) -> Vec { @@ -683,7 +705,7 @@ pub(crate) fn ensure_rust_inspect_workspace( rust_inspect_stub.push_str("fn main() {}"); #[cfg(all(test, feature = "rust_inspect"))] - TEST_RUST_INSPECT_WORKSPACE_GENERATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + record_test_rust_inspect_workspace_generation(&rust_inspect_manifest_dir); generator.generate(rust_inspect_stub.as_str()).map_err(|e| { CliError::failure(format!( @@ -2618,13 +2640,6 @@ pub def main() -> int: #[cfg(feature = "rust_inspect")] #[test] fn ensure_rust_inspect_workspace_uses_rust_safe_dependency_keys() -> Result<(), Box> { - use std::sync::atomic::Ordering; - - let _serial = super::RUST_INSPECT_WORKSPACE_TEST_LOCK - .lock() - .map_err(|e| format!("rust-inspect workspace test lock poisoned: {e}"))?; - super::TEST_RUST_INSPECT_WORKSPACE_GENERATIONS.store(0, Ordering::SeqCst); - let tmp = tempfile::tempdir()?; let requirements = ProjectRequirements::default(); let resolved = ResolvedDependencies { @@ -2649,7 +2664,7 @@ pub def main() -> int: Some("[[package]]\nname = \"metadata_probe\"\n".to_string()), )?; assert_eq!( - super::TEST_RUST_INSPECT_WORKSPACE_GENERATIONS.load(Ordering::SeqCst), + super::test_rust_inspect_workspace_generations(&out_dir), 1, "expected one rust-inspect workspace generation" ); @@ -2680,13 +2695,6 @@ pub def main() -> int: #[cfg(feature = "rust_inspect")] #[test] fn ensure_rust_inspect_workspace_skips_regeneration_when_unchanged() -> Result<(), Box> { - use std::sync::atomic::Ordering; - - let _serial = super::RUST_INSPECT_WORKSPACE_TEST_LOCK - .lock() - .map_err(|e| format!("rust-inspect workspace test lock poisoned: {e}"))?; - super::TEST_RUST_INSPECT_WORKSPACE_GENERATIONS.store(0, Ordering::SeqCst); - let tmp = tempfile::tempdir()?; let requirements = ProjectRequirements::default(); let resolved = ResolvedDependencies { @@ -2703,7 +2711,7 @@ pub def main() -> int: }; let lock = Some("[[package]]\nname = \"skip_probe\"\n".to_string()); - ensure_rust_inspect_workspace( + let out_dir = ensure_rust_inspect_workspace( tmp.path(), "skip_probe", Some("2021".to_string()), @@ -2712,7 +2720,7 @@ pub def main() -> int: lock.clone(), )?; assert_eq!( - super::TEST_RUST_INSPECT_WORKSPACE_GENERATIONS.load(Ordering::SeqCst), + super::test_rust_inspect_workspace_generations(&out_dir), 1, "first call should generate the workspace" ); @@ -2726,7 +2734,7 @@ pub def main() -> int: lock, )?; assert_eq!( - super::TEST_RUST_INSPECT_WORKSPACE_GENERATIONS.load(Ordering::SeqCst), + super::test_rust_inspect_workspace_generations(&out_dir), 1, "second call with identical inputs should skip regeneration" ); diff --git a/src/dependency_resolver.rs b/src/dependency_resolver.rs index 1392da575..221e42d65 100644 --- a/src/dependency_resolver.rs +++ b/src/dependency_resolver.rs @@ -15,7 +15,7 @@ use crate::frontend::ast::Span; use crate::frontend::diagnostics::CompileError; use crate::lockfile::CargoFeatureSelection; use crate::manifest::{DependencySource, DependencySpec, ProjectManifest}; -use incan_core::lang::stdlib::{self, STDLIB_NAMESPACES, StdlibExtraCrateSource}; +use incan_core::lang::stdlib::{self, StdlibExtraCrateSource}; /// Validate that a version requirement string uses Cargo SemVer syntax. /// @@ -306,21 +306,6 @@ fn merge_inline_imports( let mut resolved = HashMap::new(); for (crate_name, mut merged_spec) in merged { if merged_spec.spec.version.is_none() { - if !merged_spec.spec.features.is_empty() { - errors.push(DependencyError { - file_path: merged_spec.first_site.file_path.clone(), - error: with_rust_import_context( - CompileError::new( - format!("Rust import features for `{}` require a version annotation", crate_name), - merged_spec.first_site.span, - ) - .with_hint("Add `@ \"version\"` to the rust import."), - &merged_spec.first_site, - ), - }); - continue; - } - let Some(default) = known_good_spec(&crate_name) else { errors.push(DependencyError { file_path: merged_spec.first_site.file_path.clone(), @@ -337,7 +322,14 @@ fn merge_inline_imports( }); continue; }; + let requested_features = std::mem::take(&mut merged_spec.spec.features); merged_spec.spec = default; + for feature in requested_features { + if !merged_spec.spec.features.contains(&feature) { + merged_spec.spec.features.push(feature); + } + } + merged_spec.spec = merged_spec.spec.normalized(); } resolved.insert(crate_name, merged_spec); @@ -355,20 +347,11 @@ fn inline_spec_from_import(import: &InlineRustImport) -> DependencySpec { default_features: true, source: DependencySource::Registry, optional: false, - package: rust_crate_package_alias(&import.crate_name).map(str::to_string), + package: stdlib::extra_crate_package_alias(&import.crate_name).map(str::to_string), } .normalized() } -/// Return the published Cargo package name when it differs from the Rust crate import path. -fn rust_crate_package_alias(crate_name: &str) -> Option<&'static str> { - match crate_name { - "md5" => Some("md-5"), - "xxhash_rust" => Some("xxhash-rust"), - _ => None, - } -} - fn merge_inline_spec(existing: &mut InlineMergedSpec, next: &InlineRustImport) -> Result<(), String> { let next_version = next.version.clone(); if existing.spec.version != next_version { @@ -500,6 +483,10 @@ fn validate_optional_imports( // ============================================================================ fn known_good_spec(crate_name: &str) -> Option { + if let Some(spec) = known_good_spec_from_stdlib(crate_name) { + return Some(spec); + } + let (version, features): (&str, Vec<&str>) = match crate_name { "serde" => ("1.0", vec!["derive"]), "serde_json" => ("1.0", vec![]), @@ -508,8 +495,6 @@ fn known_good_spec(crate_name: &str) -> Option { "chrono" => ("0.4", vec!["serde"]), "reqwest" => ("0.11", vec!["json"]), "uuid" => ("1.0", vec!["v4", "serde"]), - "rand" => ("0.8", vec![]), - "regex" => ("1.0", vec![]), "anyhow" => ("1.0", vec![]), "thiserror" => ("1.0", vec![]), "tracing" => ("0.1", vec![]), @@ -520,10 +505,7 @@ fn known_good_spec(crate_name: &str) -> Option { "futures" => ("0.3", vec![]), "bytes" => ("1.0", vec![]), "itertools" => ("0.12", vec![]), - // For any crate not in the hardcoded list above, fall through to the stdlib registry. - // STDLIB_NAMESPACES is the single source of truth for stdlib-managed crate versions, - // so we derive the spec from there rather than duplicating version strings here. - _ => return known_good_spec_from_stdlib(crate_name), + _ => return None, }; Some( @@ -542,33 +524,27 @@ fn known_good_spec(crate_name: &str) -> Option { /// Look up a known-good spec for crates declared as `extra_crate_deps` in any stdlib namespace. /// -/// This makes `STDLIB_NAMESPACES` the single source of truth for stdlib-managed crate versions. -/// When a stdlib `.incn` file writes `from rust::axum import ...` without an inline version annotation, the resolver -/// finds the version here rather than requiring a duplicate hardcoded entry in `known_good_spec`. +/// This makes the stdlib registry the single source of truth for stdlib-managed crate versions. When a stdlib `.incn` +/// file writes `from rust::axum import ...` without an inline version annotation, the resolver finds the version here +/// rather than requiring a duplicate hardcoded entry in `known_good_spec`. fn known_good_spec_from_stdlib(crate_name: &str) -> Option { - for ns in STDLIB_NAMESPACES { - for dep in ns.extra_crate_deps { - if dep.crate_name == crate_name { - let StdlibExtraCrateSource::Version(version) = dep.source else { - // Path dependencies are not registry crates; skip. - continue; - }; - return Some( - DependencySpec { - crate_name: crate_name.to_string(), - version: Some(version.to_string()), - features: vec![], - default_features: true, - source: DependencySource::Registry, - optional: false, - package: None, - } - .normalized(), - ); - } + let dep = stdlib::extra_crate_deps() + .find(|dep| dep.crate_name == crate_name && matches!(dep.source, StdlibExtraCrateSource::Version(_)))?; + let StdlibExtraCrateSource::Version(version) = dep.source else { + return None; + }; + Some( + DependencySpec { + crate_name: crate_name.to_string(), + version: Some(version.to_string()), + features: dep.features.iter().map(|feature| (*feature).to_string()).collect(), + default_features: true, + source: DependencySource::Registry, + optional: false, + package: stdlib::extra_crate_package_alias(crate_name).map(str::to_string), } - } - None + .normalized(), + ) } #[cfg(test)] @@ -794,6 +770,51 @@ test_lib = "0.5" Ok(()) } + #[test] + fn known_good_default_allows_features_without_inline_version() -> TestResult { + let imports = vec![inline("tokio", None, &["full"], false)]; + + let resolved = resolve_ok(None, &imports, false, &default_cargo_features())?; + let tokio = dependency(&resolved.dependencies, "tokio")?; + assert_eq!(tokio.version.as_deref(), Some("1")); + assert!(tokio.features.contains(&"rt-multi-thread".to_string())); + assert!(tokio.features.contains(&"full".to_string())); + Ok(()) + } + + #[test] + fn stdlib_registry_version_dependencies_drive_known_good_defaults() -> TestResult { + for ns in stdlib::STDLIB_NAMESPACES { + for dep in ns.extra_crate_deps { + let StdlibExtraCrateSource::Version(version) = dep.source else { + continue; + }; + let spec = known_good_spec(dep.crate_name).ok_or_else(|| { + std::io::Error::other(format!( + "expected registry dependency `{}` to resolve as a known-good default", + dep.crate_name + )) + })?; + assert_eq!( + spec.version.as_deref(), + Some(version), + "dependency resolver drifted from stdlib registry metadata for `{}`", + dep.crate_name + ); + assert_eq!( + spec.features, + dep.features + .iter() + .map(|feature| (*feature).to_string()) + .collect::>(), + "dependency resolver drifted from stdlib registry feature metadata for `{}`", + dep.crate_name + ); + } + } + Ok(()) + } + #[test] fn unknown_crate_without_version_is_error() -> TestResult { let imports = vec![inline("unknown_crate_xyz", None, &[], false)]; diff --git a/src/frontend/testing_markers.rs b/src/frontend/testing_markers.rs index c96ef6647..626345ba4 100644 --- a/src/frontend/testing_markers.rs +++ b/src/frontend/testing_markers.rs @@ -135,24 +135,10 @@ pub struct TestingFixtureMarkerArgs { } impl Default for TestingMarkerSemantics { - /// Return the built-in marker semantics used as the extraction baseline for stdlib metadata. + /// Return fixture defaults used while strict marker metadata is loaded from stdlib source. fn default() -> Self { - let mut marker_kinds = HashMap::new(); - marker_kinds.insert("test".to_string(), TestingMarkerKind::Test); - marker_kinds.insert("fixture".to_string(), TestingMarkerKind::Fixture); - marker_kinds.insert("skip".to_string(), TestingMarkerKind::Skip); - marker_kinds.insert("skipif".to_string(), TestingMarkerKind::SkipIf); - marker_kinds.insert("xfail".to_string(), TestingMarkerKind::XFail); - marker_kinds.insert("xfailif".to_string(), TestingMarkerKind::XFailIf); - marker_kinds.insert("slow".to_string(), TestingMarkerKind::Slow); - marker_kinds.insert("mark".to_string(), TestingMarkerKind::Mark); - marker_kinds.insert("resource".to_string(), TestingMarkerKind::Resource); - marker_kinds.insert("serial".to_string(), TestingMarkerKind::Serial); - marker_kinds.insert("timeout".to_string(), TestingMarkerKind::Timeout); - marker_kinds.insert("parametrize".to_string(), TestingMarkerKind::Parametrize); - Self { - marker_kinds, + marker_kinds: HashMap::new(), fixture_scope_arg: "scope".to_string(), fixture_autouse_arg: "autouse".to_string(), fixture_scope_function: "function".to_string(), @@ -358,9 +344,48 @@ fn extract_testing_marker_semantics(program: &ast::Program) -> Result Result<(), TestingMarkerLoadError> { + let expected_names = incan_core::lang::testing::RUNNER_ONLY_MARKER_NAMES; + let mut missing = Vec::new(); + let mut mismatched = Vec::new(); + + for expected_name in expected_names { + let Some(actual_kind) = semantics.marker_kinds.get(*expected_name) else { + missing.push(*expected_name); + continue; + }; + let expected_kind = TestingMarkerKind::from_str(expected_name).ok_or_else(|| { + TestingMarkerLoadError::new(format!( + "runtime marker inventory contains unknown marker `{expected_name}`" + )) + })?; + if actual_kind != &expected_kind { + mismatched.push(format!( + "{expected_name} declares {actual_kind:?}, expected {expected_kind:?}" + )); + } + } + + let unexpected = semantics + .marker_kinds + .keys() + .filter(|name| !expected_names.contains(&name.as_str())) + .cloned() + .collect::>(); + + if !missing.is_empty() || !unexpected.is_empty() || !mismatched.is_empty() { + return Err(TestingMarkerLoadError::new(format!( + "std.testing marker metadata does not match runtime marker inventory; missing={missing:?}, unexpected={unexpected:?}, mismatched={mismatched:?}" + ))); + } + + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct TestingMarkerAnnotation { kind: TestingMarkerKind, @@ -399,6 +424,7 @@ fn parse_testing_metadata_dict( }; let mut kind: Option = None; + let mut runner_only = false; let mut fixture_scope_arg: Option = None; let mut fixture_autouse_arg: Option = None; let mut fixture_scopes: Option<[String; 3]> = None; @@ -428,10 +454,13 @@ fn parse_testing_metadata_dict( }; kind = Some(parsed_kind); } - TESTING_MARKER_RUNNER_ONLY_KEY if expr_as_bool_literal(value_expr).is_none() => { - return Err(TestingMarkerLoadError::new( - "malformed runner_only metadata value (expected bool)", - )); + TESTING_MARKER_RUNNER_ONLY_KEY => { + let Some(value) = expr_as_bool_literal(value_expr) else { + return Err(TestingMarkerLoadError::new( + "malformed runner_only metadata value (expected bool)", + )); + }; + runner_only = value; } TESTING_FIXTURE_SCOPE_ARG_KEY => { let Some(value) = expr_as_string_literal(value_expr) else { @@ -466,6 +495,12 @@ fn parse_testing_metadata_dict( return Ok(None); }; + if !runner_only { + return Err(TestingMarkerLoadError::new( + "std.testing marker metadata must declare runner_only=true", + )); + } + Ok(Some(TestingMarkerAnnotation { kind, fixture_scope_arg, @@ -537,6 +572,19 @@ mod tests { Ok(()) } + #[test] + fn test_std_testing_metadata_matches_runtime_marker_names() -> Result<(), Box> { + let semantics = load_testing_marker_semantics_from_stdlib()?; + let mut metadata_names: Vec<&str> = semantics.marker_kinds.keys().map(String::as_str).collect(); + metadata_names.sort_unstable(); + + let mut runtime_names = incan_core::lang::testing::RUNNER_ONLY_MARKER_NAMES.to_vec(); + runtime_names.sort_unstable(); + + assert_eq!(metadata_names, runtime_names); + Ok(()) + } + #[test] fn test_testing_marker_semantics_malformed_annotation_is_error() -> Result<(), Box> { let source = r#" @@ -561,4 +609,82 @@ def xfail(reason: str = "") -> None: assert!(extracted.is_err(), "malformed marker annotation should fail extraction"); Ok(()) } + + #[test] + fn test_testing_marker_semantics_rejects_non_runner_only_marker() -> Result<(), Box> { + let source = r#" +@rust.extern(metadata={"marker_kind": "skip", "runner_only": false}) +def skip(reason: str = "") -> None: + ... +"#; + let tokens = match crate::frontend::lexer::lex(source) { + Ok(tokens) => tokens, + Err(errs) => return Err(format!("lex failed for non-runner-only annotation fixture: {errs:?}").into()), + }; + let program = match crate::frontend::parser::parse(&tokens) { + Ok(program) => program, + Err(errs) => return Err(format!("parse failed for non-runner-only annotation fixture: {errs:?}").into()), + }; + + let extracted = extract_testing_marker_semantics(&program); + assert!( + extracted + .as_ref() + .is_err_and(|err| err.to_string().contains("runner_only=true")), + "non-runner-only marker annotation should fail extraction; got: {extracted:?}" + ); + Ok(()) + } + + #[test] + fn test_testing_marker_semantics_rejects_incomplete_marker_inventory() -> Result<(), Box> { + let source = r#" +@rust.extern(metadata={"marker_kind": "skip", "runner_only": true}) +def skip(reason: str = "") -> None: + ... +"#; + let tokens = match crate::frontend::lexer::lex(source) { + Ok(tokens) => tokens, + Err(errs) => return Err(format!("lex failed for incomplete marker inventory fixture: {errs:?}").into()), + }; + let program = match crate::frontend::parser::parse(&tokens) { + Ok(program) => program, + Err(errs) => return Err(format!("parse failed for incomplete marker inventory fixture: {errs:?}").into()), + }; + + let extracted = extract_testing_marker_semantics(&program); + assert!( + extracted + .as_ref() + .is_err_and(|err| err.to_string().contains("runtime marker inventory")), + "incomplete marker inventory should fail extraction; got: {extracted:?}" + ); + Ok(()) + } + + #[test] + fn test_testing_marker_semantics_rejects_function_kind_mismatch() -> Result<(), Box> { + let source = r#" +@rust.extern(metadata={"marker_kind": "xfail", "runner_only": true}) +def skip(reason: str = "") -> None: + ... +"#; + let tokens = match crate::frontend::lexer::lex(source) { + Ok(tokens) => tokens, + Err(errs) => return Err(format!("lex failed for mismatched marker fixture: {errs:?}").into()), + }; + let program = match crate::frontend::parser::parse(&tokens) { + Ok(program) => program, + Err(errs) => return Err(format!("parse failed for mismatched marker fixture: {errs:?}").into()), + }; + + let extracted = extract_testing_marker_semantics(&program); + assert!( + extracted + .as_ref() + .is_err_and(|err| err.to_string().contains("mismatched")), + "mismatched marker inventory should fail extraction; got: {extracted:?}" + ); + Ok(()) + } } diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index 7a8158567..9a6aa79c9 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -19,6 +19,7 @@ use incan_core::lang::decorators::{self, DecoratorId}; use incan_core::lang::derives::{self, DeriveId}; use incan_core::lang::magic_methods; use incan_core::lang::stdlib; +use incan_core::lang::testing; use incan_core::lang::traits::{self as builtin_traits, TraitId}; use incan_core::lang::types::collections::CollectionTypeId; use incan_semantics_core::SurfaceModifierTypeCheck; @@ -173,7 +174,10 @@ fn fixture_function_span(func: &FunctionDecl) -> Span { /// Return whether a decorator resolves to the RFC 004 `std.testing.fixture` marker path. fn is_possible_testing_fixture_decorator(dec: &Decorator, aliases: &HashMap>) -> bool { let resolved = crate::frontend::decorator_resolution::resolve_decorator_path(dec, aliases); - resolved.len() == 3 && resolved[0] == "std" && resolved[1] == "testing" && resolved[2] == "fixture" + resolved.len() == 3 + && resolved[0] == stdlib::STDLIB_ROOT + && resolved[1] == testing::STDLIB_TESTING_MODULE + && resolved[2] == testing::TESTING_MARKER_FIXTURE } /// Return whether any declaration in this slice of AST may be a `std.testing.fixture`. diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 165c2bba5..07fb8682f 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -13,14 +13,14 @@ use crate::frontend::typechecker::helpers::{ option_ty, string_method_return, }; use crate::frontend::typechecker::type_info::{RustMethodTraitImportUse, RustTraitImportInfo}; -use incan_core::interop::{RustCollectionFamily, RustItemKind}; +use incan_core::interop::{RustCollectionFamily, RustFunctionSig, RustItemKind, metadata_free_method_signature}; use incan_core::lang::magic_methods; use incan_core::lang::surface::collection_helpers::{self, BuiltinCollectionHelperId}; use incan_core::lang::surface::types as surface_types; use incan_core::lang::surface::types::{SEMAPHORE_ACQUIRE_ERROR_TYPE_NAME, SEMAPHORE_PERMIT_TYPE_NAME, SurfaceTypeId}; use incan_core::lang::surface::{ dict_methods, float_methods, frozen_bytes_methods, frozen_dict_methods, frozen_list_methods, frozen_set_methods, - list_methods, result_methods, set_methods, + iterator_methods, list_methods, result_methods, set_methods, }; use incan_core::lang::traits::{self as core_traits, TraitId}; use incan_core::lang::types::collections::CollectionTypeId; @@ -63,7 +63,17 @@ fn rust_receiver_display(path: &str) -> String { impl TypeChecker { /// Return whether `method` names an RFC 070 `Result[T, E]` combinator. fn result_combinator_name(method: &str) -> bool { - result_methods::from_str(method).is_some() + matches!( + result_methods::from_str(method), + Some( + result_methods::ResultMethodId::Map + | result_methods::ResultMethodId::MapErr + | result_methods::ResultMethodId::AndThen + | result_methods::ResultMethodId::OrElse + | result_methods::ResultMethodId::Inspect + | result_methods::ResultMethodId::InspectErr + ) + ) } /// Resolve a callable function or callable object to its parameter and return types. @@ -200,6 +210,7 @@ impl TypeChecker { self.validate_result_combinator_callback(method, callback_ty, &err_ty, Some(&ResolvedType::Unit), span); ResolvedType::Generic("Result".to_string(), vec![ok_ty, err_ty]) } + result_methods::ResultMethodId::Unwrap | result_methods::ResultMethodId::UnwrapOr => ResolvedType::Unknown, } } @@ -574,13 +585,15 @@ impl TypeChecker { let iterator_elem = self .iterator_protocol_element_type(base_ty) .unwrap_or_else(|| elem.clone()); + let method_id = iterator_methods::from_str(method)?; + use iterator_methods::IteratorMethodId as M; - match method { - "iter" => { + match method_id { + M::Iter => { self.validate_iterator_method_arity(method, 0, args.len(), span); Some(Self::iterator_protocol_ty(elem)) } - "map" => { + M::Map => { if !self.validate_iterator_method_arity(method, 1, args.len(), span) { return Some(Self::iterator_protocol_ty(ResolvedType::Unknown)); } @@ -591,7 +604,7 @@ impl TypeChecker { ); Some(Self::iterator_protocol_ty(mapped)) } - "filter" | "take_while" | "skip_while" => { + M::Filter | M::TakeWhile | M::SkipWhile => { if self.validate_iterator_method_arity(method, 1, args.len(), span) { self.validate_iterator_callback_return( method, @@ -603,7 +616,7 @@ impl TypeChecker { } Some(Self::iterator_protocol_ty(iterator_elem)) } - "flat_map" => { + M::FlatMap => { if !self.validate_iterator_method_arity(method, 1, args.len(), span) { return Some(Self::iterator_protocol_ty(ResolvedType::Unknown)); } @@ -628,7 +641,7 @@ impl TypeChecker { }; Some(Self::iterator_protocol_ty(flat_elem)) } - "take" | "skip" => { + M::Take | M::Skip => { if self.validate_iterator_method_arity(method, 1, args.len(), span) && let Some(arg_ty) = arg_types.first() && !self.types_compatible(arg_ty, &ResolvedType::Int) @@ -638,7 +651,7 @@ impl TypeChecker { } Some(Self::iterator_protocol_ty(iterator_elem)) } - "chain" => { + M::Chain => { if self.validate_iterator_method_arity(method, 1, args.len(), span) && let Some(arg_ty) = arg_types.first() { @@ -650,14 +663,14 @@ impl TypeChecker { } Some(Self::iterator_protocol_ty(iterator_elem)) } - "enumerate" => { + M::Enumerate => { self.validate_iterator_method_arity(method, 0, args.len(), span); Some(Self::iterator_protocol_ty(ResolvedType::Tuple(vec![ ResolvedType::Int, iterator_elem, ]))) } - "zip" => { + M::Zip => { if !self.validate_iterator_method_arity(method, 1, args.len(), span) { return Some(Self::iterator_protocol_ty(ResolvedType::Unknown)); } @@ -682,7 +695,7 @@ impl TypeChecker { other_elem, ]))) } - "batch" => { + M::Batch => { if self.validate_iterator_method_arity(method, 1, args.len(), span) && let Some(arg_ty) = arg_types.first() && !self.types_compatible(arg_ty, &ResolvedType::Int) @@ -693,15 +706,15 @@ impl TypeChecker { self.validate_iterator_batch_size_literal(args, span); Some(Self::iterator_protocol_ty(list_ty(iterator_elem))) } - "collect" => { + M::Collect => { self.validate_iterator_method_arity(method, 0, args.len(), span); Some(list_ty(iterator_elem)) } - "count" => { + M::Count => { self.validate_iterator_method_arity(method, 0, args.len(), span); Some(ResolvedType::Int) } - "any" | "all" => { + M::Any | M::All => { if self.validate_iterator_method_arity(method, 1, args.len(), span) { self.validate_iterator_callback_return( method, @@ -713,7 +726,7 @@ impl TypeChecker { } Some(ResolvedType::Bool) } - "find" => { + M::Find => { if self.validate_iterator_method_arity(method, 1, args.len(), span) { self.validate_iterator_callback_return( method, @@ -725,7 +738,7 @@ impl TypeChecker { } Some(option_ty(iterator_elem)) } - "reduce" | "fold" => { + M::Reduce | M::Fold => { if !self.validate_iterator_method_arity(method, 2, args.len(), span) { return Some(ResolvedType::Unknown); } @@ -739,7 +752,7 @@ impl TypeChecker { ); Some(acc_ty) } - "for_each" => { + M::ForEach => { if self.validate_iterator_method_arity(method, 1, args.len(), span) { self.validate_iterator_callback_return( method, @@ -751,11 +764,10 @@ impl TypeChecker { } Some(ResolvedType::Unit) } - "sum" => { + M::Sum => { self.validate_iterator_method_arity(method, 0, args.len(), span); Some(self.iterator_sum_output_type(&iterator_elem, span)) } - _ => None, } } @@ -1357,6 +1369,16 @@ impl TypeChecker { ); return Some(Self::substitute_rust_self_type(ret, rust_path)); } + if let Some(ret) = self.validate_metadata_free_rust_method_call( + rust_path, + method, + args, + arg_types, + preserves_lookup_arg_shape, + span, + ) { + return Some(ret); + } return None; }; match &metadata.kind { @@ -1376,6 +1398,16 @@ impl TypeChecker { ); return Some(Self::substitute_rust_self_type(ret, rust_path)); } + if let Some(ret) = self.validate_metadata_free_rust_method_call( + rust_path, + method, + args, + arg_types, + preserves_lookup_arg_shape, + span, + ) { + return Some(ret); + } // Stay permissive when no unambiguous imported trait or trait method signature can be selected. return Some(ResolvedType::Unknown); }; @@ -1412,6 +1444,34 @@ impl TypeChecker { } } + /// Validate one metadata-free Rust method compatibility rule through the ordinary Rust-boundary path. + fn validate_metadata_free_rust_method_call( + &mut self, + rust_path: &str, + method: &str, + args: &[CallArg], + arg_types: &[ResolvedType], + preserves_lookup_arg_shape: bool, + span: Span, + ) -> Option { + let sig: RustFunctionSig = metadata_free_method_signature(rust_path, method)?; + let callable_display = format!("rust::{rust_path}.{method}"); + let error_count = self.errors.len(); + let ret = self.validate_rust_method_call( + callable_display.as_str(), + &sig, + args, + arg_types, + preserves_lookup_arg_shape, + span, + ); + if self.errors.len() > error_count { + Some(ResolvedType::Unknown) + } else { + Some(Self::substitute_rust_self_type(ret, rust_path)) + } + } + /// Record the imported Rust extension trait needed for a method call when metadata proves a unique match. /// /// Rust method lookup needs the trait binding in scope even though the emitted call remains `receiver.method(...)`. @@ -1452,9 +1512,9 @@ impl TypeChecker { /// Record a unique imported Rust trait method when receiver metadata is unavailable. /// - /// rust-inspect can miss generated or re-export-heavy concrete types while still extracting the imported trait's - /// signature. In that case the trait signature is enough for call-site parameter shape metadata; rustc remains the - /// authority on whether the receiver type actually implements the trait. + /// rust-inspect can miss generated or re-export-heavy concrete types while still extracting the imported trait or + /// falling back to core extension-trait vocabulary. In that case the import itself is enough for Rust method + /// lookup; a recovered signature only adds call-site parameter shape metadata. fn record_unique_rust_trait_import_for_unresolved_receiver_call( &mut self, method: &str, @@ -1476,7 +1536,6 @@ impl TypeChecker { let [import_use] = matches.as_slice() else { return None; }; - import_use.signature.as_ref()?; self.type_info .record_rust_method_trait_import_use(span, import_use.clone()); Some(import_use.clone()) @@ -2915,7 +2974,8 @@ impl TypeChecker { // Rust: `Option<&T>::copied() -> Option` (for `T: Copy`). if let ResolvedType::Ref(t) | ResolvedType::RefMut(t) = inner { let t = (*t).clone(); - if matches!(t, ResolvedType::Int | ResolvedType::Float | ResolvedType::Bool) { + let is_unresolved_rust_generic = matches!(&t, ResolvedType::RustPath(path) if TypeChecker::rust_display_type_var_name(path).is_some()); + if self.is_copy_type(&t) || self.is_generic_placeholder_type(&t) || is_unresolved_rust_generic { return option_ty(t); } } @@ -2939,6 +2999,42 @@ impl TypeChecker { } } + if let ResolvedType::Generic(name, type_args) = &base_ty + && collection_type_id(name.as_str()) == Some(CollectionTypeId::Result) + && type_args.len() == 2 + { + let ok_ty = type_args[0].clone(); + match result_methods::from_str(method) { + Some(result_methods::ResultMethodId::Unwrap) => { + if !args.is_empty() { + self.errors.push(errors::type_mismatch( + "no arguments", + &format!("{} argument(s)", args.len()), + span, + )); + } + return ok_ty; + } + Some(result_methods::ResultMethodId::UnwrapOr) => { + if let Some(default_ty) = arg_types.first() + && !self.types_compatible(default_ty, &ok_ty) + { + self.errors + .push(errors::type_mismatch(&ok_ty.to_string(), &default_ty.to_string(), span)); + } + if args.len() != 1 { + self.errors.push(errors::type_mismatch( + "one default argument", + &format!("{} argument(s)", args.len()), + span, + )); + } + return ok_ty; + } + _ => {} + } + } + if let ResolvedType::Generic(name, type_args) = &base_ty && collection_type_id(name.as_str()) == Some(CollectionTypeId::Result) && type_args.len() == 2 @@ -2958,20 +3054,21 @@ impl TypeChecker { if let ResolvedType::Generic(name, type_args) = &base_ty { if collection_type_id(name.as_str()) == Some(CollectionTypeId::Generator) { let elem = type_args.first().cloned().unwrap_or(ResolvedType::Unknown); - match method { - "map" => { + use iterator_methods::IteratorMethodId as M; + match iterator_methods::from_str(method) { + Some(M::Map) => { let mapped = self.generator_map_return_type(&elem, args, &arg_types, span); return generator_ty(mapped); } - "filter" => { + Some(M::Filter) => { self.validate_generator_filter_arg(&elem, args, &arg_types, span); return generator_ty(elem); } - "take" => { + Some(M::Take) => { self.validate_generator_take_arg(args, &arg_types, span); return generator_ty(elem); } - "collect" => { + Some(M::Collect) => { if !args.is_empty() { self.errors.push(errors::type_mismatch( "no arguments", diff --git a/src/frontend/typechecker/check_expr/calls/generic_bounds.rs b/src/frontend/typechecker/check_expr/calls/generic_bounds.rs index 54f899af5..eca2eaa11 100644 --- a/src/frontend/typechecker/check_expr/calls/generic_bounds.rs +++ b/src/frontend/typechecker/check_expr/calls/generic_bounds.rs @@ -5,13 +5,6 @@ use crate::frontend::ast::{CallArg, Span, Spanned, Type}; use crate::frontend::diagnostics::errors; use crate::frontend::resolved_type_subst::{substitute_resolved_type, type_param_subst_map_call_site}; use crate::frontend::symbols::{CallableParam, FunctionInfo, MethodInfo, ResolvedType, TypeInfo}; -use crate::frontend::typechecker::helpers::collection_type_id; -use incan_core::interop::is_rust_capability_bound; -use incan_core::lang::derives::{self, DeriveId}; -use incan_core::lang::trait_capabilities::{self, TraitCapabilityInfo, TraitCapabilityType}; -use incan_core::lang::traits::{self as builtin_traits, TraitId}; -use incan_core::lang::types::collections::CollectionTypeId; -use incan_core::lang::types::numerics; impl TypeChecker { /// Validate generic function call type arguments, value arguments, and explicit type-parameter bounds. @@ -397,649 +390,4 @@ impl TypeChecker { } } } - - /// Return the active generic placeholder name represented by `ty`. - /// - /// Function bodies sometimes resolve an in-scope type parameter as a named placeholder while validating a nested - /// generic call. Treat that spelling as a placeholder only when the current bound stack actually contains it. - fn active_type_param_name<'a>(&self, ty: &'a ResolvedType) -> Option<&'a str> { - let name = match ty { - ResolvedType::TypeVar(name) | ResolvedType::Named(name) => name, - _ => return None, - }; - self.current_type_param_bound_details - .iter() - .rev() - .any(|frame| frame.contains_key(name)) - .then_some(name.as_str()) - } - - /// Check whether an active generic placeholder already carries the bound required by a nested generic call. - fn active_type_param_satisfies_bound_info( - &self, - placeholder_name: &str, - required: &crate::frontend::symbols::TypeBoundInfo, - bindings: &std::collections::HashMap, - ) -> bool { - for frame in self.current_type_param_bound_details.iter().rev() { - let Some(active_bounds) = frame.get(placeholder_name) else { - continue; - }; - for active in active_bounds { - if !Self::type_bound_names_match(active, required) { - continue; - } - if required.type_args.is_empty() { - return true; - } - if active.type_args.len() != required.type_args.len() { - continue; - } - let expected = required - .type_args - .iter() - .map(|arg| substitute_resolved_type(arg, bindings)); - let actual = active - .type_args - .iter() - .map(|arg| substitute_resolved_type(arg, bindings)); - if expected - .zip(actual) - .all(|(left, right)| self.types_compatible(&left, &right)) - { - return true; - } - } - return false; - } - false - } - - /// Render a type-parameter bound with call-site substitutions applied. - fn type_bound_display( - &self, - bound: &crate::frontend::symbols::TypeBoundInfo, - bindings: &std::collections::HashMap, - ) -> String { - if bound.type_args.is_empty() { - return bound.name.clone(); - } - let args = bound - .type_args - .iter() - .map(|arg| substitute_resolved_type(arg, bindings).to_string()) - .collect::>() - .join(", "); - format!("{}[{}]", bound.name, args) - } - - /// Return the resolved source trait item name for a bound, falling back to the visible spelling. - fn type_bound_source_name(bound: &crate::frontend::symbols::TypeBoundInfo) -> &str { - bound - .source_name - .as_deref() - .unwrap_or_else(|| bound.name.rsplit('.').next().unwrap_or(bound.name.as_str())) - } - - /// Return whether two bound records identify the same trait, accounting for import aliases. - fn type_bound_names_match( - left: &crate::frontend::symbols::TypeBoundInfo, - right: &crate::frontend::symbols::TypeBoundInfo, - ) -> bool { - if left.name == right.name { - return true; - } - left.module_path == right.module_path - && left.module_path.is_some() - && Self::type_bound_source_name(left) == Self::type_bound_source_name(right) - } - - /// Return whether a type satisfies one explicit bound, including generic trait arguments. - pub(crate) fn type_satisfies_explicit_bound_info( - &self, - ty: &ResolvedType, - bound: &crate::frontend::symbols::TypeBoundInfo, - bindings: &std::collections::HashMap, - ) -> bool { - if let Some(placeholder_name) = self.active_type_param_name(ty) - && self.active_type_param_satisfies_bound_info(placeholder_name, bound, bindings) - { - return true; - } - if bound.name == builtin_traits::as_str(TraitId::Awaitable) { - let expected_output = bound - .type_args - .first() - .map(|arg| substitute_resolved_type(arg, bindings)); - return self.type_satisfies_awaitable_bound(ty, expected_output.as_ref()); - } - if let Some(capability) = self.temporary_trait_capability_for_bound_info(bound) - && let Some(satisfies) = self.temporary_trait_capability_supports_type(capability, ty) - { - return satisfies; - } - if bound.type_args.is_empty() { - return self.type_satisfies_explicit_bound(ty, &bound.name); - } - if is_rust_capability_bound(&bound.name) { - return true; - } - if builtin_traits::from_str(&bound.name).is_some() || self.lookup_semantic_trait_info(&bound.name).is_none() { - return self.type_satisfies_explicit_bound(ty, &bound.name); - } - let expected_args = bound - .type_args - .iter() - .map(|arg| substitute_resolved_type(arg, bindings)) - .collect::>(); - self.type_satisfies_nominal_trait_bound_with_args(ty, &bound.name, &expected_args) - } - - /// Best-effort check whether a concrete type satisfies an explicit generic bound. - fn type_satisfies_explicit_bound(&self, ty: &ResolvedType, bound: &str) -> bool { - if bound == builtin_traits::as_str(TraitId::Awaitable) { - return self.type_satisfies_awaitable_bound(ty, None); - } - // `std.rust` markers (`Send`, `Sync`, …) are enforced when lowering to Rust, not here. - if is_rust_capability_bound(bound) { - return true; - } - if let Some(capability) = self.temporary_trait_capability_for_bound(bound) - && let Some(satisfies) = self.temporary_trait_capability_supports_type(capability, ty) - { - return satisfies; - } - // For non-builtin traits, apply nominal trait/supertrait compatibility (RFC 042) directly. - // - // This keeps capability checks language-general and avoids ad hoc receiver-category gating. - if builtin_traits::from_str(bound).is_none() && self.lookup_semantic_trait_info(bound).is_some() { - return self.type_satisfies_nominal_trait_bound(ty, bound); - } - match ty { - // Unknown / still-generic types are kept permissive to avoid cascading errors. - ResolvedType::Unknown - | ResolvedType::TypeVar(_) - | ResolvedType::RustPath(_) - | ResolvedType::CallSiteInfer => true, - ResolvedType::Int - | ResolvedType::Float - | ResolvedType::Numeric(_) - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit => self.primitive_type_satisfies_bound(ty, bound), - ResolvedType::Tuple(items) => self.tuple_type_satisfies_bound(items, bound), - ResolvedType::FrozenList(inner) => self.collection_type_satisfies_bound( - CollectionTypeId::FrozenList, - std::slice::from_ref(inner.as_ref()), - bound, - ), - ResolvedType::FrozenSet(inner) => self.collection_type_satisfies_bound( - CollectionTypeId::FrozenSet, - std::slice::from_ref(inner.as_ref()), - bound, - ), - ResolvedType::FrozenDict(k, v) => { - let pair = [k.as_ref().clone(), v.as_ref().clone()]; - self.collection_type_satisfies_bound(CollectionTypeId::FrozenDict, &pair, bound) - } - ResolvedType::Generic(name, args) => { - if let Some(kind) = collection_type_id(name.as_str()) { - self.collection_type_satisfies_bound(kind, args, bound) - } else { - self.named_type_satisfies_bound(name, bound) - } - } - ResolvedType::Named(type_name) => self.named_type_satisfies_bound(type_name, bound), - ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => self.type_satisfies_explicit_bound(inner, bound), - ResolvedType::Function(_, _) | ResolvedType::SelfType => false, - } - } - - /// Check whether `ty` satisfies a nominal trait bound `bound_trait` under RFC 042 semantics. - /// - /// This path is used for non-builtin traits. It intentionally reuses existing trait compatibility helpers: - /// - concrete adopters satisfy direct and transitive supertraits via `type_implements_trait` - /// - trait-typed values satisfy broader traits via `trait_is_supertrait_of` - fn type_satisfies_nominal_trait_bound(&self, ty: &ResolvedType, bound_trait: &str) -> bool { - match ty { - // Keep unknown / generic placeholders permissive to avoid cascading diagnostics. - ResolvedType::Unknown - | ResolvedType::TypeVar(_) - | ResolvedType::RustPath(_) - | ResolvedType::CallSiteInfer => true, - ResolvedType::Named(type_name) => { - if self.lookup_semantic_trait_info(type_name).is_some() { - self.trait_is_supertrait_of(type_name, bound_trait) - } else { - self.type_implements_trait(type_name, bound_trait) - } - } - ResolvedType::Generic(type_name, _args) => { - if self.lookup_semantic_trait_info(type_name).is_some() { - self.trait_is_supertrait_of(type_name, bound_trait) - } else if self.lookup_semantic_type_info(type_name).is_some() { - self.type_implements_trait(type_name, bound_trait) - } else { - false - } - } - ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => { - self.type_satisfies_nominal_trait_bound(inner, bound_trait) - } - ResolvedType::Int - | ResolvedType::Float - | ResolvedType::Numeric(_) - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - | ResolvedType::Tuple(_) - | ResolvedType::FrozenList(_) - | ResolvedType::FrozenSet(_) - | ResolvedType::FrozenDict(_, _) - | ResolvedType::Function(_, _) - | ResolvedType::SelfType => false, - } - } - - /// Return whether a nominal type satisfies a trait bound with exact expected trait arguments. - fn type_satisfies_nominal_trait_bound_with_args( - &self, - ty: &ResolvedType, - bound_trait: &str, - expected_args: &[ResolvedType], - ) -> bool { - match ty { - ResolvedType::Unknown - | ResolvedType::TypeVar(_) - | ResolvedType::RustPath(_) - | ResolvedType::CallSiteInfer => true, - ResolvedType::Named(type_name) => { - self.type_implements_trait_with_args(type_name, &[], bound_trait, expected_args) - } - ResolvedType::Generic(type_name, type_args) => { - self.type_implements_trait_with_args(type_name, type_args, bound_trait, expected_args) - } - ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => { - self.type_satisfies_nominal_trait_bound_with_args(inner, bound_trait, expected_args) - } - ResolvedType::Int - | ResolvedType::Float - | ResolvedType::Numeric(_) - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - | ResolvedType::Tuple(_) - | ResolvedType::FrozenList(_) - | ResolvedType::FrozenSet(_) - | ResolvedType::FrozenDict(_, _) - | ResolvedType::Function(_, _) - | ResolvedType::SelfType => false, - } - } - - /// Check a concrete model/class adoption list for a matching generic trait instantiation. - fn type_implements_trait_with_args( - &self, - type_name: &str, - concrete_type_args: &[ResolvedType], - bound_trait: &str, - expected_args: &[ResolvedType], - ) -> bool { - let Some(info) = self.lookup_semantic_type_info(type_name) else { - return false; - }; - let (owner_type_params, adoptions, derives) = match info { - TypeInfo::Model(model) => ( - model.type_params.as_slice(), - model.trait_adoptions.as_slice(), - Some(model.derives.as_slice()), - ), - TypeInfo::Class(class) => ( - class.type_params.as_slice(), - class.trait_adoptions.as_slice(), - Some(class.derives.as_slice()), - ), - TypeInfo::Enum(en) => ( - en.type_params.as_slice(), - en.trait_adoptions.as_slice(), - Some(en.derives.as_slice()), - ), - TypeInfo::Newtype(newtype) => (newtype.type_params.as_slice(), newtype.trait_adoptions.as_slice(), None), - TypeInfo::Builtin | TypeInfo::TypeAlias => return false, - }; - - if expected_args.is_empty() - && derives.is_some_and(|items| items.iter().any(|derive| derive == bound_trait)) - && self.lookup_semantic_trait_info(bound_trait).is_some() - { - return true; - } - - let owner_subst = - crate::frontend::resolved_type_subst::type_param_subst_map(owner_type_params, concrete_type_args); - for adoption in adoptions { - let Some(adopted_info) = self.lookup_semantic_trait_info(&adoption.name) else { - continue; - }; - let direct_args = if adoption.type_args.is_empty() { - concrete_type_args - .iter() - .take(adopted_info.type_params.len()) - .cloned() - .collect::>() - } else { - adoption - .type_args - .iter() - .map(|arg| substitute_resolved_type(arg, &owner_subst)) - .collect::>() - }; - if direct_args.len() != adopted_info.type_params.len() { - continue; - } - if self.trait_name_matches(&adoption.name, bound_trait) - && self.trait_args_match(&direct_args, expected_args) - { - return true; - } - - let subst = - crate::frontend::resolved_type_subst::type_param_subst_map(&adopted_info.type_params, &direct_args); - for (supertrait_name, supertrait_args) in self.semantic_supertrait_closure(&adoption.name) { - if !self.trait_name_matches(&supertrait_name, bound_trait) { - continue; - } - let instantiated = supertrait_args - .iter() - .map(|arg| substitute_resolved_type(arg, &subst)) - .collect::>(); - if self.trait_args_match(&instantiated, expected_args) { - return true; - } - } - } - false - } - - /// Compare instantiated trait arguments using the typechecker's compatibility relation. - fn trait_args_match(&self, actual_args: &[ResolvedType], expected_args: &[ResolvedType]) -> bool { - actual_args.len() == expected_args.len() - && actual_args - .iter() - .zip(expected_args.iter()) - .all(|(actual, expected)| self.types_compatible(actual, expected)) - } - - /// Return whether a primitive type satisfies a builtin or registry-backed temporary capability bound. - fn primitive_type_satisfies_bound(&self, ty: &ResolvedType, bound: &str) -> bool { - if bound == derives::as_str(DeriveId::Copy) { - return self.is_copy_type(ty); - } - if let Some(capability) = self.temporary_trait_capability_for_bound(bound) - && let Some(satisfies) = self.temporary_trait_capability_supports_type(capability, ty) - { - return satisfies; - } - - match builtin_traits::from_str(bound) { - Some(TraitId::Clone | TraitId::Debug | TraitId::Display) => matches!( - ty, - ResolvedType::Int - | ResolvedType::Float - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - ), - Some(TraitId::Default) => matches!( - ty, - ResolvedType::Int - | ResolvedType::Float - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - ), - Some(TraitId::Awaitable) => self.type_satisfies_awaitable_bound(ty, None), - Some(TraitId::Eq | TraitId::Ord | TraitId::Hash) => matches!( - ty, - ResolvedType::Int - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - ), - Some(TraitId::PartialEq | TraitId::PartialOrd) => matches!( - ty, - ResolvedType::Int - | ResolvedType::Float - | ResolvedType::Bool - | ResolvedType::Str - | ResolvedType::Bytes - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - ), - _ => false, - } - } - - /// Resolve a temporary trait-owned capability bridge for a bound. - /// - /// This keeps RFC 101's v0.3 bridge explicit until RFC 098/099 can express the same conformance family in source. - fn temporary_trait_capability_for_bound(&self, bound: &str) -> Option<&'static TraitCapabilityInfo> { - let (module_path, trait_name) = self.resolve_bound_trait_path(bound)?; - let capability = trait_capabilities::for_trait_path(&module_path, &trait_name)?; - let info = self - .lookup_semantic_trait_info(bound) - .or_else(|| self.lookup_semantic_trait_info(capability.trait_name))?; - capability - .required_methods - .iter() - .all(|method| info.methods.contains_key(*method)) - .then_some(capability) - } - - /// Resolve a temporary capability bridge from a checked bound that may have crossed a package manifest boundary. - fn temporary_trait_capability_for_bound_info( - &self, - bound: &crate::frontend::symbols::TypeBoundInfo, - ) -> Option<&'static TraitCapabilityInfo> { - if let Some(module_path) = &bound.module_path { - let trait_name = Self::type_bound_source_name(bound); - return trait_capabilities::for_trait_path(module_path, trait_name); - } - self.temporary_trait_capability_for_bound(&bound.name) - } - - /// Resolve a bound spelling to its defining module path and trait name. - fn resolve_bound_trait_path(&self, bound: &str) -> Option<(Vec, String)> { - if let Some(path) = self.import_aliases.get(bound) - && path.len() >= 2 - { - let trait_name = path.last()?.clone(); - let module_path = path[..path.len() - 1].to_vec(); - return Some((module_path, trait_name)); - } - if !bound.contains('.') { - let module_path = self.current_module_path.clone()?; - return Some((module_path, bound.to_string())); - } - let (module_name, trait_name) = bound.rsplit_once('.')?; - let module_path = self.module_path_for_imported_name(module_name)?; - Some((module_path, trait_name.to_string())) - } - - /// Return temporary trait satisfaction for proven source type families. - /// - /// Unresolved shapes stay permissive so ordinary inference and Rust interop can finish before a later concrete - /// substitution proves or rejects the capability. `None` means this bridge has no opinion and nominal lookup should - /// continue. - fn temporary_trait_capability_supports_type( - &self, - capability: &TraitCapabilityInfo, - ty: &ResolvedType, - ) -> Option { - match ty { - ResolvedType::Unknown - | ResolvedType::TypeVar(_) - | ResolvedType::RustPath(_) - | ResolvedType::CallSiteInfer => Some(true), - ResolvedType::Int => Some(trait_capabilities::supports_type(capability, TraitCapabilityType::Int)), - ResolvedType::Bool => Some(trait_capabilities::supports_type(capability, TraitCapabilityType::Bool)), - ResolvedType::Str => Some(trait_capabilities::supports_type(capability, TraitCapabilityType::Str)), - ResolvedType::Bytes => Some(trait_capabilities::supports_type( - capability, - TraitCapabilityType::Bytes, - )), - ResolvedType::Numeric(id) => Some(trait_capabilities::supports_type( - capability, - TraitCapabilityType::Numeric(*id), - )), - ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => { - self.temporary_trait_capability_supports_type(capability, inner) - } - ResolvedType::Generic(name, args) - if numerics::decimal_constructor_from_str(name.as_str()).is_some() - && args.len() == 2 - && args - .iter() - .all(|arg| matches!(arg, ResolvedType::TypeVar(value) if value.parse::().is_ok())) => - { - Some(trait_capabilities::supports_type( - capability, - TraitCapabilityType::Decimal, - )) - } - ResolvedType::Named(type_name) | ResolvedType::Generic(type_name, _) - if self.value_enum_type_satisfies_temporary_trait_capability(type_name) => - { - Some(trait_capabilities::supports_type( - capability, - TraitCapabilityType::ValueEnum, - )) - } - ResolvedType::Float - | ResolvedType::FrozenStr - | ResolvedType::FrozenBytes - | ResolvedType::Unit - | ResolvedType::Tuple(_) - | ResolvedType::FrozenList(_) - | ResolvedType::FrozenSet(_) - | ResolvedType::FrozenDict(_, _) - | ResolvedType::Function(_, _) - | ResolvedType::SelfType => Some(false), - ResolvedType::Generic(_, _) | ResolvedType::Named(_) => None, - } - } - - /// Return whether a nominal type is a stable scalar value enum category for temporary capability bridges. - fn value_enum_type_satisfies_temporary_trait_capability(&self, type_name: &str) -> bool { - matches!( - self.lookup_semantic_type_info(type_name), - Some(crate::frontend::symbols::TypeInfo::Enum(info)) if info.value_enum.is_some() - ) - } - - fn tuple_type_satisfies_bound(&self, items: &[ResolvedType], bound: &str) -> bool { - match builtin_traits::from_str(bound) { - Some( - TraitId::Clone - | TraitId::Debug - | TraitId::Default - | TraitId::Eq - | TraitId::PartialEq - | TraitId::Ord - | TraitId::PartialOrd - | TraitId::Hash, - ) => items.iter().all(|item| self.type_satisfies_explicit_bound(item, bound)), - _ => false, - } - } - - fn collection_type_satisfies_bound(&self, kind: CollectionTypeId, args: &[ResolvedType], bound: &str) -> bool { - let all_args_satisfy = || args.iter().all(|arg| self.type_satisfies_explicit_bound(arg, bound)); - match builtin_traits::from_str(bound) { - Some(TraitId::Clone | TraitId::Debug) => all_args_satisfy(), - Some(TraitId::Default) => matches!( - kind, - CollectionTypeId::List - | CollectionTypeId::FrozenList - | CollectionTypeId::Dict - | CollectionTypeId::FrozenDict - | CollectionTypeId::Set - | CollectionTypeId::FrozenSet - | CollectionTypeId::Option - ), - Some(TraitId::Eq | TraitId::PartialEq) => all_args_satisfy(), - Some(TraitId::Ord | TraitId::PartialOrd) => { - matches!( - kind, - CollectionTypeId::List - | CollectionTypeId::FrozenList - | CollectionTypeId::Tuple - | CollectionTypeId::Option - ) && all_args_satisfy() - } - Some(TraitId::Hash) => { - matches!( - kind, - CollectionTypeId::List - | CollectionTypeId::FrozenList - | CollectionTypeId::Tuple - | CollectionTypeId::Option - ) && all_args_satisfy() - } - _ => false, - } - } - - /// Return whether `ty` is one of the checked await-realization paths for `Awaitable[T]`. - fn type_satisfies_awaitable_bound(&self, ty: &ResolvedType, expected_output: Option<&ResolvedType>) -> bool { - let Some(output_ty) = self.awaitable_output_type_for_known_type(ty) else { - return false; - }; - expected_output.is_none_or(|expected| { - matches!(output_ty, ResolvedType::Unknown) || self.types_compatible(&output_ty, expected) - }) - } - - /// Resolve the output type for known awaitable carrier types. - fn awaitable_output_type_for_known_type(&self, ty: &ResolvedType) -> Option { - self.await_output_type_from_type(ty) - } - - /// Return whether a named user type explicitly satisfies a generic trait bound. - fn named_type_satisfies_bound(&self, type_name: &str, bound: &str) -> bool { - match self.lookup_type_info(type_name) { - Some(TypeInfo::Builtin) => matches!(builtin_traits::from_str(bound), Some(TraitId::Clone | TraitId::Debug)), - Some(TypeInfo::Model(info)) => { - info.traits.iter().any(|t| t == bound) || info.derives.iter().any(|d| d == bound) - } - Some(TypeInfo::Class(info)) => { - info.traits.iter().any(|t| t == bound) || info.derives.iter().any(|d| d == bound) - } - Some(TypeInfo::Enum(info)) => { - info.traits.iter().any(|t| t == bound) || info.derives.iter().any(|d| d == bound) - } - Some(TypeInfo::Newtype(info)) => info.traits.iter().any(|t| t == bound), - Some(TypeInfo::TypeAlias) => false, - None => false, - } - } } diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index 6a5f31fdc..c7b134e31 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -268,20 +268,22 @@ impl TypeChecker { /// This first tries builtin coercion-matrix matches, then resolved-type compatibility, then rusttype-specific /// boundary adapters. fn rust_arg_boundary_match(&self, arg_ty: &ResolvedType, rust_param_ty: &str) -> RustArgBoundaryMatch { - let normalized = rust_param_ty.replace(' ', ""); + let display = Self::rust_display_without_lifetimes(rust_param_ty); + let normalized = display.replace(' ', ""); if Self::rust_display_type_var_name(normalized.as_str()).is_some() { return RustArgBoundaryMatch::Exact; } - let borrowed_shared = matches!(Self::rust_display_borrow_kind(normalized.as_str()), Some((false, _))); - if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(normalized.as_str()) { - if Self::is_rust_generic_type_param_display(inner) + let borrowed_shared = matches!(Self::rust_display_borrow_kind(display.as_str()), Some((false, _))); + if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(display.as_str()) { + let inner_normalized = Self::compact_rust_display(inner); + if Self::is_rust_generic_type_param_display(inner_normalized.as_str()) && !is_mut && !matches!(arg_ty, ResolvedType::Ref(_) | ResolvedType::RefMut(_)) { return RustArgBoundaryMatch::Exact; } if !is_mut { - let target_inner_ty = self.resolved_type_from_rust_display(inner); + let target_inner_ty = self.resolved_type_from_rust_display(inner_normalized.as_str()); if Self::incan_boundary_type_display(arg_ty).is_none() && self.types_compatible(arg_ty, &target_inner_ty) { @@ -289,13 +291,13 @@ impl TypeChecker { } } if is_mut { - let target_inner_ty = self.resolved_type_from_rust_display(inner); + let target_inner_ty = self.resolved_type_from_rust_display(inner_normalized.as_str()); if self.types_compatible(arg_ty, &target_inner_ty) { return RustArgBoundaryMatch::Exact; } if let Some(incan_display) = Self::incan_boundary_type_display(arg_ty) && let Some(CoercionPolicy::Exact) = - admitted_builtin_coercion(incan_display.as_str(), inner.replace(' ', "").as_str()) + admitted_builtin_coercion(incan_display.as_str(), inner_normalized.as_str()) { return RustArgBoundaryMatch::Exact; } @@ -331,7 +333,9 @@ impl TypeChecker { let params: Vec = params .iter() .map(|param| { - CallableParam::positional(self.resolved_param_type_from_rust_display(param.type_display.as_str())) + CallableParam::positional( + self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()), + ) }) .collect(); // Plain Rust type variables carry by-value shape, but they are not ordinary borrow-boundary snapshots. @@ -409,7 +413,7 @@ impl TypeChecker { for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(params.iter()) { let arg_expr = Self::call_arg_expr(arg); let normalized = param.type_display.replace(' ', ""); - let target_ty = self.resolved_param_type_from_rust_display(param.type_display.as_str()); + let target_ty = self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()); if preserves_lookup_arg_shape && self.rust_lookup_probe_boundary_match(arg_ty, &target_ty) { continue; } @@ -462,7 +466,7 @@ impl TypeChecker { for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(sig.params.iter()) { let arg_expr = Self::call_arg_expr(arg); let normalized = param.type_display.replace(' ', ""); - let target_ty = self.resolved_param_type_from_rust_display(param.type_display.as_str()); + let target_ty = self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()); match self.rust_arg_boundary_match(arg_ty, param.type_display.as_str()) { RustArgBoundaryMatch::Exact => {} RustArgBoundaryMatch::Coercion(kind) => { @@ -837,16 +841,88 @@ mod validate_rust_function_call_tests { .contains_key(&(span.start, span.end)), "expected rust arg coercion metadata for borrowed String boundary" ); - let coercion = checker - .type_info - .rust - .arg_coercions - .get(&(span.start, span.end)) - .expect("coercion metadata should be present"); + let expected = ResolvedType::Ref(Box::new(ResolvedType::RustPath("String".to_string()))); assert_eq!( - coercion.target_type, - ResolvedType::Ref(Box::new(ResolvedType::Str)), - "borrowed Rust params must preserve borrow shape in lowering metadata" + checker + .type_info + .rust + .arg_coercions + .get(&(span.start, span.end)) + .map(|coercion| &coercion.target_type), + Some(&expected), + "borrowed owned Rust params must preserve owned target shape in lowering metadata" + ); + } + + #[test] + fn rust_function_call_accepts_string_for_borrowed_str_param() { + let mut checker = TypeChecker::new(); + let span = Span::new(10, 20); + let arg_expr = Spanned::new(Expr::Literal(Literal::String("{}".to_string())), span); + let args = [CallArg::Positional(arg_expr)]; + let sig = RustFunctionSig { + params: vec![RustParam { + name: Some("value".to_string()), + type_display: "&str".to_string(), + }], + return_type: "()".to_string(), + is_async: false, + is_unsafe: false, + }; + + let _ = checker.validate_rust_function_call("rust::demo::takes_borrowed_str", &sig, &args, span); + + assert!( + checker.errors.is_empty(), + "expected borrowed str boundary to admit Incan str, errors={:?}", + checker.errors + ); + let expected = ResolvedType::Ref(Box::new(ResolvedType::Str)); + assert_eq!( + checker + .type_info + .rust + .arg_coercions + .get(&(span.start, span.end)) + .map(|coercion| &coercion.target_type), + Some(&expected), + "borrowed str params must stay distinct from borrowed owned String params" + ); + } + + #[test] + fn rust_function_call_accepts_bytes_for_borrowed_vec_param() { + let mut checker = TypeChecker::new(); + let span = Span::new(10, 20); + let arg_expr = Spanned::new(Expr::Literal(Literal::Bytes(b"abc".to_vec())), span); + let args = [CallArg::Positional(arg_expr)]; + let sig = RustFunctionSig { + params: vec![RustParam { + name: Some("value".to_string()), + type_display: "&Vec".to_string(), + }], + return_type: "()".to_string(), + is_async: false, + is_unsafe: false, + }; + + let _ = checker.validate_rust_function_call("rust::demo::takes_borrowed_vec", &sig, &args, span); + + assert!( + checker.errors.is_empty(), + "expected borrowed Vec boundary to admit Incan bytes, errors={:?}", + checker.errors + ); + let expected = ResolvedType::Ref(Box::new(ResolvedType::RustPath("Vec".to_string()))); + assert_eq!( + checker + .type_info + .rust + .arg_coercions + .get(&(span.start, span.end)) + .map(|coercion| &coercion.target_type), + Some(&expected), + "borrowed owned Rust byte-vector params must preserve owned target shape in lowering metadata" ); } diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index b25c3ca88..12871e9d2 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -19,7 +19,7 @@ use crate::library_manifest::{ PartialExport, ReceiverExport, StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, TypeParamExport, resolved_type_from_manifest_type_ref, }; -use incan_core::interop::{RustItemKind, RustTraitAssoc, is_rust_capability_bound}; +use incan_core::interop::{RustItemKind, RustTraitAssoc, fallback_rust_trait_methods, is_rust_capability_bound}; use incan_core::lang::stdlib::{self, is_typechecker_only_stdlib}; use incan_core::lang::surface::types as surface_types; use incan_semantics_core::{DecoratorFeature, SurfaceFeatureKey}; @@ -1604,7 +1604,7 @@ impl TypeChecker { } if trait_methods.is_empty() { trait_methods.extend( - Self::known_rust_trait_methods(info.path.as_str()) + fallback_rust_trait_methods(info.path.as_str()) .iter() .map(|method| (*method).to_string()), ); @@ -1626,71 +1626,6 @@ impl TypeChecker { self.define_rust_import_symbol(name, info, span); } - /// Return fallback trait method names for Rust traits when rustdoc metadata is unavailable. - fn known_rust_trait_methods(path: &str) -> &'static [&'static str] { - match path { - "std::io::Read" => &[ - "read", - "read_to_end", - "read_to_string", - "read_exact", - "read_buf", - "read_buf_exact", - "bytes", - "chain", - "take", - ], - "std::io::Write" => &["write", "write_all", "write_fmt", "flush"], - "std::io::Seek" => &["seek", "rewind", "stream_position", "seek_relative"], - "byteorder::ReadBytesExt" => &[ - "read_u8", - "read_i8", - "read_u16", - "read_i16", - "read_u32", - "read_i32", - "read_u64", - "read_i64", - "read_u128", - "read_i128", - "read_f32", - "read_f64", - ], - "byteorder::WriteBytesExt" => &[ - "write_u8", - "write_i8", - "write_u16", - "write_i16", - "write_u32", - "write_i32", - "write_u64", - "write_i64", - "write_u128", - "write_i128", - "write_f32", - "write_f64", - ], - "sha2::Digest" | "sha3::Digest" | "blake2::Digest" | "md5::Digest" | "sha1::Digest" => &[ - "new", - "new_with_prefix", - "update", - "chain_update", - "finalize", - "finalize_into", - "finalize_reset", - "reset", - "output_size", - "digest", - ], - "blake2::digest::XofReader" | "sha3::digest::XofReader" => &["read"], - "std::os::unix::fs::MetadataExt" => &[ - "dev", "ino", "mode", "nlink", "uid", "gid", "rdev", "size", "atime", "mtime", "ctime", "blksize", - "blocks", - ], - _ => &[], - } - } - /// Define a symbol for a Rust crate import. /// /// Explicit Rust imports must be allowed to shadow dependency-exported Incan types with the same simple name. This diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 3903115d1..051aaa0ae 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -49,6 +49,7 @@ mod collect; mod const_eval; mod helpers; pub(crate) mod stdlib_loader; +mod trait_bound_relations; mod type_info; mod validate_rust_module; @@ -76,8 +77,11 @@ use crate::frontend::surface_semantics::SurfaceContext; use crate::frontend::symbols::*; #[cfg(feature = "rust_inspect")] use crate::rust_inspect::RustMetadataCache; -use helpers::{collection_type_id, render_resolved_type_as_rust_arg, stringlike_type_id}; -use incan_core::interop::{RustFunctionSig, RustItemKind, RustItemMetadata, RustParam, RustTypeShape}; +use helpers::{collection_name, collection_type_id, render_resolved_type_as_rust_arg, stringlike_type_id}; +use incan_core::interop::{ + RustFunctionSig, RustItemKind, RustItemMetadata, RustParam, RustTypeShape, render_rust_type_shape_path, + split_top_level_rust_args, strip_rust_borrow_lifetimes, +}; use incan_core::lang::conventions; use incan_core::lang::decorators::{self as core_decorators, DecoratorId}; use incan_core::lang::surface::types as surface_types; @@ -477,25 +481,7 @@ impl TypeChecker { } fn split_top_level_generic_args(args: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut depth = 0usize; - let mut start = 0usize; - for (idx, ch) in args.char_indices() { - match ch { - '<' | '(' | '[' => depth += 1, - '>' | ')' | ']' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - parts.push(args[start..idx].trim()); - start = idx + ch.len_utf8(); - } - _ => {} - } - } - let tail = args[start..].trim(); - if !tail.is_empty() { - parts.push(tail); - } - parts + split_top_level_rust_args(args) } /// Normalize a rust-inspect lookup path down to the nominal item path. @@ -740,53 +726,21 @@ impl TypeChecker { /// /// When `args` is empty, returns `path` unchanged (no angle brackets). fn render_rust_shape_path(path: &str, args: &[RustTypeShape]) -> String { - if args.is_empty() { - return path.to_string(); - } - let rendered_args: Vec = args.iter().map(Self::render_rust_shape_type).collect(); - format!("{path}<{}>", rendered_args.join(", ")) - } - - /// Pretty-print a [`RustTypeShape`] as a stable Rust-like type string. - /// - /// Feeds [`ResolvedType::RustPath`] strings. Scalar widths are normalized (`f64`, `i64`, `String`, `Vec`) to - /// match [`Self::resolved_type_from_rust_shape`], not to recover the exact original Rust spelling from metadata. - fn render_rust_shape_type(shape: &RustTypeShape) -> String { - match shape { - RustTypeShape::Bool => "bool".to_string(), - RustTypeShape::Float => "f64".to_string(), - RustTypeShape::Int => "i64".to_string(), - RustTypeShape::Str => "String".to_string(), - RustTypeShape::Bytes => "Vec".to_string(), - RustTypeShape::Unit => "()".to_string(), - RustTypeShape::Option(inner) => format!("Option<{}>", Self::render_rust_shape_type(inner)), - RustTypeShape::Result(ok, err) => { - format!( - "Result<{}, {}>", - Self::render_rust_shape_type(ok), - Self::render_rust_shape_type(err) - ) - } - RustTypeShape::Tuple(items) => { - let rendered: Vec = items.iter().map(Self::render_rust_shape_type).collect(); - format!("({})", rendered.join(", ")) - } - RustTypeShape::Ref(inner) => format!("&{}", Self::render_rust_shape_type(inner)), - RustTypeShape::RustPath { path, args } => Self::render_rust_shape_path(path, args), - RustTypeShape::TypeParam(name) => name.clone(), - RustTypeShape::Unknown => "?".to_string(), - } + render_rust_type_shape_path(path, args) } - /// Detect whether a normalized Rust display type starts with `&T` or `&mut T`. + /// Detect whether a Rust display type starts with `&T` or `&mut T`. /// /// Returns the mutability flag plus the remaining inner type spelling so [`Self::resolved_type_from_rust_display`] /// can preserve borrow semantics for Rust-backed values instead of collapsing them into plain path types. - fn rust_display_borrow_kind(normalized: &str) -> Option<(bool, &str)> { - if let Some(inner) = normalized.strip_prefix("&mut") { - return Some((true, inner)); + fn rust_display_borrow_kind(display: &str) -> Option<(bool, &str)> { + let after_amp = display.trim().strip_prefix('&')?.trim_start(); + if let Some(inner) = after_amp.strip_prefix("mut") + && inner.chars().next().is_none_or(char::is_whitespace) + { + return Some((true, inner.trim_start())); } - normalized.strip_prefix('&').map(|inner| (false, inner)) + Some((false, after_amp)) } /// Remove Rust lifetime labels that decorate borrowed display types. @@ -795,28 +749,84 @@ impl TypeChecker { /// the ownership shape and payload type, so erase the lifetime after `&` before whitespace normalization turns it /// into an unparseable token such as `&'hstr`. fn strip_borrow_lifetimes(rust_ty: &str) -> String { - let mut out = String::with_capacity(rust_ty.len()); - let mut chars = rust_ty.chars().peekable(); - while let Some(ch) = chars.next() { - out.push(ch); - if ch != '&' { - continue; - } - while matches!(chars.peek(), Some(next) if next.is_whitespace()) { - out.push(chars.next().expect("peeked whitespace should exist")); - } - if !matches!(chars.peek(), Some('\'')) { - continue; - } - chars.next(); - while matches!(chars.peek(), Some(next) if next.is_ascii_alphanumeric() || *next == '_') { - chars.next(); - } - while matches!(chars.peek(), Some(next) if next.is_whitespace()) { - chars.next(); + strip_rust_borrow_lifetimes(rust_ty) + } + + fn rust_display_without_lifetimes(rust_ty: &str) -> String { + Self::strip_borrow_lifetimes(rust_ty) + .replace("'static ", "") + .replace("'_", "") + .trim_start_matches("::") + .to_string() + } + + fn compact_rust_display(rust_ty: &str) -> String { + Self::rust_display_without_lifetimes(rust_ty).replace(' ', "") + } + + fn rust_generic_base_and_args(normalized: &str) -> Option<(&str, Vec<&str>)> { + let start = normalized.find('<')?; + if !normalized.ends_with('>') { + return None; + } + let base = normalized[..start].trim(); + let inner = &normalized[start + 1..normalized.len() - 1]; + Some((base, Self::split_top_level_generic_args(inner))) + } + + fn rust_collection_id_from_display_base(base: &str) -> Option { + incan_core::lang::types::collections::from_rust_display_base(base) + } + + fn resolved_structural_rust_param_display(&self, normalized: &str, mut resolve_arg: F) -> Option + where + F: FnMut(&Self, &str) -> ResolvedType, + { + let (base, arg_displays) = Self::rust_generic_base_and_args(normalized)?; + let collection_id = Self::rust_collection_id_from_display_base(base)?; + let mut args = arg_displays + .into_iter() + .map(|arg| resolve_arg(self, arg)) + .collect::>(); + match collection_id { + CollectionTypeId::List if args.len() == 1 => Some(ResolvedType::Generic( + collection_name(CollectionTypeId::List).to_string(), + args, + )), + CollectionTypeId::Dict if args.len() == 2 => Some(ResolvedType::Generic( + collection_name(CollectionTypeId::Dict).to_string(), + args, + )), + CollectionTypeId::Set if args.len() == 1 => Some(ResolvedType::Generic( + collection_name(CollectionTypeId::Set).to_string(), + args, + )), + CollectionTypeId::Option if args.len() == 1 => Some(ResolvedType::Generic( + collection_name(CollectionTypeId::Option).to_string(), + args, + )), + CollectionTypeId::Result if args.len() <= 2 => { + let ok = args.first().cloned().unwrap_or(ResolvedType::Unknown); + let err = args.get(1).cloned().unwrap_or(ResolvedType::Unknown); + Some(ResolvedType::Generic( + collection_name(CollectionTypeId::Result).to_string(), + vec![ok, err], + )) } + CollectionTypeId::Tuple => Some(ResolvedType::Tuple(args)), + CollectionTypeId::FrozenList if args.len() == 1 => Some(ResolvedType::FrozenList(Box::new(args.remove(0)))), + CollectionTypeId::FrozenSet if args.len() == 1 => Some(ResolvedType::FrozenSet(Box::new(args.remove(0)))), + CollectionTypeId::FrozenDict if args.len() == 2 => { + let value = args.pop().unwrap_or(ResolvedType::Unknown); + let key = args.pop().unwrap_or(ResolvedType::Unknown); + Some(ResolvedType::FrozenDict(Box::new(key), Box::new(value))) + } + CollectionTypeId::Generator if args.len() == 1 => Some(ResolvedType::Generic( + collection_name(CollectionTypeId::Generator).to_string(), + args, + )), + _ => None, } - out } /// Map structured rust-inspect [`RustTypeShape`] into a [`ResolvedType`] for field access and pattern typing. @@ -885,14 +895,12 @@ impl TypeChecker { /// /// ## `Result` parsing /// - /// `Result<…>` is split on the **first** top-level comma only. Nested generics that contain commas (for example - /// `Result, String>`) are therefore parsed incorrectly and may degrade to [`ResolvedType::Unknown`] - /// for one or both type arguments. Prefer precise typing from Incan surfaces over relying on this heuristic. + /// `Result<…>` uses top-level generic splitting, so nested generic or tuple commas stay inside the appropriate + /// argument. Prefer precise typing from Incan surfaces over relying on this heuristic for arbitrary Rust paths. pub(crate) fn resolved_type_from_rust_display(&self, rust_ty: &str) -> ResolvedType { let trimmed = rust_ty.trim(); - let no_lifetimes = Self::strip_borrow_lifetimes(trimmed); - let no_lifetimes = no_lifetimes.replace("'static ", "").replace("'_", "").replace(' ', ""); - let normalized = no_lifetimes.trim_start_matches("::").to_string(); + let display = Self::rust_display_without_lifetimes(trimmed); + let normalized = display.replace(' ', ""); if let Some(Symbol { kind: SymbolKind::RustItem(info), .. @@ -906,7 +914,7 @@ impl TypeChecker { "&[u8]" => return ResolvedType::Bytes, _ => {} } - if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(normalized.as_str()) { + if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(display.as_str()) { let inner_ty = self.resolved_type_from_rust_display(inner); return if is_mut { ResolvedType::RefMut(Box::new(inner_ty)) @@ -935,31 +943,32 @@ impl TypeChecker { "Vec" | "std::vec::Vec" | "alloc::vec::Vec" | "&[u8]" => ResolvedType::Bytes, "()" => ResolvedType::Unit, _ if normalized.ends_with('>') => { - if let Some((base, inner)) = normalized.split_once('<') { - let base = base.trim_end_matches('>'); - let inner = inner.trim_end_matches('>'); + if let Some((base, args)) = Self::rust_generic_base_and_args(normalized.as_str()) { let tail = base.rsplit("::").next().unwrap_or(base); match collection_type_id(tail) { Some(CollectionTypeId::Option) => { + let inner = args.first().copied().unwrap_or(""); return ResolvedType::Generic( - "Option".to_string(), + collection_name(CollectionTypeId::Option).to_string(), vec![self.resolved_type_from_rust_display(inner)], ); } Some(CollectionTypeId::Result) => { - let mut parts = inner.splitn(2, ','); - let ok_ty = parts - .next() + let ok_ty = args + .first() .map(|p| self.resolved_type_from_rust_display(p)) .unwrap_or(ResolvedType::Unknown); // Result aliases such as `datafusion_common::error::Result` often erase the concrete // error arm from the display. Keep the success path semantic and degrade only the missing // error arm. - let err_ty = parts - .next() + let err_ty = args + .get(1) .map(|p| self.resolved_type_from_rust_display(p)) .unwrap_or(ResolvedType::Unknown); - return ResolvedType::Generic("Result".to_string(), vec![ok_ty, err_ty]); + return ResolvedType::Generic( + collection_name(CollectionTypeId::Result).to_string(), + vec![ok_ty, err_ty], + ); } _ => {} } @@ -1007,17 +1016,17 @@ impl TypeChecker { /// borrowed Rust boundary so emission can pass `&arg` instead of moving an owned `String` or `Vec`. pub(crate) fn resolved_param_type_from_rust_display(&self, rust_ty: &str) -> ResolvedType { let trimmed = rust_ty.trim(); - let no_lifetimes = Self::strip_borrow_lifetimes(trimmed); - let no_lifetimes = no_lifetimes.replace("'static ", "").replace("'_", "").replace(' ', ""); - let normalized = no_lifetimes.trim_start_matches("::").to_string(); + let display = Self::rust_display_without_lifetimes(trimmed); + let normalized = display.replace(' ', ""); if let Some(name) = Self::rust_display_type_var_name(normalized.as_str()) { return ResolvedType::TypeVar(name.to_string()); } - if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(normalized.as_str()) { + if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(display.as_str()) { + let inner_normalized = Self::compact_rust_display(inner); let inner_ty = match inner { "str" => ResolvedType::Str, "[u8]" => ResolvedType::Bytes, - _ => self.resolved_type_from_rust_display(inner), + _ => self.resolved_type_from_rust_display(inner_normalized.as_str()), }; return if is_mut { ResolvedType::RefMut(Box::new(inner_ty)) @@ -1025,9 +1034,47 @@ impl TypeChecker { ResolvedType::Ref(Box::new(inner_ty)) }; } + if let Some(structural) = self.resolved_structural_rust_param_display(normalized.as_str(), |checker, arg| { + checker.resolved_param_type_from_rust_display(arg) + }) { + return structural; + } self.resolved_type_from_rust_display(normalized.as_str()) } + /// Convert a Rust parameter display type into the typed target carried by Rust-boundary coercion metadata. + /// + /// This preserves the semantic difference between slice borrow targets such as `&str`/`&[u8]` and borrowed owned + /// Rust targets such as `&String`/`&Vec`. Ordinary parameter typing still maps Rust scalar displays onto Incan + /// value types, but coercion metadata is a backend contract: lowering and emission must be able to choose borrow + /// versus materialize-then-borrow behavior from this typed target without re-decoding Rust display strings. + pub(crate) fn resolved_rust_boundary_target_from_param_display(&self, rust_ty: &str) -> ResolvedType { + let trimmed = rust_ty.trim(); + let display = Self::rust_display_without_lifetimes(trimmed); + let normalized = display.replace(' ', ""); + if let Some((is_mut, inner)) = Self::rust_display_borrow_kind(display.as_str()) { + let inner_normalized = Self::compact_rust_display(inner); + let inner_ty = match inner { + "str" => ResolvedType::Str, + "[u8]" => ResolvedType::Bytes, + "String" | "std::string::String" | "alloc::string::String" => ResolvedType::RustPath(inner.to_string()), + "Vec" | "std::vec::Vec" | "alloc::vec::Vec" => ResolvedType::RustPath(inner.to_string()), + _ => self.resolved_type_from_rust_display(inner_normalized.as_str()), + }; + return if is_mut { + ResolvedType::RefMut(Box::new(inner_ty)) + } else { + ResolvedType::Ref(Box::new(inner_ty)) + }; + } + if let Some(structural) = self.resolved_structural_rust_param_display(normalized.as_str(), |checker, arg| { + checker.resolved_rust_boundary_target_from_param_display(arg) + }) { + return structural; + } + self.resolved_param_type_from_rust_display(normalized.as_str()) + } + /// Set the declared Rust crate names from `incan.toml [rust-dependencies]`. /// /// When set, `rust.module()` path validation will check that the first segment of the path is either `incan_stdlib` diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index edc8a0cdc..c1904035c 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -2604,6 +2604,49 @@ fn test_resolved_param_type_from_builtin_borrowed_displays_preserves_ref_payload ); } +#[test] +fn test_resolved_param_type_from_structural_borrowed_display_preserves_nested_ref_payload() { + let checker = TypeChecker::new(); + assert_eq!( + checker.resolved_param_type_from_rust_display("Vec<&str>"), + ResolvedType::Generic("List".to_string(), vec![ResolvedType::Ref(Box::new(ResolvedType::Str))]), + ); + assert_eq!( + checker.resolved_rust_boundary_target_from_param_display("Vec<&String>"), + ResolvedType::Generic( + "List".to_string(), + vec![ResolvedType::Ref(Box::new(ResolvedType::RustPath( + "String".to_string() + )))] + ), + ); +} + +#[test] +fn test_resolved_param_type_does_not_treat_mut_prefix_as_mutable_borrow_keyword() { + let checker = TypeChecker::new(); + assert_eq!( + checker.resolved_param_type_from_rust_display("&mutability::Foo"), + ResolvedType::Ref(Box::new(ResolvedType::RustPath("mutability::Foo".to_string()))), + ); + assert_eq!( + checker.resolved_param_type_from_rust_display("&mut mutability::Foo"), + ResolvedType::RefMut(Box::new(ResolvedType::RustPath("mutability::Foo".to_string()))), + ); +} + +#[test] +fn test_resolved_result_display_splits_only_top_level_generic_commas() { + let checker = TypeChecker::new(); + assert_eq!( + checker.resolved_type_from_rust_display("Result, String>"), + ResolvedType::Generic( + "Result".to_string(), + vec![ResolvedType::RustPath("Vec<(i32,i32)>".to_string()), ResolvedType::Str,], + ), + ); +} + #[test] fn test_types_compatible_refmut_is_assignable_to_ref_but_not_reverse() { let checker = TypeChecker::new(); @@ -11829,6 +11872,29 @@ def main(result: Result[int, str]) -> None: check_str(source) } +#[test] +fn test_result_unwrap_helpers_typecheck() -> Result<(), Vec> { + let source = r#" +def direct(result: Result[int, str]) -> int: + return result.unwrap() + +def fallback(result: Result[int, str]) -> int: + return result.unwrap_or(0) +"#; + + check_str(source) +} + +#[test] +fn test_option_copied_accepts_generic_reference_payloads() -> Result<(), Vec> { + let source = r#" +def copy_placeholder[T](value: Option[&T]) -> Option[T]: + return value.copied() +"#; + + check_str(source) +} + #[test] fn test_rfc070_result_combinators_reject_bad_callbacks() { let source = r#" diff --git a/src/frontend/typechecker/trait_bound_relations.rs b/src/frontend/typechecker/trait_bound_relations.rs new file mode 100644 index 000000000..eddfd8f8b --- /dev/null +++ b/src/frontend/typechecker/trait_bound_relations.rs @@ -0,0 +1,659 @@ +//! Trait-bound satisfaction and temporary capability bridges. + +use std::collections::HashMap; + +use super::TypeChecker; +use crate::frontend::resolved_type_subst::substitute_resolved_type; +use crate::frontend::symbols::{ResolvedType, TypeBoundInfo, TypeInfo}; +use crate::frontend::typechecker::helpers::collection_type_id; +use incan_core::interop::is_rust_capability_bound; +use incan_core::lang::derives::{self, DeriveId}; +use incan_core::lang::trait_capabilities::{self, TraitCapabilityInfo, TraitCapabilityType}; +use incan_core::lang::traits::{self as builtin_traits, TraitId}; +use incan_core::lang::types::collections::CollectionTypeId; +use incan_core::lang::types::numerics; + +impl TypeChecker { + /// Render a type-parameter bound with call-site substitutions applied. + pub(in crate::frontend::typechecker) fn type_bound_display( + &self, + bound: &TypeBoundInfo, + bindings: &HashMap, + ) -> String { + if bound.type_args.is_empty() { + return bound.name.clone(); + } + let args = bound + .type_args + .iter() + .map(|arg| substitute_resolved_type(arg, bindings).to_string()) + .collect::>() + .join(", "); + format!("{}[{}]", bound.name, args) + } + + /// Return whether a type satisfies one explicit bound, including generic trait arguments. + pub(crate) fn type_satisfies_explicit_bound_info( + &self, + ty: &ResolvedType, + bound: &TypeBoundInfo, + bindings: &HashMap, + ) -> bool { + if let Some(placeholder_name) = self.active_type_param_name(ty) + && self.active_type_param_satisfies_bound_info(placeholder_name, bound, bindings) + { + return true; + } + if bound.name == builtin_traits::as_str(TraitId::Awaitable) { + let expected_output = bound + .type_args + .first() + .map(|arg| substitute_resolved_type(arg, bindings)); + return self.type_satisfies_awaitable_bound(ty, expected_output.as_ref()); + } + if let Some(capability) = self.temporary_trait_capability_for_bound_info(bound) + && let Some(satisfies) = self.temporary_trait_capability_supports_type(capability, ty) + { + return satisfies; + } + if bound.type_args.is_empty() { + return self.type_satisfies_explicit_bound(ty, &bound.name); + } + if is_rust_capability_bound(&bound.name) { + return true; + } + if builtin_traits::from_str(&bound.name).is_some() || self.lookup_semantic_trait_info(&bound.name).is_none() { + return self.type_satisfies_explicit_bound(ty, &bound.name); + } + let expected_args = bound + .type_args + .iter() + .map(|arg| substitute_resolved_type(arg, bindings)) + .collect::>(); + self.type_satisfies_nominal_trait_bound_with_args(ty, &bound.name, &expected_args) + } + + /// Best-effort check whether a concrete type satisfies an explicit generic bound. + pub(in crate::frontend::typechecker) fn type_satisfies_explicit_bound( + &self, + ty: &ResolvedType, + bound: &str, + ) -> bool { + if bound == builtin_traits::as_str(TraitId::Awaitable) { + return self.type_satisfies_awaitable_bound(ty, None); + } + if is_rust_capability_bound(bound) { + return true; + } + if let Some(capability) = self.temporary_trait_capability_for_bound(bound) + && let Some(satisfies) = self.temporary_trait_capability_supports_type(capability, ty) + { + return satisfies; + } + if builtin_traits::from_str(bound).is_none() && self.lookup_semantic_trait_info(bound).is_some() { + return self.type_satisfies_nominal_trait_bound(ty, bound); + } + match ty { + ResolvedType::Unknown + | ResolvedType::TypeVar(_) + | ResolvedType::RustPath(_) + | ResolvedType::CallSiteInfer => true, + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Numeric(_) + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit => self.primitive_type_satisfies_bound(ty, bound), + ResolvedType::Tuple(items) => self.tuple_type_satisfies_bound(items, bound), + ResolvedType::FrozenList(inner) => self.collection_type_satisfies_bound( + CollectionTypeId::FrozenList, + std::slice::from_ref(inner.as_ref()), + bound, + ), + ResolvedType::FrozenSet(inner) => self.collection_type_satisfies_bound( + CollectionTypeId::FrozenSet, + std::slice::from_ref(inner.as_ref()), + bound, + ), + ResolvedType::FrozenDict(k, v) => { + let pair = [k.as_ref().clone(), v.as_ref().clone()]; + self.collection_type_satisfies_bound(CollectionTypeId::FrozenDict, &pair, bound) + } + ResolvedType::Generic(name, args) => { + if let Some(kind) = collection_type_id(name.as_str()) { + self.collection_type_satisfies_bound(kind, args, bound) + } else { + self.named_type_satisfies_bound(name, bound) + } + } + ResolvedType::Named(type_name) => self.named_type_satisfies_bound(type_name, bound), + ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => self.type_satisfies_explicit_bound(inner, bound), + ResolvedType::Function(_, _) | ResolvedType::SelfType => false, + } + } + + /// Return the active generic placeholder name represented by `ty`. + fn active_type_param_name<'a>(&self, ty: &'a ResolvedType) -> Option<&'a str> { + let name = match ty { + ResolvedType::TypeVar(name) | ResolvedType::Named(name) => name, + _ => return None, + }; + self.current_type_param_bound_details + .iter() + .rev() + .any(|frame| frame.contains_key(name)) + .then_some(name.as_str()) + } + + /// Check whether an active generic placeholder already carries the bound required by a nested generic call. + fn active_type_param_satisfies_bound_info( + &self, + placeholder_name: &str, + required: &TypeBoundInfo, + bindings: &HashMap, + ) -> bool { + for frame in self.current_type_param_bound_details.iter().rev() { + let Some(active_bounds) = frame.get(placeholder_name) else { + continue; + }; + for active in active_bounds { + if !Self::type_bound_names_match(active, required) { + continue; + } + if required.type_args.is_empty() { + return true; + } + if active.type_args.len() != required.type_args.len() { + continue; + } + let expected = required + .type_args + .iter() + .map(|arg| substitute_resolved_type(arg, bindings)); + let actual = active + .type_args + .iter() + .map(|arg| substitute_resolved_type(arg, bindings)); + if expected + .zip(actual) + .all(|(left, right)| self.types_compatible(&left, &right)) + { + return true; + } + } + return false; + } + false + } + + /// Return the resolved source trait item name for a bound, falling back to the visible spelling. + fn type_bound_source_name(bound: &TypeBoundInfo) -> &str { + bound + .source_name + .as_deref() + .unwrap_or_else(|| bound.name.rsplit('.').next().unwrap_or(bound.name.as_str())) + } + + /// Return whether two bound records identify the same trait, accounting for import aliases. + fn type_bound_names_match(left: &TypeBoundInfo, right: &TypeBoundInfo) -> bool { + if left.name == right.name { + return true; + } + left.module_path == right.module_path + && left.module_path.is_some() + && Self::type_bound_source_name(left) == Self::type_bound_source_name(right) + } + + /// Check whether `ty` satisfies a nominal trait bound `bound_trait` under RFC 042 semantics. + fn type_satisfies_nominal_trait_bound(&self, ty: &ResolvedType, bound_trait: &str) -> bool { + match ty { + ResolvedType::Unknown + | ResolvedType::TypeVar(_) + | ResolvedType::RustPath(_) + | ResolvedType::CallSiteInfer => true, + ResolvedType::Named(type_name) => { + if self.lookup_semantic_trait_info(type_name).is_some() { + self.trait_is_supertrait_of(type_name, bound_trait) + } else { + self.type_implements_trait(type_name, bound_trait) + } + } + ResolvedType::Generic(type_name, _args) => { + if self.lookup_semantic_trait_info(type_name).is_some() { + self.trait_is_supertrait_of(type_name, bound_trait) + } else if self.lookup_semantic_type_info(type_name).is_some() { + self.type_implements_trait(type_name, bound_trait) + } else { + false + } + } + ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => { + self.type_satisfies_nominal_trait_bound(inner, bound_trait) + } + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Numeric(_) + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + | ResolvedType::Tuple(_) + | ResolvedType::FrozenList(_) + | ResolvedType::FrozenSet(_) + | ResolvedType::FrozenDict(_, _) + | ResolvedType::Function(_, _) + | ResolvedType::SelfType => false, + } + } + + /// Return whether a nominal type satisfies a trait bound with exact expected trait arguments. + fn type_satisfies_nominal_trait_bound_with_args( + &self, + ty: &ResolvedType, + bound_trait: &str, + expected_args: &[ResolvedType], + ) -> bool { + match ty { + ResolvedType::Unknown + | ResolvedType::TypeVar(_) + | ResolvedType::RustPath(_) + | ResolvedType::CallSiteInfer => true, + ResolvedType::Named(type_name) => { + self.type_implements_trait_with_args(type_name, &[], bound_trait, expected_args) + } + ResolvedType::Generic(type_name, type_args) => { + self.type_implements_trait_with_args(type_name, type_args, bound_trait, expected_args) + } + ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => { + self.type_satisfies_nominal_trait_bound_with_args(inner, bound_trait, expected_args) + } + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Numeric(_) + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + | ResolvedType::Tuple(_) + | ResolvedType::FrozenList(_) + | ResolvedType::FrozenSet(_) + | ResolvedType::FrozenDict(_, _) + | ResolvedType::Function(_, _) + | ResolvedType::SelfType => false, + } + } + + /// Check a concrete model/class adoption list for a matching generic trait instantiation. + fn type_implements_trait_with_args( + &self, + type_name: &str, + concrete_type_args: &[ResolvedType], + bound_trait: &str, + expected_args: &[ResolvedType], + ) -> bool { + let Some(info) = self.lookup_semantic_type_info(type_name) else { + return false; + }; + let (owner_type_params, adoptions, derives) = match info { + TypeInfo::Model(model) => ( + model.type_params.as_slice(), + model.trait_adoptions.as_slice(), + Some(model.derives.as_slice()), + ), + TypeInfo::Class(class) => ( + class.type_params.as_slice(), + class.trait_adoptions.as_slice(), + Some(class.derives.as_slice()), + ), + TypeInfo::Enum(en) => ( + en.type_params.as_slice(), + en.trait_adoptions.as_slice(), + Some(en.derives.as_slice()), + ), + TypeInfo::Newtype(newtype) => (newtype.type_params.as_slice(), newtype.trait_adoptions.as_slice(), None), + TypeInfo::Builtin | TypeInfo::TypeAlias => return false, + }; + + if expected_args.is_empty() + && derives.is_some_and(|items| items.iter().any(|derive| derive == bound_trait)) + && self.lookup_semantic_trait_info(bound_trait).is_some() + { + return true; + } + + let owner_subst = + crate::frontend::resolved_type_subst::type_param_subst_map(owner_type_params, concrete_type_args); + for adoption in adoptions { + let Some(adopted_info) = self.lookup_semantic_trait_info(&adoption.name) else { + continue; + }; + let direct_args = if adoption.type_args.is_empty() { + concrete_type_args + .iter() + .take(adopted_info.type_params.len()) + .cloned() + .collect::>() + } else { + adoption + .type_args + .iter() + .map(|arg| substitute_resolved_type(arg, &owner_subst)) + .collect::>() + }; + if direct_args.len() != adopted_info.type_params.len() { + continue; + } + if self.trait_name_matches(&adoption.name, bound_trait) + && self.trait_args_match(&direct_args, expected_args) + { + return true; + } + + let subst = + crate::frontend::resolved_type_subst::type_param_subst_map(&adopted_info.type_params, &direct_args); + for (supertrait_name, supertrait_args) in self.semantic_supertrait_closure(&adoption.name) { + if !self.trait_name_matches(&supertrait_name, bound_trait) { + continue; + } + let instantiated = supertrait_args + .iter() + .map(|arg| substitute_resolved_type(arg, &subst)) + .collect::>(); + if self.trait_args_match(&instantiated, expected_args) { + return true; + } + } + } + false + } + + /// Compare instantiated trait arguments using the typechecker's compatibility relation. + fn trait_args_match(&self, actual_args: &[ResolvedType], expected_args: &[ResolvedType]) -> bool { + actual_args.len() == expected_args.len() + && actual_args + .iter() + .zip(expected_args.iter()) + .all(|(actual, expected)| self.types_compatible(actual, expected)) + } + + /// Return whether a primitive type satisfies a builtin or registry-backed temporary capability bound. + fn primitive_type_satisfies_bound(&self, ty: &ResolvedType, bound: &str) -> bool { + if bound == derives::as_str(DeriveId::Copy) { + return self.is_copy_type(ty); + } + if let Some(capability) = self.temporary_trait_capability_for_bound(bound) + && let Some(satisfies) = self.temporary_trait_capability_supports_type(capability, ty) + { + return satisfies; + } + + match builtin_traits::from_str(bound) { + Some(TraitId::Clone | TraitId::Debug | TraitId::Display) => matches!( + ty, + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + ), + Some(TraitId::Default) => matches!( + ty, + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + ), + Some(TraitId::Awaitable) => self.type_satisfies_awaitable_bound(ty, None), + Some(TraitId::Eq | TraitId::Ord | TraitId::Hash) => matches!( + ty, + ResolvedType::Int + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + ), + Some(TraitId::PartialEq | TraitId::PartialOrd) => matches!( + ty, + ResolvedType::Int + | ResolvedType::Float + | ResolvedType::Bool + | ResolvedType::Str + | ResolvedType::Bytes + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + ), + _ => false, + } + } + + /// Resolve a temporary trait-owned capability bridge for a bound. + fn temporary_trait_capability_for_bound(&self, bound: &str) -> Option<&'static TraitCapabilityInfo> { + let (module_path, trait_name) = self.resolve_bound_trait_path(bound)?; + let capability = trait_capabilities::for_trait_path(&module_path, &trait_name)?; + self.validated_temporary_trait_capability(capability, bound, None, None) + } + + /// Resolve a temporary capability bridge from a checked bound that may have crossed a package manifest boundary. + fn temporary_trait_capability_for_bound_info(&self, bound: &TypeBoundInfo) -> Option<&'static TraitCapabilityInfo> { + if let Some(module_path) = &bound.module_path { + let trait_name = Self::type_bound_source_name(bound); + let capability = trait_capabilities::for_trait_path(module_path, trait_name)?; + return self.validated_temporary_trait_capability( + capability, + &bound.name, + bound.source_name.as_deref(), + Some(module_path), + ); + } + self.temporary_trait_capability_for_bound(&bound.name) + } + + /// Validate that a temporary capability bridge points at a real trait with the required semantic surface. + fn validated_temporary_trait_capability( + &self, + capability: &'static TraitCapabilityInfo, + visible_bound: &str, + source_name: Option<&str>, + module_path: Option<&[String]>, + ) -> Option<&'static TraitCapabilityInfo> { + let info = self + .lookup_semantic_trait_info(visible_bound) + .or_else(|| source_name.and_then(|name| self.lookup_semantic_trait_info(name))) + .or_else(|| self.lookup_semantic_trait_info(capability.trait_name)); + if let Some(info) = info + && capability + .required_methods + .iter() + .all(|method| info.methods.contains_key(*method)) + { + return Some(capability); + } + let manifest_bound_identifies_capability = source_name == Some(capability.trait_name) + && module_path.is_some_and(|path| trait_capabilities::module_path_matches(capability, path)); + manifest_bound_identifies_capability.then_some(capability) + } + + /// Resolve a bound spelling to its defining module path and trait name. + fn resolve_bound_trait_path(&self, bound: &str) -> Option<(Vec, String)> { + if let Some(path) = self.import_aliases.get(bound) + && path.len() >= 2 + { + let trait_name = path.last()?.clone(); + let module_path = path[..path.len() - 1].to_vec(); + return Some((module_path, trait_name)); + } + if !bound.contains('.') { + let module_path = self.current_module_path.clone()?; + return Some((module_path, bound.to_string())); + } + let (module_name, trait_name) = bound.rsplit_once('.')?; + let module_path = self.module_path_for_imported_name(module_name)?; + Some((module_path, trait_name.to_string())) + } + + /// Return temporary trait satisfaction for proven source type families. + fn temporary_trait_capability_supports_type( + &self, + capability: &TraitCapabilityInfo, + ty: &ResolvedType, + ) -> Option { + match ty { + ResolvedType::Unknown + | ResolvedType::TypeVar(_) + | ResolvedType::RustPath(_) + | ResolvedType::CallSiteInfer => Some(true), + ResolvedType::Int => Some(trait_capabilities::supports_type(capability, TraitCapabilityType::Int)), + ResolvedType::Bool => Some(trait_capabilities::supports_type(capability, TraitCapabilityType::Bool)), + ResolvedType::Str => Some(trait_capabilities::supports_type(capability, TraitCapabilityType::Str)), + ResolvedType::Bytes => Some(trait_capabilities::supports_type( + capability, + TraitCapabilityType::Bytes, + )), + ResolvedType::Numeric(id) => Some(trait_capabilities::supports_type( + capability, + TraitCapabilityType::Numeric(*id), + )), + ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => { + self.temporary_trait_capability_supports_type(capability, inner) + } + ResolvedType::Generic(name, args) + if numerics::decimal_constructor_from_str(name.as_str()).is_some() + && args.len() == 2 + && args + .iter() + .all(|arg| matches!(arg, ResolvedType::TypeVar(value) if value.parse::().is_ok())) => + { + Some(trait_capabilities::supports_type( + capability, + TraitCapabilityType::Decimal, + )) + } + ResolvedType::Named(type_name) | ResolvedType::Generic(type_name, _) + if self.value_enum_type_satisfies_temporary_trait_capability(type_name) => + { + Some(trait_capabilities::supports_type( + capability, + TraitCapabilityType::ValueEnum, + )) + } + ResolvedType::Float + | ResolvedType::FrozenStr + | ResolvedType::FrozenBytes + | ResolvedType::Unit + | ResolvedType::Tuple(_) + | ResolvedType::FrozenList(_) + | ResolvedType::FrozenSet(_) + | ResolvedType::FrozenDict(_, _) + | ResolvedType::Function(_, _) + | ResolvedType::SelfType => Some(false), + ResolvedType::Generic(_, _) | ResolvedType::Named(_) => None, + } + } + + /// Return whether a nominal type is a stable scalar value enum category for temporary capability bridges. + fn value_enum_type_satisfies_temporary_trait_capability(&self, type_name: &str) -> bool { + matches!( + self.lookup_semantic_type_info(type_name), + Some(TypeInfo::Enum(info)) if info.value_enum.is_some() + ) + } + + fn tuple_type_satisfies_bound(&self, items: &[ResolvedType], bound: &str) -> bool { + match builtin_traits::from_str(bound) { + Some( + TraitId::Clone + | TraitId::Debug + | TraitId::Default + | TraitId::Eq + | TraitId::PartialEq + | TraitId::Ord + | TraitId::PartialOrd + | TraitId::Hash, + ) => items.iter().all(|item| self.type_satisfies_explicit_bound(item, bound)), + _ => false, + } + } + + fn collection_type_satisfies_bound(&self, kind: CollectionTypeId, args: &[ResolvedType], bound: &str) -> bool { + let all_args_satisfy = || args.iter().all(|arg| self.type_satisfies_explicit_bound(arg, bound)); + match builtin_traits::from_str(bound) { + Some(TraitId::Clone | TraitId::Debug) => all_args_satisfy(), + Some(TraitId::Default) => matches!( + kind, + CollectionTypeId::List + | CollectionTypeId::FrozenList + | CollectionTypeId::Dict + | CollectionTypeId::FrozenDict + | CollectionTypeId::Set + | CollectionTypeId::FrozenSet + | CollectionTypeId::Option + ), + Some(TraitId::Eq | TraitId::PartialEq) => all_args_satisfy(), + Some(TraitId::Ord | TraitId::PartialOrd) => { + matches!( + kind, + CollectionTypeId::List + | CollectionTypeId::FrozenList + | CollectionTypeId::Tuple + | CollectionTypeId::Option + ) && all_args_satisfy() + } + Some(TraitId::Hash) => { + matches!( + kind, + CollectionTypeId::List + | CollectionTypeId::FrozenList + | CollectionTypeId::Tuple + | CollectionTypeId::Option + ) && all_args_satisfy() + } + _ => false, + } + } + + /// Return whether `ty` is one of the checked await-realization paths for `Awaitable[T]`. + fn type_satisfies_awaitable_bound(&self, ty: &ResolvedType, expected_output: Option<&ResolvedType>) -> bool { + let Some(output_ty) = self.await_output_type_from_type(ty) else { + return false; + }; + expected_output.is_none_or(|expected| { + matches!(output_ty, ResolvedType::Unknown) || self.types_compatible(&output_ty, expected) + }) + } + + /// Return whether a named user type explicitly satisfies a generic trait bound. + fn named_type_satisfies_bound(&self, type_name: &str, bound: &str) -> bool { + match self.lookup_type_info(type_name) { + Some(TypeInfo::Builtin) => matches!(builtin_traits::from_str(bound), Some(TraitId::Clone | TraitId::Debug)), + Some(TypeInfo::Model(info)) => { + info.traits.iter().any(|t| t == bound) || info.derives.iter().any(|d| d == bound) + } + Some(TypeInfo::Class(info)) => { + info.traits.iter().any(|t| t == bound) || info.derives.iter().any(|d| d == bound) + } + Some(TypeInfo::Enum(info)) => { + info.traits.iter().any(|t| t == bound) || info.derives.iter().any(|d| d == bound) + } + Some(TypeInfo::Newtype(info)) => info.traits.iter().any(|t| t == bound), + Some(TypeInfo::TypeAlias) => false, + None => false, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index a7bdd3bdd..de6e1f5f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,4 +42,4 @@ pub use frontend::typechecker; pub use backend::IrCodegen; pub use backend::project::ProjectGenerator; -pub use format::{FormatConfig, check_formatted, format_diff, format_source, format_source_with_config}; +pub use format::{FormatConfig, FormatError, check_formatted, format_diff, format_source, format_source_with_config}; diff --git a/tests/codegen_snapshot_tests.rs b/tests/codegen_snapshot_tests.rs index f4b27760f..5f374a39b 100644 --- a/tests/codegen_snapshot_tests.rs +++ b/tests/codegen_snapshot_tests.rs @@ -722,6 +722,27 @@ def main(result: Result[int, str]) -> Result[int, str]: ); } +#[test] +fn test_rfc070_result_unwrap_codegen_does_not_require_debug_err() { + let source = r#" +model PlainError: + message: str + +pub def direct(result: Result[int, PlainError]) -> int: + return result.unwrap() +"#; + let rust_code = generate_rust(source); + let compact = rust_code.split_whitespace().collect::(); + assert!( + compact.contains("matchresult{Ok(__incan_ok)=>__incan_ok,Err(_)=>panic!"), + "Result.unwrap should lower to an explicit match that discards Err without a Debug bound:\n{rust_code}" + ); + assert!( + !compact.contains("result.unwrap()"), + "Result.unwrap should not lower to Rust unwrap(), which requires E: Debug:\n{rust_code}" + ); +} + #[test] fn test_rfc070_result_inspect_non_copy_observer_borrows_payload() { let source = r#" diff --git a/tests/fixtures/vocab_guardrails/semantic_string_audit.json b/tests/fixtures/vocab_guardrails/semantic_string_audit.json new file mode 100644 index 000000000..7ad8d79ef --- /dev/null +++ b/tests/fixtures/vocab_guardrails/semantic_string_audit.json @@ -0,0 +1,322 @@ +{ + "files": [ + { + "path": "crates/incan_core/src/interop/coercions.rs", + "category": "registry-backed Rust-boundary coercion policy", + "expected_count": 39, + "expected_fingerprint": "0x631ac11a12a89439" + }, + { + "path": "crates/incan_core/src/interop/extension_traits.rs", + "category": "metadata-free Rust extension-trait fallback inventory", + "expected_count": 8, + "expected_fingerprint": "0x86a8087fbbe1db3b" + }, + { + "path": "crates/incan_core/src/interop/metadata.rs", + "category": "registry-backed Rust collection metadata policy", + "expected_count": 14, + "expected_fingerprint": "0x5da489b106a16ae2" + }, + { + "path": "crates/rust_inspect/src/cache.rs", + "category": "rust-inspect cache migration compatibility", + "expected_count": 1, + "expected_fingerprint": "0x57ff753cfc22d019" + }, + { + "path": "crates/rust_inspect/src/cache_timing.rs", + "category": "rust-inspect timing environment compatibility", + "expected_count": 1, + "expected_fingerprint": "0xdc8309b97ee19675" + }, + { + "path": "crates/rust_inspect/src/extractor.rs", + "category": "rust-inspect display-shape normalization", + "expected_count": 18, + "expected_fingerprint": "0xfdfffcbe8f5fc285" + }, + { + "path": "crates/rust_inspect/src/lib.rs", + "category": "rust-inspect environment and unknown-display normalization", + "expected_count": 2, + "expected_fingerprint": "0xd1080e9767292290" + }, + { + "path": "src/backend/ir/codegen.rs", + "category": "codegen facade compatibility and Rust serde fallback", + "expected_count": 4, + "expected_fingerprint": "0xcedb21689b86932d" + }, + { + "path": "src/backend/ir/codegen/dependency_metadata.rs", + "category": "dependency metadata compatibility and web route preservation", + "expected_count": 2, + "expected_fingerprint": "0x9d1944407ac71f29" + }, + { + "path": "src/backend/ir/codegen/serde_activation.rs", + "category": "serde activation compatibility", + "expected_count": 3, + "expected_fingerprint": "0x2b22f0e7dbaebdbd" + }, + { + "path": "src/backend/ir/conversions.rs", + "category": "central argument conversion shape checks", + "expected_count": 2, + "expected_fingerprint": "0xc102811ab1ea1d38" + }, + { + "path": "src/backend/ir/emit/decls/functions.rs", + "category": "callable and Rust macro emission compatibility", + "expected_count": 2, + "expected_fingerprint": "0x99dc282326807784" + }, + { + "path": "src/backend/ir/emit/decls/impls.rs", + "category": "generated stdlib JSON/newtype helper retention", + "expected_count": 4, + "expected_fingerprint": "0x3fc0034bdf2e07be" + }, + { + "path": "src/backend/ir/emit/decls/mod.rs", + "category": "generated stdlib import path recognition", + "expected_count": 1, + "expected_fingerprint": "0x1c3e7de164e70908" + }, + { + "path": "src/backend/ir/emit/decls/structures.rs", + "category": "derive macro emission compatibility", + "expected_count": 4, + "expected_fingerprint": "0xd279ee037e30c621" + }, + { + "path": "src/backend/ir/emit/expressions/calls.rs", + "category": "testing helper and public-module emission compatibility", + "expected_count": 2, + "expected_fingerprint": "0x823a39e4d5d80958" + }, + { + "path": "src/backend/ir/emit/expressions/comprehensions.rs", + "category": "dict view compatibility emission", + "expected_count": 1, + "expected_fingerprint": "0x79dfdfd491f691d1" + }, + { + "path": "src/backend/ir/emit/expressions/methods.rs", + "category": "quarantined metadata-free method compatibility", + "expected_count": 9, + "expected_fingerprint": "0x2bda7f88a3c65087" + }, + { + "path": "src/backend/ir/emit/expressions/methods/fast_paths.rs", + "category": "registered method fast-path receiver typing", + "expected_count": 2, + "expected_fingerprint": "0xd121f2f287b25f51" + }, + { + "path": "src/backend/ir/emit/expressions/mod.rs", + "category": "numeric emission compatibility", + "expected_count": 1, + "expected_fingerprint": "0x7ab516025dc0a176" + }, + { + "path": "src/backend/ir/emit/mod.rs", + "category": "Rust path segment escaping compatibility", + "expected_count": 1, + "expected_fingerprint": "0x90822765d714d957" + }, + { + "path": "src/backend/ir/emit/types.rs", + "category": "Rust path and static trait emission compatibility", + "expected_count": 2, + "expected_fingerprint": "0x5656f96237432bea" + }, + { + "path": "src/backend/ir/expr.rs", + "category": "known iterator method enum classification", + "expected_count": 23, + "expected_fingerprint": "0x5c7ee976092c9c9a" + }, + { + "path": "src/backend/ir/lower/decl/helpers.rs", + "category": "primitive, derive, and Rust namespace lowering compatibility", + "expected_count": 10, + "expected_fingerprint": "0xb30118abb73ddcee" + }, + { + "path": "src/backend/ir/lower/decl/methods.rs", + "category": "callable, JSON, and iterator method lowering compatibility", + "expected_count": 4, + "expected_fingerprint": "0xbcf2d12b24fabc65" + }, + { + "path": "src/backend/ir/lower/decl/mod.rs", + "category": "derive const lowering compatibility", + "expected_count": 1, + "expected_fingerprint": "0x41b88bc9914484bf" + }, + { + "path": "src/backend/ir/lower/decl/traits.rs", + "category": "iterator trait method lowering compatibility", + "expected_count": 1, + "expected_fingerprint": "0x38db8c81d2ab19aa" + }, + { + "path": "src/backend/ir/lower/expr/calls.rs", + "category": "testing assert-raises lowering policy", + "expected_count": 2, + "expected_fingerprint": "0x88708e083b004857" + }, + { + "path": "src/backend/ir/lower/expr/mod.rs", + "category": "method and stdlib lowering compatibility", + "expected_count": 10, + "expected_fingerprint": "0xf0d6e2f722d351e8" + }, + { + "path": "src/backend/ir/lower/mod.rs", + "category": "newtype, derive, and validation lowering compatibility", + "expected_count": 5, + "expected_fingerprint": "0xefce60e6312a0c70" + }, + { + "path": "src/backend/ir/lower/stmt.rs", + "category": "placeholder assignment lowering compatibility", + "expected_count": 1, + "expected_fingerprint": "0xae028e0ef66ceaa2" + }, + { + "path": "src/backend/ir/lower/types.rs", + "category": "lowered primitive type spelling compatibility", + "expected_count": 6, + "expected_fingerprint": "0xa6967a320ba65785" + }, + { + "path": "src/backend/ir/reference_shape.rs", + "category": "central Rust reference-shape compatibility", + "expected_count": 1, + "expected_fingerprint": "0x22d77392903f6589" + }, + { + "path": "src/backend/ir/trait_bound_inference.rs", + "category": "clone/as_ref/self trait-bound inference compatibility", + "expected_count": 4, + "expected_fingerprint": "0x7246e6844a35b5b3" + }, + { + "path": "src/cli/commands/common.rs", + "category": "project materialization and dependency compatibility", + "expected_count": 5, + "expected_fingerprint": "0x3990288339d2b3b3" + }, + { + "path": "src/dependency_resolver.rs", + "category": "dependency resolver registry and package-alias policy", + "expected_count": 18, + "expected_fingerprint": "0x12d230c26526632c" + }, + { + "path": "src/frontend/testing_markers.rs", + "category": "metadata-loaded testing marker inventory", + "expected_count": 13, + "expected_fingerprint": "0x4d4acefa2e275e14" + }, + { + "path": "src/frontend/typechecker/check_decl.rs", + "category": "declaration-level stdlib and derive compatibility", + "expected_count": 3, + "expected_fingerprint": "0xe83eca1f5db018d2" + }, + { + "path": "src/frontend/typechecker/check_expr/access.rs", + "category": "method/type access surface classification", + "expected_count": 40, + "expected_fingerprint": "0xd87e478e91056a9f" + }, + { + "path": "src/frontend/typechecker/check_expr/basics.rs", + "category": "basic expression Rust/stdlib escape compatibility", + "expected_count": 2, + "expected_fingerprint": "0xffdd8d7881dba8e2" + }, + { + "path": "src/frontend/typechecker/check_expr/calls.rs", + "category": "enum constructor member compatibility", + "expected_count": 2, + "expected_fingerprint": "0x0582b4a26a7a5b97" + }, + { + "path": "src/frontend/typechecker/check_expr/calls/constructors.rs", + "category": "constructor named-argument compatibility", + "expected_count": 3, + "expected_fingerprint": "0xa6598f835bad7c2e" + }, + { + "path": "src/frontend/typechecker/check_expr/calls/rust_boundary.rs", + "category": "Rust-boundary borrowed display recognition", + "expected_count": 2, + "expected_fingerprint": "0x2c33eb1e16ffe7d2" + }, + { + "path": "src/frontend/typechecker/check_expr/control_flow.rs", + "category": "async guard method control-flow policy", + "expected_count": 3, + "expected_fingerprint": "0x2893f90138d3e39f" + }, + { + "path": "src/frontend/typechecker/check_expr/mod.rs", + "category": "expression-level pub/rust namespace compatibility", + "expected_count": 3, + "expected_fingerprint": "0x4241a4c501ebe60c" + }, + { + "path": "src/frontend/typechecker/check_stmt.rs", + "category": "statement-level runtime exception compatibility", + "expected_count": 1, + "expected_fingerprint": "0xe98b9ff171b2c549" + }, + { + "path": "src/frontend/typechecker/collect.rs", + "category": "derive and public-module collection compatibility", + "expected_count": 2, + "expected_fingerprint": "0x50547807f8d5d3bb" + }, + { + "path": "src/frontend/typechecker/collect/decorators.rs", + "category": "decorator and Rust module collection compatibility", + "expected_count": 3, + "expected_fingerprint": "0x380186b8c04e5753" + }, + { + "path": "src/frontend/typechecker/collect/stdlib_imports.rs", + "category": "stdlib import and extension-trait compatibility", + "expected_count": 8, + "expected_fingerprint": "0x3dbd625f6b96862d" + }, + { + "path": "src/frontend/typechecker/const_eval.rs", + "category": "Rust module const classification compatibility", + "expected_count": 1, + "expected_fingerprint": "0x654cd60a41fc2f2f" + }, + { + "path": "src/frontend/typechecker/mod.rs", + "category": "Rust display type parsing and stdlib derive compatibility", + "expected_count": 34, + "expected_fingerprint": "0xa66345b30dd9c215" + }, + { + "path": "src/frontend/typechecker/stdlib_loader.rs", + "category": "stdlib loader primitive compatibility", + "expected_count": 6, + "expected_fingerprint": "0x12908cd63ee9cda3" + }, + { + "path": "src/frontend/typechecker/validate_rust_module.rs", + "category": "Rust module validation compatibility", + "expected_count": 1, + "expected_fingerprint": "0x3814274efdb9fae2" + } + ] +} diff --git a/tests/vocab_guardrails.rs b/tests/vocab_guardrails.rs index 60cdfa67c..06cceba52 100644 --- a/tests/vocab_guardrails.rs +++ b/tests/vocab_guardrails.rs @@ -4,6 +4,9 @@ use std::path::{Path, PathBuf}; use incan_core::lang::derives; use incan_core::lang::types::collections; +use serde::Deserialize; + +const SEMANTIC_STRING_AUDIT_PATH: &str = "tests/fixtures/vocab_guardrails/semantic_string_audit.json"; /// Guardrail against reintroducing stringly-typed vocabulary checks. /// @@ -42,10 +45,184 @@ fn no_new_stringly_vocab_checks_in_rust_sources() { } } +#[derive(Debug)] +struct AuditedSemanticStringFile { + path: String, + category: String, + expected_count: usize, + expected_fingerprint: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SemanticStringAudit { + files: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAuditedSemanticStringFile { + path: String, + category: String, + expected_count: usize, + expected_fingerprint: String, +} + +/// Guardrail for semantic string comparisons that remain in high-risk compiler paths. +/// +/// A semantic string comparison is not automatically wrong. Some strings are source names, manifest keys, Rust display +/// fragments, or quarantined metadata-free compatibility policy. The point of this test is that these comparisons must +/// be visible and classified instead of silently growing in typechecking, lowering, emission, dependency resolution, or +/// Rust inspection. +#[test] +fn semantic_string_checks_are_classified() { + let root = repo_root(); + let audit_entries = audited_semantic_string_files(&root); + let scan_files = semantic_string_scan_files(&root); + let scanned_paths: BTreeSet = scan_files.iter().map(|path| rel_path(&root, path)).collect(); + let mut offenders = Vec::new(); + + for path in &scan_files { + let sites = semantic_string_sites(path); + if sites.is_empty() { + continue; + } + let rel = rel_path(&root, path); + let actual_count = sites.len(); + let actual_fingerprint = fingerprint_sites(&sites); + match audit_entries.iter().find(|entry| entry.path == rel) { + Some(entry) if entry.expected_count == actual_count && entry.expected_fingerprint == actual_fingerprint => { + } + Some(entry) => offenders.push(format!( + "{} changed in `{}`: expected {} sites/{:016x}, found {} sites/{:016x}", + entry.category, rel, entry.expected_count, entry.expected_fingerprint, actual_count, actual_fingerprint + )), + None => offenders.push(format!( + "unclassified semantic string checks in `{rel}`: {} sites/{actual_fingerprint:016x}", + actual_count + )), + } + } + + let mut audited_paths: BTreeSet<&str> = BTreeSet::new(); + let mut previous_audited_path: Option<&str> = None; + for entry in &audit_entries { + if let Some(previous) = previous_audited_path + && previous > entry.path.as_str() + { + offenders.push(format!( + "semantic string audit paths are not sorted: `{previous}` appears before `{}`", + entry.path + )); + } + previous_audited_path = Some(entry.path.as_str()); + if !audited_paths.insert(entry.path.as_str()) { + offenders.push(format!("duplicate semantic string audit entry: `{}`", entry.path)); + } + let path = root.join(&entry.path); + if !path.exists() { + offenders.push(format!( + "audited semantic string file no longer exists: `{}` ({})", + entry.path, entry.category + )); + } else if !scanned_paths.contains(&entry.path) { + offenders.push(format!( + "audited semantic string file is outside scanned roots: `{}` ({})", + entry.path, entry.category + )); + } + } + + if !offenders.is_empty() { + let mut msg = String::new(); + msg.push_str( + "Semantic string checks changed. Move behavior behind a registry when possible; otherwise classify the file in the semantic string audit fixture.\n\n", + ); + for offender in offenders { + msg.push_str("- "); + msg.push_str(&offender); + msg.push('\n'); + } + msg.push_str("\nCurrent scanned sites:\n"); + for path in &scan_files { + let sites = semantic_string_sites(path); + if sites.is_empty() { + continue; + } + let rel = rel_path(&root, path); + msg.push_str(&format!( + "\n{rel} ({} sites/{:016x})\n", + sites.len(), + fingerprint_sites(&sites) + )); + for site in sites { + msg.push_str(&format!(" {}\n", site.trim())); + } + } + panic!("{msg}"); + } +} + fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +fn rel_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn audited_semantic_string_files(root: &Path) -> Vec { + let audit_path = root.join(SEMANTIC_STRING_AUDIT_PATH); + let contents = fs::read_to_string(&audit_path) + .unwrap_or_else(|err| panic!("failed to read semantic string audit `{}`: {err}", audit_path.display())); + let audit: SemanticStringAudit = serde_json::from_str(&contents).unwrap_or_else(|err| { + panic!( + "failed to parse semantic string audit `{}`: {err}", + audit_path.display() + ) + }); + + if audit.files.is_empty() { + panic!( + "semantic string audit `{}` must classify at least one file", + audit_path.display() + ); + } + + audit + .files + .into_iter() + .map(|entry| { + let expected_fingerprint = + parse_expected_fingerprint(&audit_path, &entry.path, &entry.expected_fingerprint); + AuditedSemanticStringFile { + path: entry.path, + category: entry.category, + expected_count: entry.expected_count, + expected_fingerprint, + } + }) + .collect() +} + +fn parse_expected_fingerprint(audit_path: &Path, entry_path: &str, value: &str) -> u64 { + let hex = value.strip_prefix("0x").unwrap_or_else(|| { + panic!( + "semantic string audit `{}` entry `{entry_path}` has non-hex expected_fingerprint `{value}`", + audit_path.display() + ) + }); + u64::from_str_radix(hex, 16).unwrap_or_else(|err| { + panic!( + "semantic string audit `{}` entry `{entry_path}` has invalid expected_fingerprint `{value}`: {err}", + audit_path.display() + ) + }) +} + fn tier_a_spellings() -> Vec<&'static str> { // Tier A: high-signal, drift-prone vocabulary. // - Generic bases / builtin collection type names (and aliases) @@ -131,3 +308,211 @@ fn is_suspicious_line(line: &str, spellings: &[&'static str]) -> bool { false } + +fn semantic_string_scan_files(root: &Path) -> Vec { + const ROOTS: &[&str] = &[ + "crates/incan_core/src/interop", + "crates/rust_inspect/src", + "src/backend/ir", + "src/cli/commands/common.rs", + "src/dependency_resolver.rs", + "src/frontend/testing_markers.rs", + "src/frontend/typechecker", + ]; + + let mut files = Vec::new(); + for root_path in ROOTS { + collect_rust_files(&root.join(root_path), &mut files); + } + files.sort(); + files.dedup(); + files +} + +fn collect_rust_files(path: &Path, files: &mut Vec) { + if path.is_file() { + if is_semantic_string_scan_file(path) { + files.push(path.to_path_buf()); + } + return; + } + + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rust_files(&path, files); + } else if is_semantic_string_scan_file(&path) { + files.push(path); + } + } +} + +fn is_semantic_string_scan_file(path: &Path) -> bool { + path.extension().and_then(|ext| ext.to_str()) == Some("rs") + && path.file_name().and_then(|file| file.to_str()) != Some("tests.rs") + && !path + .components() + .any(|component| component.as_os_str().to_str() == Some("tests")) +} + +fn semantic_string_sites(path: &Path) -> Vec { + let Ok(contents) = fs::read_to_string(path) else { + return Vec::new(); + }; + let mut sites = Vec::new(); + let mut brace_depth = 0usize; + let mut pending_cfg_test = false; + let mut skip_until_depth: Option = None; + + for line in contents.lines() { + let code = strip_line_comment(line).trim(); + if let Some(target_depth) = skip_until_depth { + brace_depth = update_brace_depth(brace_depth, code); + if brace_depth <= target_depth { + skip_until_depth = None; + } + continue; + } + + if code.starts_with("#[cfg(test)]") { + pending_cfg_test = true; + brace_depth = update_brace_depth(brace_depth, code); + continue; + } + if pending_cfg_test && code.contains("mod tests") && code.contains('{') { + let target_depth = brace_depth; + brace_depth = update_brace_depth(brace_depth, code); + if brace_depth > target_depth { + skip_until_depth = Some(target_depth); + } + pending_cfg_test = false; + continue; + } + if pending_cfg_test && !code.starts_with("#[") && !code.is_empty() { + pending_cfg_test = false; + } + + if semantic_string_line(code) { + sites.push(code.to_string()); + } + brace_depth = update_brace_depth(brace_depth, code); + } + + sites +} + +fn update_brace_depth(current: usize, code: &str) -> usize { + let mut depth = current; + let mut in_string = false; + let mut escaped = false; + for byte in code.bytes() { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + match byte { + b'"' => in_string = true, + b'{' => depth = depth.saturating_add(1), + b'}' => depth = depth.saturating_sub(1), + _ => {} + } + } + depth +} + +fn strip_line_comment(line: &str) -> &str { + let mut in_string = false; + let mut escaped = false; + let bytes = line.as_bytes(); + let mut idx = 0usize; + while idx + 1 < bytes.len() { + let byte = bytes[idx]; + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + idx += 1; + continue; + } + if byte == b'"' { + in_string = true; + } else if byte == b'/' && bytes[idx + 1] == b'/' { + return &line[..idx]; + } + idx += 1; + } + line +} + +fn semantic_string_line(code: &str) -> bool { + if code.is_empty() || !code.contains('"') { + return false; + } + if code.starts_with("#[") + || code.starts_with("assert!") + || code.starts_with("assert_eq!") + || code.starts_with("assert_ne!") + || code.starts_with("panic!") + || code.starts_with("format!") + || code.starts_with("write!") + || code.starts_with("writeln!") + { + return false; + } + + line_has_string_comparison(code) + || line_has_string_matches_macro(code) + || line_has_string_match_arm(code) + || line_has_semantic_string_table(code) +} + +fn line_has_string_comparison(code: &str) -> bool { + code.contains("== \"") + || code.contains("!= \"") + || code.contains("== &\"") + || code.contains("!= &\"") + || code.contains(".as_deref() == Some(\"") + || code.contains(".as_deref() != Some(\"") + || code.contains("== Some(\"") + || code.contains("!= Some(\"") +} + +fn line_has_string_matches_macro(code: &str) -> bool { + code.contains("matches!(") && code.contains('"') +} + +fn line_has_string_match_arm(code: &str) -> bool { + let Some(arrow_idx) = code.find("=>") else { + return false; + }; + let before_arrow = code[..arrow_idx].trim_start(); + before_arrow.starts_with('"') || before_arrow.starts_with("| \"") || before_arrow.starts_with("(\"") +} + +fn line_has_semantic_string_table(code: &str) -> bool { + code.contains("methods: &[") || code.contains("expected: &[") || code.contains("features: &[") +} + +fn fingerprint_sites(sites: &[String]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for site in sites { + for byte in site.as_bytes().iter().chain(std::iter::once(&b'\n')) { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + hash +} diff --git a/workspaces/docs-site/docs/language/reference/language.md b/workspaces/docs-site/docs/language/reference/language.md index 9eb7038c2..ad8e0c62b 100644 --- a/workspaces/docs-site/docs/language/reference/language.md +++ b/workspaces/docs-site/docs/language/reference/language.md @@ -641,6 +641,35 @@ Class, model, trait, enum, newtype, field, alias, and module decorators remain l | OrElse | `or_else` | | Recover or remap through a Result-returning operation from an Err payload. | RFC 070 | 0.3 | Stable | | Inspect | `inspect` | | Observe an Ok payload by implicit borrow while preserving the original Result. | RFC 070 | 0.3 | Stable | | InspectErr | `inspect_err` | | Observe an Err payload by implicit borrow while preserving the original Result. | RFC 070 | 0.3 | Stable | +| Unwrap | `unwrap` | | Return the Ok payload or panic. | RFC 000 | 0.1 | Stable | +| UnwrapOr | `unwrap_or` | | Return the Ok payload or a default value. | RFC 000 | 0.1 | Stable | + + +### Iterator methods + +| Id | Canonical | Aliases | Description | RFC | Since | Stability | +|---|---|---|---|---|---|---| +| Iter | `iter` | | Create an iterator over an iterable. | RFC 088 | 0.3 | Stable | +| Map | `map` | | Lazily transform iterator items. | RFC 088 | 0.3 | Stable | +| Filter | `filter` | | Lazily keep items that match a predicate. | RFC 088 | 0.3 | Stable | +| Enumerate | `enumerate` | | Yield each item with its zero-based index. | RFC 088 | 0.3 | Stable | +| Zip | `zip` | | Pair items from two iterables. | RFC 088 | 0.3 | Stable | +| Take | `take` | | Yield at most the requested number of items. | RFC 088 | 0.3 | Stable | +| Skip | `skip` | | Discard at most the requested number of items. | RFC 088 | 0.3 | Stable | +| TakeWhile | `take_while` | | Yield items until a predicate first returns false. | RFC 088 | 0.3 | Stable | +| SkipWhile | `skip_while` | | Discard items while a predicate returns true. | RFC 088 | 0.3 | Stable | +| Chain | `chain` | | Yield receiver items followed by another iterable. | RFC 088 | 0.3 | Stable | +| FlatMap | `flat_map` | | Map items to iterables and flatten the result. | RFC 088 | 0.3 | Stable | +| Batch | `batch` | | Yield fixed-size list batches. | RFC 088 | 0.3 | Stable | +| Collect | `collect` | | Consume an iterator into a list. | RFC 088 | 0.3 | Stable | +| Count | `count` | | Consume an iterator and return the item count. | RFC 088 | 0.3 | Stable | +| Reduce | `reduce` | | Consume an iterator with an explicit accumulator. | RFC 088 | 0.3 | Stable | +| Fold | `fold` | | Consume an iterator with an explicit accumulator. | RFC 088 | 0.3 | Stable | +| Any | `any` | | Return whether any item satisfies a predicate. | RFC 088 | 0.3 | Stable | +| All | `all` | | Return whether every item satisfies a predicate. | RFC 088 | 0.3 | Stable | +| Find | `find` | | Return the first item satisfying a predicate. | RFC 088 | 0.3 | Stable | +| ForEach | `for_each` | | Consume an iterator for side effects. | RFC 088 | 0.3 | Stable | +| Sum | `sum` | | Consume an iterator and return the numeric sum. | RFC 088 | 0.3 | Stable | ### FrozenList methods diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 86a843bd3..cba38374a 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -53,6 +53,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening - **Release-candidate hardening**: The RC validation loop against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, and `std.regex` Rust-boundary text borrowing (#615, #616, #617, #620, #621, #622, #624, #625, #627). +- **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). - **Compiler**: Rust interop fixes cover retained enum-pattern imports, owned Incan values passed to shared borrowed generic Rust parameters, `Vec` adaptation from `list[T]`, prost-style inherent and trait-provided `decode(buf: T)` calls, extension-trait import retention from metadata, and trait-typed local annotation diagnostics (#459, #506, #128, #609, #612, #447, #462). From cfc242d21875f792d69467d508b10cc195170dca Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 15:25:26 +0200 Subject: [PATCH 11/58] bugfix - stabilize Rust bridge identity and test module ordering (#630, #631) (#632) --- Cargo.lock | 154 +++++++++--------- src/cli/commands/common.rs | 2 +- src/cli/test_runner/module_graph.rs | 107 +++++++++--- src/frontend/typechecker/mod.rs | 31 +++- src/frontend/typechecker/tests.rs | 45 ++++- tests/cli_integration.rs | 54 ++++++ tests/codegen_snapshot_tests.rs | 2 + .../docs-site/docs/release_notes/0_3.md | 4 +- 8 files changed, 289 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aaac3967e..2e1387d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,7 +92,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -103,7 +103,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -610,27 +610,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8628cc4ba7f88a9205a7ee42327697abc61195a1e3d92cfae172d6a946e722e" +checksum = "008f1a8d1da5074ad858f398775a6d1989031892e46927df5ed18d3be1ed8717" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d582754487e6c9a065a91c42ccf1bdd8d5977af33468dac5ae9bec0ce88acb3e" +checksum = "9fd76237df1f4e26edb5ad7971d20280ed1e193331fd257f1b4e4dfefd88dda2" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb59c81ace12ee7c33074db7903d4d75d1f40b28cd3e8e6f491de57b29129eb9" +checksum = "380f0bc43e535df6855bbee649efb00bde39c3f33434c47c8e10ac836d21bf47" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -638,9 +638,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c06993a681be9cf3140798a3d4ac5bec955e7444416a2fdc87fda8567285d" +checksum = "4811e3e4502de04257e90c0a93225b56d9b85e0f9ad10b81446b415511009610" dependencies = [ "serde", "serde_derive", @@ -649,9 +649,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b61f95c5a211918f5d336254a61a488b36a5818de47a868e8c4658dce9cccc" +checksum = "82ffadb34d497f3e76fb3b4baf764c24ba8a51512976a1b77f78bdbf8f4aa687" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -677,9 +677,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b85aa822fce72080d041d7c2cf7c3f5c6ecdea7afae68379ba4ef85269c4fa5" +checksum = "be4f6992eb6faf086ddc7deaaa5f279abfe7f5fd5ae5709bd38253450fc7b945" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -690,24 +690,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9fc89326cd072cc19e96892f09b5692c0dfe17cd4da2858ba30c2cd85c0" +checksum = "70e1b2aad7d055925a4ea9cdbfa9d1d987f9dfc8ad6b708be28f901ac620a298" [[package]] name = "cranelift-control" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d005320f487e6e8a3edcc7f2fd4f43fcc9946d1013bf206ea649789ac1617fc" +checksum = "89a355348325e0a63b65c00def3871597b9fcc79d25456397010d16d872b3772" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e62ef34c6e720f347a79ece043e8584e242d168911da640bac654a33a6aaaf5" +checksum = "43f4847d93ce2c80d2bff929aa1004dfb3ce2cf5d881f6ced54b8d654d967ba3" dependencies = [ "cranelift-bitset", "serde", @@ -717,9 +717,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa2ad00399dd47e7e7e33cb1dc23b0e39ed9dcd01e8f026fc37af91655031b8" +checksum = "ba24e5fe5242cc445e7892ef0a51a4351cf716e3a04ac7a3a05820d056c39818" dependencies = [ "cranelift-codegen", "log", @@ -729,15 +729,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c51975ed217b4e8e5a7fd11e9ec83a96104bdff311dddcb505d1d8a9fd7fc6" +checksum = "89bc2035de85c4f04ba7bd57eb5bd3a8b775235bf28852dbf87105115cb8919a" [[package]] name = "cranelift-native" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b1889e00da9729d8f8525f3c12998ded86ea709058ff844ebe00b97548de0e" +checksum = "5ea6630c16921ab087792750f239d0c0173411e80179ca7c0ce0710ce9e7646a" dependencies = [ "cranelift-codegen", "libc", @@ -746,9 +746,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.131.1" +version = "0.131.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a8f82fd5124f009f72167e60139245cd3b56cfd4b53050f22110c48c5f4da1" +checksum = "faa4bbad54fc28cc0da1f9a5d7f7f826ec8cafda3d503b401b2daaaa93c63ef0" [[package]] name = "crc32fast" @@ -948,7 +948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2005,7 +2005,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2047,7 +2047,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2298,9 +2298,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9326e3a0093d170582cf64ed9e4cf253b8aac155ec4a294ff62330450bbf094" +checksum = "dff0ead8b4616f81b3d3efd41ce41bcf9ea364a5d8df8be8a8a1f98b50104349" dependencies = [ "cranelift-bitset", "log", @@ -2310,9 +2310,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c6433917e3789605b1f4cd2a589f637ff17212344e7fa5ba99544625ba52c7" +checksum = "f4389e5820b1b39810ac12a27aa665320cab3caa51913a79637c06f284cfe223" dependencies = [ "proc-macro2", "quote", @@ -3239,7 +3239,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3537,7 +3537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3645,7 +3645,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4312,9 +4312,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" +checksum = "af4eccc0728f061979efa8ff4c962cff7041fead4baadb74973f01b9c47158a4" dependencies = [ "addr2line 0.26.1", "async-trait", @@ -4354,9 +4354,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e15aa0d1545e48d9b25ca604e9e27b4cd6d5886d30ac5787b57b3a2daf85b57" +checksum = "7e84dbe3208c1336a41546beb75927b3b37e2e4fce06653d214b407136fbe295" dependencies = [ "anyhow", "cpp_demangle", @@ -4385,9 +4385,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c136cb0d2d47850d6d04a58157130ac98b0df4c17626cd30b083d26b607b7027" +checksum = "c223bd503db76df8d74d1fcca39e734d25f7a0c1dcaf1509b67f3855d1b0f803" dependencies = [ "anyhow", "proc-macro2", @@ -4400,15 +4400,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df3d3b4fa2119c6fd161e475b4e21aaefb51d082353b922b433bea37facc65" +checksum = "ab123ad511483a1b918399789d0cc7dea7c5c6476743df73949007b5b225fc74" [[package]] name = "wasmtime-internal-core" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2c7fa6523647262bfb4095dbdf4087accefe525813e783f81a0c682f418ce4" +checksum = "4364d345719bba7fc4c435992ea1cb0c118f1e90a88c6e6f22a7a4fc507700c6" dependencies = [ "hashbrown 0.16.1", "libm", @@ -4417,9 +4417,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c032f422e39061dfc43f32190c0a3526b04161ec4867f362958f3fe9d1fe29" +checksum = "c5a3bc28a172037c7864128bb208017a02bba659a59c27acacc048c09e25c1fc" dependencies = [ "cfg-if", "cranelift-codegen", @@ -4444,9 +4444,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8dd76d80adf450cc260ba58f23c28030401930b19149695b1d121f7d621e791" +checksum = "3c90a899a47d3da6e384e7b4cad61fdcb27535a395742b32440bdf9980ea83fa" dependencies = [ "cc", "cfg-if", @@ -4459,9 +4459,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab453cc600b28ee5d3f9495aa6d4cb2c81eda40903e9287296b548fba8b2391d" +checksum = "84f364747aa74c686b18925918e5cfd615a73c9613c7a31fc1cd86f42df12fbe" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -4469,9 +4469,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1859e920871515d324fb9757c3e448d6ed1512ca6ccdff14b6e016505d6ada" +checksum = "c3ba98c1492f530833e0d3cc17dbb0c3c57c9f1bb3b078ae44bb55a233e43eba" dependencies = [ "cfg-if", "libc", @@ -4481,9 +4481,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dfe405bd6adb1386d935a30f16a236bd4ef0d3c383e7cbbab98d063c9d9b73" +checksum = "94b8f8a89e8f3660646f820c7d8310a67094156bb866e9d56f1b00892e011206" dependencies = [ "cfg-if", "cranelift-codegen", @@ -4494,9 +4494,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a9b9165fc45d42c81edfe3e9cb458e58720594ad5db6553c4079ea041a4a581" +checksum = "7a12754f1ffc4a3300d56d324c418b8b32cf029606618da22c7d076213882a3f" dependencies = [ "proc-macro2", "quote", @@ -4505,9 +4505,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95f439b70ba3855a8c808d2cd798eef79bcd389f78aa48a8a694ea8e2904410c" +checksum = "4b06e4ed07adc579645e5c55c67b3138c49da2e468fad52d3db7b7a098ecc733" dependencies = [ "cranelift-codegen", "gimli 0.33.0", @@ -4522,9 +4522,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c7ced16dc16d2027f9f8d3a503e191dcce0f53fe9218e7990135b31f8f6fdb" +checksum = "0f08787948e3c983799d616ef7dd57463253e9ca8bab6607eef8134f12353f70" dependencies = [ "anyhow", "bitflags 2.11.0", @@ -4535,9 +4535,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d3d57dd833d0c3ea2016a2aa54c6c517bf8dad9e79d8a593b0252c12bc961e3" +checksum = "1b2f19834bc6edbc31ac95fdcfd5ddcd7643759265a1d545dec36ac6cc788ca8" dependencies = [ "async-trait", "bitflags 2.11.0", @@ -4565,9 +4565,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6650bb4c61012b2221e751b7bc1162c7fd11bd1bc29e0714ad6ca463777a3422" +checksum = "c3e0c6efdbaf90906016be9ed9ff17b7b58f393876287beebe5bd7fa1de54dbb" dependencies = [ "async-trait", "bytes", @@ -4609,9 +4609,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f878b066ad36054ad6e7724230f28ea7f981f44e595e39946d5225fd9e87755" +checksum = "17b644ab90da80bbca28973192978ac452cbd876955bb209e6ff2cd1955e43a7" dependencies = [ "bitflags 2.11.0", "thiserror 2.0.18", @@ -4623,9 +4623,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f57f0bc709dacc9c69869006457ab4e1bc9d93695400f06224f33cbe8af81778" +checksum = "521f9d558365357274d960340eb9eb4f4d768fafdc79f381fd2e13a85b925ebc" dependencies = [ "heck", "proc-macro2", @@ -4637,9 +4637,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63976fe41647f7c55c680b88a7b9b68aae9184f5a6b4a0971bf3eb39c287467f" +checksum = "8a386e86021363c9f0abd1e189e8f8a729d9b5aab2bb7172a3e40f2ab647a936" dependencies = [ "proc-macro2", "quote", @@ -4653,14 +4653,14 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "winch-codegen" -version = "44.0.1" +version = "44.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6da7c536f3cfe5ff63537f795902fed56b8b5adcc7a87843a86dd8d4e57a7946" +checksum = "f16496e92d2b232f9d195ae74f71a674aabae7b7fa722d39068836723d3b653c" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs index 6e982756a..21b9137ca 100644 --- a/src/cli/commands/common.rs +++ b/src/cli/commands/common.rs @@ -1073,7 +1073,7 @@ pub fn collect_modules(entry_path: &str) -> CliResult> { /// This explicit sort guarantees each module appears only after its direct and transitive dependencies for acyclic /// portions of the graph. For cyclic components (for example stdlib prelude re-export loops), we keep deterministic /// fallback ordering rather than hard-failing in collection. -fn topologically_sort_modules( +pub(crate) fn topologically_sort_modules( modules: Vec, dependency_edges: &HashMap>, ) -> CliResult> { diff --git a/src/cli/test_runner/module_graph.rs b/src/cli/test_runner/module_graph.rs index 6f579c68d..9378911fe 100644 --- a/src/cli/test_runner/module_graph.rs +++ b/src/cli/test_runner/module_graph.rs @@ -3,7 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; use crate::cli::commands::common::{ - resolve_stdlib_module_source_path, uses_iterator_adapter_surface, uses_result_combinator_surface, + resolve_stdlib_module_source_path, topologically_sort_modules, uses_iterator_adapter_surface, + uses_result_combinator_surface, }; use crate::cli::prelude::ParsedModule; use crate::frontend::ast::Program; @@ -24,7 +25,7 @@ fn queue_incan_stdlib_source_module( incan_source_stdlib_module_paths: &mut HashMap, processed: &HashSet, to_process: &mut Vec<(PathBuf, String, Vec)>, -) -> Result<(), String> { +) -> Result, String> { let stdlib_key = module_path.join("."); let source_path = if let Some(cached_path) = incan_source_stdlib_module_paths.get(&stdlib_key) { cached_path.clone() @@ -37,9 +38,9 @@ fn queue_incan_stdlib_source_module( module_segments.extend(module_path.iter().skip(1).cloned()); let module_name = module_segments.join("_"); if !processed.contains(&source_path) { - to_process.push((source_path, module_name, module_segments)); + to_process.push((source_path.clone(), module_name, module_segments)); } - Ok(()) + Ok(Some(source_path)) } /// Queue one canonical source-import resolution for test dependency collection. @@ -48,26 +49,31 @@ fn queue_resolved_source_import( incan_source_stdlib_module_paths: &mut HashMap, processed: &HashSet, to_process: &mut Vec<(PathBuf, String, Vec)>, -) -> Result<(), String> { +) -> Result, String> { match resolution { SourceModuleImportResolution::Stdlib { module_path } => { if stdlib::stdlib_stub_path(&module_path).is_some() { - queue_incan_stdlib_source_module( + return queue_incan_stdlib_source_module( &module_path, incan_source_stdlib_module_paths, processed, to_process, - )?; + ); } } SourceModuleImportResolution::Local(module_ref) => { if !processed.contains(&module_ref.file_path) { - to_process.push((module_ref.file_path, module_ref.module_name, module_ref.path_segments)); + to_process.push(( + module_ref.file_path.clone(), + module_ref.module_name, + module_ref.path_segments, + )); } + return Ok(Some(module_ref.file_path)); } SourceModuleImportResolution::External => {} } - Ok(()) + Ok(None) } /// Queue implicit source stdlib helper modules that generated Rust may reference without a source import. @@ -76,9 +82,10 @@ fn queue_implicit_stdlib_helpers( incan_source_stdlib_module_paths: &mut HashMap, processed: &HashSet, to_process: &mut Vec<(PathBuf, String, Vec)>, -) -> Result<(), String> { - if uses_iterator_adapter_surface(program) { - queue_incan_stdlib_source_module( +) -> Result, String> { + let mut queued = Vec::new(); + if uses_iterator_adapter_surface(program) + && let Some(path) = queue_incan_stdlib_source_module( &[ stdlib::STDLIB_ROOT.to_string(), "derives".to_string(), @@ -87,17 +94,25 @@ fn queue_implicit_stdlib_helpers( incan_source_stdlib_module_paths, processed, to_process, - )?; + )? + { + queued.push(path); } - if uses_result_combinator_surface(program) { - queue_incan_stdlib_source_module( + if uses_result_combinator_surface(program) + && let Some(path) = queue_incan_stdlib_source_module( &[stdlib::STDLIB_ROOT.to_string(), "result".to_string()], incan_source_stdlib_module_paths, processed, to_process, - )?; + )? + { + queued.push(path); } - Ok(()) + Ok(queued) +} + +fn dependency_edge_key(path: &Path) -> String { + path.to_string_lossy().to_string() } /// Collect source modules referenced by a test file's imports. @@ -118,6 +133,7 @@ pub(crate) fn collect_source_modules_for_test( let mut processed = HashSet::new(); let mut to_process: Vec<(PathBuf, String, Vec)> = Vec::new(); let mut incan_source_stdlib_module_paths: HashMap = HashMap::new(); + let mut dependency_edges: HashMap> = HashMap::new(); queue_implicit_stdlib_helpers( test_ast, @@ -128,7 +144,7 @@ pub(crate) fn collect_source_modules_for_test( // ---- Walk test AST to find user module imports ---- for resolved in resolve_program_source_imports(test_ast, source_root, Some(source_root)) { - queue_resolved_source_import( + let _ = queue_resolved_source_import( resolved.resolution, &mut incan_source_stdlib_module_paths, &processed, @@ -142,6 +158,8 @@ pub(crate) fn collect_source_modules_for_test( continue; } processed.insert(file_path.clone()); + let file_key = dependency_edge_key(&file_path); + dependency_edges.entry(file_key.clone()).or_default(); let source = fs::read_to_string(&file_path) .map_err(|e| format!("Failed to read source module '{}': {}", file_path.display(), e))?; @@ -184,17 +202,29 @@ pub(crate) fn collect_source_modules_for_test( eprint!("{}", diagnostics::format_error(&fp, &source, warn)); } - queue_implicit_stdlib_helpers(&ast, &mut incan_source_stdlib_module_paths, &processed, &mut to_process)?; + for dependency_path in + queue_implicit_stdlib_helpers(&ast, &mut incan_source_stdlib_module_paths, &processed, &mut to_process)? + { + dependency_edges + .entry(file_key.clone()) + .or_default() + .insert(dependency_edge_key(&dependency_path)); + } // Walk this module's imports for transitive dependencies. let current_base = file_path.parent().unwrap_or(source_root); for resolved in resolve_program_source_imports(&ast, current_base, Some(source_root)) { - queue_resolved_source_import( + if let Some(dependency_path) = queue_resolved_source_import( resolved.resolution, &mut incan_source_stdlib_module_paths, &processed, &mut to_process, - )?; + )? { + dependency_edges + .entry(file_key.clone()) + .or_default() + .insert(dependency_edge_key(&dependency_path)); + } } modules.push(ParsedModule { @@ -206,7 +236,7 @@ pub(crate) fn collect_source_modules_for_test( }); } - Ok(modules) + topologically_sort_modules(modules, &dependency_edges).map_err(|err| err.message) } #[cfg(test)] @@ -257,6 +287,39 @@ mod tests { Ok(()) } + #[test] + fn test_runner_orders_source_dependencies_before_dependents() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let src_dir = tmp.path().join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write(src_dir.join("helper.incn"), "pub def target() -> int:\n return 1\n")?; + std::fs::write( + src_dir.join("functions.incn"), + "from helper import target as target_builder\n\npub public_target = alias target_builder\n", + )?; + + let test_source = "from functions import public_target\n"; + let tokens = lexer::lex(test_source).map_err(|errs| errs[0].message.clone())?; + let ast = parser::parse_with_context(&tokens, Some("tests/test_alias.incn"), None) + .map_err(|errs| errs[0].message.clone())?; + + let modules = collect_source_modules_for_test(&ast, &src_dir, None, None, None)?; + let helper_idx = modules + .iter() + .position(|module| module.file_path.ends_with("helper.incn")) + .ok_or("expected helper.incn to be collected")?; + let functions_idx = modules + .iter() + .position(|module| module.file_path.ends_with("functions.incn")) + .ok_or("expected functions.incn to be collected")?; + + assert!( + helper_idx < functions_idx, + "test runner should order dependency modules before dependent modules" + ); + Ok(()) + } + #[test] fn test_runner_collects_implicit_result_helper_modules() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 051aaa0ae..f00bfef2c 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -620,15 +620,32 @@ impl TypeChecker { } return Some(false); } + Some(self.rust_type_args_compatible(actual_args.as_slice(), expected_args.as_slice())) + } + + fn rust_type_args_compatible(&self, actual_args: &[ResolvedType], expected_args: &[ResolvedType]) -> bool { if actual_args.len() != expected_args.len() { - return Some(actual_args.is_empty() && expected_args.is_empty()); + return (actual_args.is_empty() && expected_args.iter().all(Self::rust_type_arg_is_unknown_placeholder)) + || (expected_args.is_empty() && actual_args.iter().all(Self::rust_type_arg_is_unknown_placeholder)); + } + actual_args.iter().zip(expected_args.iter()).all(|(actual, expected)| { + Self::rust_type_arg_is_unknown_placeholder(actual) + || Self::rust_type_arg_is_unknown_placeholder(expected) + || self.types_compatible(actual, expected) + }) + } + + fn rust_type_arg_is_unknown_placeholder(arg: &ResolvedType) -> bool { + match arg { + ResolvedType::Unknown => true, + ResolvedType::RustPath(path) => { + matches!( + path.trim().as_bytes(), + [b'?'] | [b'{', b'u', b'n', b'k', b'n', b'o', b'w', b'n', b'}'] + ) + } + _ => false, } - Some( - actual_args - .iter() - .zip(expected_args.iter()) - .all(|(actual, expected)| self.types_compatible(actual, expected)), - ) } /// Whether a Rust signature parameter is the implicit receiver (`self`/`&self`/`&mut self`). diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index c1904035c..d42ff74cf 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -3720,6 +3720,15 @@ def render[T](value: Label[T]) -> str: fn seed_async_rust_method_probe( checker: &mut TypeChecker, manifest_dir: &std::path::Path, +) -> Result<(), Box> { + seed_async_rust_method_probe_with_options_param(checker, manifest_dir, "demo::CsvReadOptions") +} + +#[cfg(feature = "rust_inspect")] +fn seed_async_rust_method_probe_with_options_param( + checker: &mut TypeChecker, + manifest_dir: &std::path::Path, + options_param_type: &str, ) -> Result<(), Box> { checker.rust_inspect_cache.insert_test_item( manifest_dir, @@ -3756,7 +3765,7 @@ fn seed_async_rust_method_probe( }, RustParam { name: Some("options".to_string()), - type_display: "demo::CsvReadOptions".to_string(), + type_display: options_param_type.to_string(), }, ], return_type: "Result<(), demo::DataFusionError>".to_string(), @@ -3855,6 +3864,38 @@ pub async def register_csv_with_await() -> None: Ok(()) } +#[cfg(feature = "rust_inspect")] +#[test] +fn test_rust_async_method_call_accepts_imported_type_with_unknown_generic_metadata() +-> Result<(), Box> { + let source = r#" +import std.async +from rust::demo import SessionContext +from rust::demo import CsvReadOptions +from rust::demo import make_context +from rust::demo import make_options + +pub async def register_csv_with_unknown_options_metadata() -> None: + ctx = make_context() + opts = make_options() + match await ctx.register_csv("orders", "orders.csv", opts): + Ok(_) => pass + Err(_) => pass +"#; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; + let mut checker = TypeChecker::new(); + let tmp = seeded_rust_inspect_workspace()?; + checker.set_rust_inspect_manifest_dir(tmp.path().to_path_buf()); + seed_async_rust_method_probe_with_options_param(&mut checker, tmp.path(), "demo::CsvReadOptions")?; + checker.check_program(&ast).map_err(|errs| { + std::io::Error::other(format!( + "expected Rust async method to accept an imported Rust type when metadata has only unknown generic args: {errs:?}" + )) + })?; + Ok(()) +} + #[cfg(feature = "rust_inspect")] #[test] fn test_rust_async_method_call_without_await_is_rejected() -> Result<(), Box> { @@ -8227,6 +8268,7 @@ def f(w: Widget) -> None: Ok(()) } +#[cfg(feature = "rust_inspect")] #[test] fn test_rust_extension_trait_associated_call_records_param_shape() -> Result<(), Box> { let source = r#" @@ -8314,6 +8356,7 @@ def f(encoded: bytes) -> None: Ok(()) } +#[cfg(feature = "rust_inspect")] #[test] fn test_rust_extension_trait_associated_call_records_param_shape_without_receiver_metadata() -> Result<(), Box> { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index ce6aa47a9..9f9e3c211 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1733,6 +1733,60 @@ def main() -> None: Ok(()) } +#[test] +fn test_accepts_public_alias_of_imported_item_issue631() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "public_alias_test_reexport", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("helper.incn"), + r#"pub def target() -> int: + return 1 +"#, + )?; + fs::write( + src_dir.join("functions.incn"), + r#"from helper import target as target_builder + +pub public_target = alias target_builder +"#, + )?; + fs::write( + &main_path, + r#"from functions import public_target + + +def main() -> None: + assert public_target() == 1 +"#, + )?; + fs::write( + tests_dir.join("test_alias.incn"), + r#"from functions import public_target + + +def test_alias() -> None: + assert public_target() == 1 +"#, + )?; + + let build_output = run_incan( + tmp.path(), + &["build", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&build_output, "incan build for public alias issue631"); + + let test_path = tests_dir.join("test_alias.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success(&test_output, "incan test for public alias issue631"); + Ok(()) +} + #[test] fn build_frozen_uses_existing_lockfile_without_network() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/codegen_snapshot_tests.rs b/tests/codegen_snapshot_tests.rs index 5f374a39b..1219e2eca 100644 --- a/tests/codegen_snapshot_tests.rs +++ b/tests/codegen_snapshot_tests.rs @@ -117,6 +117,7 @@ fn generate_rust_with_widgets_manifest(source: &str) -> String { normalize_codegen_output(&code) } +#[cfg(feature = "rust_inspect")] fn generate_rust_with_substrait_probe(source: &str) -> String { let tmp = match tempfile::tempdir() { Ok(tmp) => tmp, @@ -2358,6 +2359,7 @@ fn test_issue217_rust_enum_match_bindings_codegen() { insta::assert_snapshot!("issue217_rust_enum_match_bindings", rust_code); } +#[cfg(feature = "rust_inspect")] #[test] fn test_issue459_rust_enum_pattern_import_codegen() { let source = load_test_file("issue459_rust_enum_pattern_import"); diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index cba38374a..d4dc91a0a 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,11 +52,11 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release-candidate hardening**: The RC validation loop against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, and `std.regex` Rust-boundary text borrowing (#615, #616, #617, #620, #621, #622, #624, #625, #627). +- **Release-candidate hardening**: The RC validation loop against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, and test-runner source-module ordering for public aliases of imported items (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). -- **Compiler**: Rust interop fixes cover retained enum-pattern imports, owned Incan values passed to shared borrowed generic Rust parameters, `Vec` adaptation from `list[T]`, prost-style inherent and trait-provided `decode(buf: T)` calls, extension-trait import retention from metadata, and trait-typed local annotation diagnostics (#459, #506, #128, #609, #612, #447, #462). +- **Compiler**: Rust interop fixes cover retained enum-pattern imports, owned Incan values passed to shared borrowed generic Rust parameters, `Vec` adaptation from `list[T]`, prost-style inherent and trait-provided `decode(buf: T)` calls, extension-trait import retention from metadata, Rust bridge type identity across re-exported and placeholder-generic argument displays, and trait-typed local annotation diagnostics (#459, #506, #128, #609, #612, #447, #630, #462). - **Compiler**: Typechecking and lowering now preserve more generic information, including `Self` substitution on instantiated generic receivers, generic model/class field access, generic instance methods, list literals containing `Self`, trait/supertrait upcasts, imported prost oneof payloads, explicit call-site generic cycles, and locals initialized from static factory calls (#237, #231, #253, #230, #184, #218, #279, #252, #255). - **Compiler**: Runtime and generated-manifest hardening routes collection/JSON extraction and decorator misuse stubs through named helpers, keeps Tokio and `serde_json` behind feature gates, prunes unused generated Rust without broad `allow` attributes, and improves runtime diagnostics for f-string unknown symbols and collection/string conversion failures (#351, #157, #214, #71, #81). - **Tooling**: `incan test` reuses more generated harness state, isolates single-file runs, keeps project cwd stable, includes generated helper modules such as `std.result` when test files use helper-backed surfaces, and treats project manifests as one lock surface across scripts and test harness inputs (#268, #269, #271, #288, #378, #610, #505). From 9683eeb073772e0e0d8135ac90f2e5498a55ad34 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 15:29:12 +0200 Subject: [PATCH 12/58] docs - polish v0.3 release notes --- workspaces/docs-site/docs/release_notes/0_3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index d4dc91a0a..088681b00 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release-candidate hardening**: The RC validation loop against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, and test-runner source-module ordering for public aliases of imported items (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, and test-runner source-module ordering for public aliases of imported items (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 984d565cca08afb8dcbe87d5aa8157253db2c1fd Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 16:06:04 +0200 Subject: [PATCH 13/58] chore - strengthen agent publication guardrails --- .agents/learnings.md | 6 +++-- .agents/skills/create-github-issue/SKILL.md | 26 ++++++++++++++++++- .agents/skills/flag-compiler-bug/SKILL.md | 7 +++-- .../review-incan-source-quality/SKILL.md | 1 + 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.agents/learnings.md b/.agents/learnings.md index fbec9d6f5..8f7d1400c 100644 --- a/.agents/learnings.md +++ b/.agents/learnings.md @@ -13,8 +13,9 @@ Reference document for AI agents. These are hard-won insights from past RFC impl - **Duckborrowing is codegen policy**: when work touches lowering/emission, call arguments, collection literals, returns, match scrutinees, Rust interop, or generated `.clone()`s, route ownership through `src/backend/ir/ownership.rs` / `ValueUseSite` and update trait-bound inference/tests instead of adding local `.clone()`, `.as_ref()`, `str(...)`, or `.into()` workarounds. (Issue #121, April 2026) - **Forward receivers by borrow shape**: when lowering wrappers or adapters around methods, model `self` as the callable's actual receiver borrow (`&Owner` or `&mut Owner`) and pass that through directly; inserting `.clone()` hides a compiler lowering shortcut as user-visible ownership behavior and breaks mutable receiver support. (RFC 036 / issue #170) - **PR conflict resolution must use `origin/main` as the merge base**: when a user asks to merge main or resolve PR conflicts, inspect and merge against `origin/main`, not the local `main` branch copy. Local `main` can lag the remote and give a false “merged main” result while GitHub still reports conflicts. (RFC 015 branch, April 2026) +- **Match ladders are a smell**: in authored `.incn` code, avoid nested `match` ladders that only peel `Option`/enum variants before continuing; prefer `if let`, early returns, or a focused `match` with shallow arms. Do not "fix" the ladder by creating a forest of one-use helpers; keep helpers only when they name a real concept. (InQL #25 source-quality cleanup, May 2026) - **Name repeated kind checks**: when the language lacks grouped pattern arms, do not duplicate long `kind == A or kind == B ...` chains across functions; hide the grouping behind one predicate/helper so later enum-surface changes do not drift between call sites. (Prism output-column cleanup, April 2026) -- **Never expose local paths**: Shareable artifacts must use repo-relative paths or plain command names; absolute workstation paths like `/Users/...` leak personal details and should be blocked in hooks and avoided in docs, issues, PR text, and examples. +- **Never expose local paths**: Shareable artifacts must use repo-relative paths or plain command names; absolute workstation paths like `/Users/...` leak personal details and should be blocked in hooks and avoided in docs, issues, PR text, and examples. For GitHub issues/PRs, run the check before the first create/update call because edit history can preserve the original text. - **New AST variants need full pipeline wiring**: adding a `Statement`/`Expr` variant is never parser-only; you must update formatter, feature scanners, typechecker, lowering, and any AST bridge layers in the same change or compilation/tests will break in scattered places (RFC 027 Phase 6). - **Method defaults need emission tests**: method default arguments can pass typechecking but still emit invalid Rust if the method-call emitter does not synthesize omitted defaults; when adding or using method defaults, include a run/codegen test that calls the method with omitted arguments. (Issue #286) - **Stdlib function defaults cross stages**: imported stdlib free-function defaults must be preserved from AST loading through typechecking, lowering, and emission; a typechecker-only default fix can still produce generated Rust calls with missing arguments. Add end-to-end run coverage when public stdlib APIs rely on omitted defaults. (RFC 064 / issue #342) @@ -79,13 +80,14 @@ Reference document for AI agents. These are hard-won insights from past RFC impl - **Markdown prose should not be short-wrapped**: when generating authored Markdown documents, do not manually wrap prose to artificial line lengths; use natural paragraph lines unless the structure itself requires line breaks, because short-wrapped prose reads fragmented and creates noisy diffs for whitepapers, RFCs, and research docs. (Pallay research docs, April 2026) - **RFC phase before code**: when using `ralph-loop` for an RFC implementation, move the RFC to `In Progress` and confirm the implementation plan/checklist before writing code; do not treat lifecycle edits and phase confirmation as a post-implementation cleanup step. (RFC 016 / issue #327) - **North-star first for RFCs**: when a maintainer asks for an RFC, start from the desired end-state contract and only discuss incremental slices after that north-star is explicit; do not reflexively shrink RFC scope into the smallest implementable change unless the user asks for rollout planning. +- **Pre-RFC research lives in root `__research__`**: capture exploratory north-star notes, spikes, and design parking lots under the repository root `__research__/` directory, not `.agents/`; `.agents/` is for reusable agent workflows/learnings rather than project research artifacts. (Android/Incan rustc bridge ideation, May 2026) - **RFCs are decision records, not diaries**: keep RFCs as moment-in-time intent/status documents, and move implementation details, drift notes, and current behavior into regular docs or release notes with issue links instead of rewriting RFC narrative in flight. - **Generated references are gates**: when adding or changing a stdlib namespace or language registry entry, run `cargo run -p incan_core --bin generate_lang_reference` and verify no diff before publishing; `make pre-commit` alone may miss generated `language/reference/language.md` drift that CI enforces. (RFC 065 / issue #343) - **Implementation work must check dev version first**: before landing an implementation on the active dev line, verify the repo's actual source-of-truth version instead of assuming an older release train from stale docs or a worker worktree; at minimum, implementation work should bump `-dev.N` by one and update any versioned docs/release-note targets that track `main`. (Issue #333, April 2026) - **Stdlib closeouts need reference-nav parity**: when a stdlib issue changes a module's implementation shape or canonical docs path, update the stdlib reference index, MkDocs nav, and any legacy standalone reference page together; otherwise modules like `std.testing` drift out of the `language/reference/stdlib/` structure even when release notes and how-to docs were updated. (Issues #301/#302) - **RFC lifecycle edits need graph updates**: When an RFC is renamed, moved, split, or superseded, update inbound RFC references and regenerate `workspaces/docs-site/docs/_snippets/rfcs_refs.md` plus `workspaces/docs-site/docs/_snippets/tables/rfcs_index.md`; otherwise the docs graph silently points at stale RFC paths and statuses. (RFC 012/050/051 split) - **RFC checklist gaps force replanning**: In a Ralph loop, unchecked RFC `Progress Checklist` items are scope failures, not PR-body residual risks; route them back through Plan -> Do -> Check -> Act before publishing, and only use closing keywords after the RFC is fully checked and bumped. (RFC 084 / issue #453) -- **Ralph worktrees live in encero/tmp**: for `ralph-loop`, every implementation must start in a fresh worktree under `/Users/danny/Development/encero/tmp`, not `/tmp` and not the primary checkout, so VS Code discovers the workspace and orchestration stays consistent. (RFC 016 / issue #327) +- **Ralph worktrees live in encero/tmp**: for `ralph-loop`, every implementation must start in a fresh worktree under the workspace root's `encero/tmp` directory, not a system temp directory and not the primary checkout, so VS Code discovers the workspace and orchestration stays consistent. (RFC 016 / issue #327) ## Builtin trait stubs and stdlib method lookup (#193) diff --git a/.agents/skills/create-github-issue/SKILL.md b/.agents/skills/create-github-issue/SKILL.md index 3069f56cb..ec65e1d5f 100644 --- a/.agents/skills/create-github-issue/SKILL.md +++ b/.agents/skills/create-github-issue/SKILL.md @@ -25,7 +25,30 @@ description: Drafts a GitHub issue title and body using the target repository's 6. **Produce the draft** — See [Output format](#output-format). For YAML `body` block semantics (markdown vs textarea vs dropdown vs checkboxes), use [reference.md](reference.md). -7. **Optional: related PR or branch** — If the issue tracks follow-up work, mention the branch or PR link in the body where the template has a freeform section. +7. **Run the public text safety gate** — Before showing the draft to the user or calling any GitHub issue creation/update tool, inspect the exact title and body that will be published. Public issue text must not contain local absolute paths, personal workspace paths, usernames from local paths, machine-specific temporary directories, shell prompts, or environment details that are not needed to reproduce the issue. Replace them with repo-relative paths, generic commands, or neutral placeholders. + +8. **Optional: related PR or branch** — If the issue tracks follow-up work, mention the branch or PR link in the body where the template has a freeform section. + +## Public Text Safety Gate + +GitHub issues are public by default and edits may remain visible in history. Treat the first publication as permanent. + +Before creating or updating an issue, manually scan the title and body for these banned patterns: + +- local absolute paths, including `/Users/...`, `/home/...`, `/private/...`, `/tmp/...`, and `C:\Users\...` +- personal workspace segments copied from a local checkout path +- commands that invoke a binary through an absolute local path +- local machine usernames, hostnames, shell prompts, or editor-specific transient paths +- private notes, agent state paths, scratch files, or temporary repro directories + +Use these replacements instead: + +- repo-relative paths such as `examples/session_read_transform_write_csv.incn` +- generic commands such as `incan run examples/session_read_transform_write_csv.incn` +- neutral environment descriptions such as `macOS`, `Linux`, `release/v0.3`, or `Incan 0.3.0-rc6` +- short repro files embedded directly in the issue body when possible + +If the only known command uses an absolute local path, rewrite it before publication. Do not publish first and clean it up afterward. ## Fallbacks @@ -88,3 +111,4 @@ Actual: Compiler panics with ... - [ ] Dropdown and checkbox options match **that file’s** YAML, not another project’s. - [ ] Required sections are filled or explicitly flagged as missing. - [ ] Title prefix and labels match the YAML when present. +- [ ] Public text safety gate passed on the exact issue title/body before publishing. diff --git a/.agents/skills/flag-compiler-bug/SKILL.md b/.agents/skills/flag-compiler-bug/SKILL.md index e9023ea45..e4eb9a1aa 100644 --- a/.agents/skills/flag-compiler-bug/SKILL.md +++ b/.agents/skills/flag-compiler-bug/SKILL.md @@ -45,7 +45,7 @@ Do not flag a compiler bug when the issue is more likely: Capture: -- exact command +- exact local command for your private working notes, then derive a sanitized public command before filing - exact observed output, panic text, or wrong behavior - affected stage if inferable: parser, typechecker, lowering, emission, runtime boundary, formatter, CLI, or LSP - current branch / commit / task context @@ -100,7 +100,7 @@ Include: - minimal repro - expected vs actual behavior -- exact command +- sanitized command, using repo-relative paths and tool names instead of local absolute binary paths - logs / panic text / snapshot diff if relevant - affected stage - blocker status @@ -108,6 +108,8 @@ Include: - environment and commit context - related issue, RFC, branch, or task +Before creating the issue, run the `create-github-issue` public text safety gate on the exact title/body you will publish. Do not publish absolute local paths such as `/Users/...`, `/home/...`, `/private/...`, `/tmp/...`, or commands that expose a local checkout path. If the private reproduction used a local compiler binary, publish a generic equivalent such as `incan run path/to/repro.incn` and keep commit/version information in the Environment section. + If the current workflow permits creating the GitHub issue directly, do that after the duplicate check. Otherwise return the ready-to-file draft. ### 6. Return to the original task @@ -138,5 +140,6 @@ If a real workaround exists, continue the task and explicitly record: - Repro is minimal and copy-pastable. - Duplicate search is explicit, not assumed. +- Public issue text is sanitized before the first GitHub create/update call. - Blocking vs workaround judgment is stated plainly. - The original task is either paused honestly or resumed with a real workaround. diff --git a/.agents/skills/review-incan-source-quality/SKILL.md b/.agents/skills/review-incan-source-quality/SKILL.md index 470fc640b..fdb418487 100644 --- a/.agents/skills/review-incan-source-quality/SKILL.md +++ b/.agents/skills/review-incan-source-quality/SKILL.md @@ -109,6 +109,7 @@ Flag Incan source that has: - `@rust.extern`, `rusttype`, or `rust.module` used to avoid writing expressible Incan behavior; - design narrowing or backend fallback justified by “Incan cannot do this” without local examples, tests, or probe evidence; - sentinel initialization such as `value = 0` only to satisfy later branch assignment; +- nested `match` ladders that only peel `Option`/enum variants before continuing, when `if let`, early returns, or a focused shallow `match` would state the same control flow more directly; also flag helper forests that merely hide the ladder one branch at a time; - verbose `match` blocks that just rewrap a `Result` where `?` would read naturally; - verbose `match` blocks that only transform one `Result` branch where RFC 070 combinators such as `map`, `map_err`, `and_then`, or `or_else` would state the intent directly; - unnecessary type noise when inference or a local helper would be clearer; From 5650a286f0af0c4c23260fd0562d51b1af19745a Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 18:24:56 +0200 Subject: [PATCH 14/58] bugfix - preserve question-mark propagation in comprehensions (#633) (#635) --- .../src/diagnostics/catalog/errors/types.rs | 9 + .../ir/emit/expressions/comprehensions.rs | 416 +++++++++++++++++- src/frontend/typechecker/check_expr/comps.rs | 2 + .../typechecker/check_expr/control_flow.rs | 18 +- src/frontend/typechecker/tests.rs | 42 +- tests/codegen_snapshot_tests.rs | 28 ++ tests/integration_tests.rs | 74 ++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 8 files changed, 576 insertions(+), 15 deletions(-) diff --git a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs index 2095cda06..22a9e0db6 100644 --- a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs +++ b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs @@ -429,6 +429,15 @@ pub fn incompatible_error_type(expected: &str, found: &str, span: Span) -> Compi .with_hint("Use map_err to convert the error type, or add a From implementation") } +pub fn try_without_result_return(span: Span) -> CompileError { + CompileError::type_error( + "Cannot use '?' here: the enclosing function does not return Result[_, E]".to_string(), + span, + ) + .with_note("The '?' operator unwraps Ok(value) or returns early with Err(error)") + .with_hint("Change the enclosing function return type to Result[T, E], or handle the Result with match") +} + pub fn testing_marker_runtime_call_not_supported(name: &str, span: Span) -> CompileError { CompileError::type_error( format!("'{}' is a test marker decorator and cannot be called at runtime", name), diff --git a/src/backend/ir/emit/expressions/comprehensions.rs b/src/backend/ir/emit/expressions/comprehensions.rs index c5042b96a..34d4bd50c 100644 --- a/src/backend/ir/emit/expressions/comprehensions.rs +++ b/src/backend/ir/emit/expressions/comprehensions.rs @@ -8,11 +8,14 @@ use proc_macro2::TokenStream; use quote::quote; -use super::super::super::expr::{BuiltinFn, IrExprKind, IrGeneratorClause, Pattern, TypedExpr}; +use super::super::super::expr::{ + BuiltinFn, FormatPart, IrCallArg, IrDictEntry, IrExprKind, IrGeneratorClause, IrListEntry, Pattern, TypedExpr, +}; use super::super::super::ownership::{ ComprehensionIterationPlan, dict_comprehension_key_needs_clone, plan_dict_comprehension_iteration, plan_list_comprehension_iteration, plan_owned_iterator_source, }; +use super::super::super::stmt::{AssignTarget, IrStmt, IrStmtKind}; use super::super::super::types::IrType; use super::super::{EmitError, IrEmitter}; @@ -112,20 +115,28 @@ impl<'a> IrEmitter<'a> { // ---- Context: iterator setup ---- let pattern_tokens = self.emit_pattern(pattern); let elem = self.emit_expr(element)?; + let body_can_propagate = Self::expr_contains_try(element) || filter.is_some_and(Self::expr_contains_try); if let Some(iter) = self.emit_direct_comprehension_iterable(iterable)? { + if body_can_propagate { + return self.emit_direct_list_comp_loop(iter, pattern_tokens, elem, filter); + } return self.emit_direct_list_comp(iter, pattern_tokens, elem, filter); } let iter = self.emit_expr(iterable)?; let is_range = self.is_range_iterable(iterable); let iter_wrapped = quote! { (#iter) }; - - match plan_list_comprehension_iteration( + let plan = plan_list_comprehension_iteration( Self::comprehension_iterable_item_ty(&iterable.ty), is_range, filter.is_some(), - ) { + ); + if body_can_propagate { + return self.emit_list_comp_loop(plan, iter_wrapped, pattern, pattern_tokens, elem, filter); + } + + match plan { ComprehensionIterationPlan::RangeFilter => { let Some(filter) = filter else { return Err(EmitError::Unsupported( @@ -211,6 +222,9 @@ impl<'a> IrEmitter<'a> { let pattern_tokens = self.emit_pattern(pattern); let key_tokens = self.emit_expr(key)?; let value_tokens = self.emit_expr(value)?; + let body_can_propagate = Self::expr_contains_try(key) + || Self::expr_contains_try(value) + || filter.is_some_and(Self::expr_contains_try); // ---- Context: key ownership for collected map entries ---- // Dict comprehensions build `(key, value)` tuples left-to-right. For non-Copy keys we clone before the tuple so @@ -224,11 +238,27 @@ impl<'a> IrEmitter<'a> { }; if let Some(iter) = self.emit_direct_comprehension_iterable(iterable)? { + if body_can_propagate { + return self.emit_direct_dict_comp_loop(iter, pattern_tokens, cloned_key, value_tokens, filter); + } return self.emit_direct_dict_comp(iter, pattern_tokens, cloned_key, value_tokens, filter); } let iter = self.emit_expr(iterable)?; - match plan_dict_comprehension_iteration(Self::comprehension_iterable_item_ty(&iterable.ty), filter.is_some()) { + let plan = + plan_dict_comprehension_iteration(Self::comprehension_iterable_item_ty(&iterable.ty), filter.is_some()); + if body_can_propagate { + return self.emit_dict_comp_loop( + plan, + quote! { (#iter) }, + pattern, + pattern_tokens, + (cloned_key, value_tokens), + filter, + ); + } + + match plan { ComprehensionIterationPlan::FilterMapCloneBinding => { let Some(filter) = filter else { return Err(EmitError::Unsupported( @@ -367,6 +397,103 @@ impl<'a> IrEmitter<'a> { } } + /// Emit a direct-iterator list comprehension as an imperative block. + /// + /// This path is used when the element or filter contains `?`. A Rust iterator closure would make `?` target the + /// closure's element-returning type instead of the enclosing Incan function's `Result` return type. + fn emit_direct_list_comp_loop( + &self, + iter: TokenStream, + pattern: TokenStream, + elem: TokenStream, + filter: Option<&TypedExpr>, + ) -> Result { + let body = self.emit_list_comp_push_body(elem, filter)?; + Ok(quote! {{ + let mut __incan_list = Vec::new(); + for #pattern in (#iter) { + #body + } + __incan_list + }}) + } + + /// Emit a planned list comprehension as an imperative block. + fn emit_list_comp_loop( + &self, + plan: ComprehensionIterationPlan, + iter: TokenStream, + pattern: &Pattern, + pattern_tokens: TokenStream, + elem: TokenStream, + filter: Option<&TypedExpr>, + ) -> Result { + let body = self.emit_list_comp_push_body(elem, filter)?; + match plan { + ComprehensionIterationPlan::RangeDirect | ComprehensionIterationPlan::RangeFilter => Ok(quote! {{ + let mut __incan_list = Vec::new(); + for #pattern_tokens in #iter { + #body + } + __incan_list + }}), + ComprehensionIterationPlan::IterCopied => Ok(quote! {{ + let mut __incan_list = Vec::new(); + for #pattern_tokens in #iter.iter().copied() { + #body + } + __incan_list + }}), + ComprehensionIterationPlan::IterCloned => Ok(quote! {{ + let mut __incan_list = Vec::new(); + for #pattern_tokens in #iter.iter().cloned() { + #body + } + __incan_list + }}), + ComprehensionIterationPlan::FilterMapCloneBinding => { + let item_binding = Self::filter_map_item_binding(pattern, &pattern_tokens); + Ok(quote! {{ + let mut __incan_list = Vec::new(); + for #item_binding in #iter.iter() { + let #pattern_tokens = (*#item_binding).clone(); + #body + } + __incan_list + }}) + } + ComprehensionIterationPlan::FilterMapCopyBinding => { + let item_binding = Self::filter_map_item_binding(pattern, &pattern_tokens); + Ok(quote! {{ + let mut __incan_list = Vec::new(); + for #item_binding in #iter.iter() { + let #pattern_tokens = *#item_binding; + #body + } + __incan_list + }}) + } + } + } + + /// Emit one list-comprehension loop body, preserving filter semantics when present. + fn emit_list_comp_push_body( + &self, + elem: TokenStream, + filter: Option<&TypedExpr>, + ) -> Result { + if let Some(filter) = filter { + let filter_tokens = self.emit_expr(filter)?; + Ok(quote! { + if #filter_tokens { + __incan_list.push(#elem); + } + }) + } else { + Ok(quote! { __incan_list.push(#elem); }) + } + } + /// Emit a dict comprehension over an iterable expression that already returns owned values for closure binding. fn emit_direct_dict_comp( &self, @@ -393,4 +520,283 @@ impl<'a> IrEmitter<'a> { Ok(quote! { (#iter).map(|#pattern| (#key, #value)).collect::>() }) } } + + /// Emit a direct-iterator dict comprehension as an imperative block for propagating body expressions. + fn emit_direct_dict_comp_loop( + &self, + iter: TokenStream, + pattern: TokenStream, + key: TokenStream, + value: TokenStream, + filter: Option<&TypedExpr>, + ) -> Result { + let body = self.emit_dict_comp_insert_body(key, value, filter)?; + Ok(quote! {{ + let mut __incan_dict = std::collections::HashMap::new(); + for #pattern in (#iter) { + #body + } + __incan_dict + }}) + } + + /// Emit a planned dict comprehension as an imperative block. + fn emit_dict_comp_loop( + &self, + plan: ComprehensionIterationPlan, + iter: TokenStream, + pattern: &Pattern, + pattern_tokens: TokenStream, + key_value: (TokenStream, TokenStream), + filter: Option<&TypedExpr>, + ) -> Result { + let (key, value) = key_value; + let body = self.emit_dict_comp_insert_body(key, value, filter)?; + match plan { + ComprehensionIterationPlan::IterCopied => Ok(quote! {{ + let mut __incan_dict = std::collections::HashMap::new(); + for #pattern_tokens in #iter.iter().copied() { + #body + } + __incan_dict + }}), + ComprehensionIterationPlan::IterCloned => Ok(quote! {{ + let mut __incan_dict = std::collections::HashMap::new(); + for #pattern_tokens in #iter.iter().cloned() { + #body + } + __incan_dict + }}), + ComprehensionIterationPlan::FilterMapCloneBinding => { + let item_binding = Self::filter_map_item_binding(pattern, &pattern_tokens); + Ok(quote! {{ + let mut __incan_dict = std::collections::HashMap::new(); + for #item_binding in #iter.iter() { + let #pattern_tokens = (*#item_binding).clone(); + #body + } + __incan_dict + }}) + } + ComprehensionIterationPlan::FilterMapCopyBinding => { + let item_binding = Self::filter_map_item_binding(pattern, &pattern_tokens); + Ok(quote! {{ + let mut __incan_dict = std::collections::HashMap::new(); + for #item_binding in #iter.iter() { + let #pattern_tokens = *#item_binding; + #body + } + __incan_dict + }}) + } + ComprehensionIterationPlan::RangeDirect | ComprehensionIterationPlan::RangeFilter => { + unreachable!("dict comprehensions do not use range-specific iteration plans") + } + } + } + + /// Emit one dict-comprehension loop body, preserving filter semantics when present. + fn emit_dict_comp_insert_body( + &self, + key: TokenStream, + value: TokenStream, + filter: Option<&TypedExpr>, + ) -> Result { + if let Some(filter) = filter { + let filter_tokens = self.emit_expr(filter)?; + Ok(quote! { + if #filter_tokens { + __incan_dict.insert(#key, #value); + } + }) + } else { + Ok(quote! { __incan_dict.insert(#key, #value); }) + } + } + + /// Return whether an expression subtree contains `?` and therefore cannot be emitted inside a non-Result Rust + /// iterator closure. + fn expr_contains_try(expr: &TypedExpr) -> bool { + match &expr.kind { + IrExprKind::Try(_) => true, + IrExprKind::BinOp { left, right, .. } => Self::expr_contains_try(left) || Self::expr_contains_try(right), + IrExprKind::UnaryOp { operand, .. } + | IrExprKind::Await(operand) + | IrExprKind::Cast { expr: operand, .. } + | IrExprKind::NumericResize { expr: operand, .. } + | IrExprKind::InteropCoerce { expr: operand, .. } => Self::expr_contains_try(operand), + IrExprKind::Call { func, args, .. } => { + Self::expr_contains_try(func) || args.iter().any(Self::call_arg_contains_try) + } + IrExprKind::BuiltinCall { args, .. } => args.iter().any(Self::expr_contains_try), + IrExprKind::MethodCall { receiver, args, .. } | IrExprKind::KnownMethodCall { receiver, args, .. } => { + Self::expr_contains_try(receiver) || args.iter().any(Self::call_arg_contains_try) + } + IrExprKind::Field { object, .. } => Self::expr_contains_try(object), + IrExprKind::Index { object, index } => Self::expr_contains_try(object) || Self::expr_contains_try(index), + IrExprKind::Slice { + target, + start, + end, + step, + } => { + Self::expr_contains_try(target) + || start.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + || end.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + || step.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + } + IrExprKind::ListComp { + element, + iterable, + filter, + .. + } => { + Self::expr_contains_try(element) + || Self::expr_contains_try(iterable) + || filter.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + } + IrExprKind::DictComp { + key, + value, + iterable, + filter, + .. + } => { + Self::expr_contains_try(key) + || Self::expr_contains_try(value) + || Self::expr_contains_try(iterable) + || filter.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + } + IrExprKind::Generator { element, clauses } => { + Self::expr_contains_try(element) || clauses.iter().any(Self::generator_clause_contains_try) + } + IrExprKind::List(items) => items.iter().any(Self::list_entry_contains_try), + IrExprKind::Dict(entries) => entries.iter().any(Self::dict_entry_contains_try), + IrExprKind::Set(items) | IrExprKind::Tuple(items) => items.iter().any(Self::expr_contains_try), + IrExprKind::Struct { fields, .. } => fields.iter().any(|(_, expr)| Self::expr_contains_try(expr)), + IrExprKind::If { + condition, + then_branch, + else_branch, + } => { + Self::expr_contains_try(condition) + || Self::expr_contains_try(then_branch) + || else_branch.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + } + IrExprKind::Match { scrutinee, arms } => { + Self::expr_contains_try(scrutinee) + || arms.iter().any(|arm| { + arm.guard.as_ref().is_some_and(Self::expr_contains_try) || Self::expr_contains_try(&arm.body) + }) + } + IrExprKind::Closure { body, .. } => Self::expr_contains_try(body), + IrExprKind::Block { stmts, value } => { + stmts.iter().any(Self::stmt_contains_try) + || value.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + } + IrExprKind::Loop { body } => body.iter().any(Self::stmt_contains_try), + IrExprKind::Race { arms, .. } => arms + .iter() + .any(|arm| Self::expr_contains_try(&arm.awaitable) || Self::expr_contains_try(&arm.body)), + IrExprKind::Range { start, end, .. } => { + start.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + || end.as_ref().is_some_and(|expr| Self::expr_contains_try(expr)) + } + IrExprKind::Format { parts } => parts.iter().any(|part| match part { + FormatPart::Literal(_) => false, + FormatPart::Expr { expr, .. } => Self::expr_contains_try(expr), + }), + IrExprKind::Unit + | IrExprKind::None + | IrExprKind::Bool(_) + | IrExprKind::Int(_) + | IrExprKind::IntLiteral(_) + | IrExprKind::Float(_) + | IrExprKind::Decimal(_) + | IrExprKind::String(_) + | IrExprKind::Bytes(_) + | IrExprKind::Var { .. } + | IrExprKind::StaticRead { .. } + | IrExprKind::StaticBinding { .. } + | IrExprKind::AssociatedFunction { .. } + | IrExprKind::Literal(_) + | IrExprKind::FieldsList(_) + | IrExprKind::SerdeToJson + | IrExprKind::SerdeFromJson(_) => false, + } + } + + fn call_arg_contains_try(arg: &IrCallArg) -> bool { + Self::expr_contains_try(&arg.expr) + } + + fn list_entry_contains_try(entry: &IrListEntry) -> bool { + match entry { + IrListEntry::Element(expr) | IrListEntry::Spread(expr) => Self::expr_contains_try(expr), + } + } + + fn dict_entry_contains_try(entry: &IrDictEntry) -> bool { + match entry { + IrDictEntry::Pair(key, value) => Self::expr_contains_try(key) || Self::expr_contains_try(value), + IrDictEntry::Spread(expr) => Self::expr_contains_try(expr), + } + } + + fn generator_clause_contains_try(clause: &IrGeneratorClause) -> bool { + match clause { + IrGeneratorClause::For { iterable, .. } => Self::expr_contains_try(iterable), + IrGeneratorClause::If(condition) => Self::expr_contains_try(condition), + } + } + + fn stmt_contains_try(stmt: &IrStmt) -> bool { + match &stmt.kind { + IrStmtKind::Expr(expr) | IrStmtKind::Let { value: expr, .. } | IrStmtKind::Yield(expr) => { + Self::expr_contains_try(expr) + } + IrStmtKind::Assign { target, value } => { + Self::assign_target_contains_try(target) || Self::expr_contains_try(value) + } + IrStmtKind::CompoundAssign { target, value, .. } => { + Self::assign_target_contains_try(target) || Self::expr_contains_try(value) + } + IrStmtKind::Return(value) | IrStmtKind::Break { value, .. } => { + value.as_ref().is_some_and(Self::expr_contains_try) + } + IrStmtKind::While { condition, body, .. } => { + Self::expr_contains_try(condition) || body.iter().any(Self::stmt_contains_try) + } + IrStmtKind::For { iterable, body, .. } => { + Self::expr_contains_try(iterable) || body.iter().any(Self::stmt_contains_try) + } + IrStmtKind::Loop { body, .. } | IrStmtKind::Block(body) => body.iter().any(Self::stmt_contains_try), + IrStmtKind::If { + condition, + then_branch, + else_branch, + } => { + Self::expr_contains_try(condition) + || then_branch.iter().any(Self::stmt_contains_try) + || else_branch + .as_ref() + .is_some_and(|body| body.iter().any(Self::stmt_contains_try)) + } + IrStmtKind::Match { scrutinee, arms } => { + Self::expr_contains_try(scrutinee) + || arms.iter().any(|arm| { + arm.guard.as_ref().is_some_and(Self::expr_contains_try) || Self::expr_contains_try(&arm.body) + }) + } + IrStmtKind::Continue(_) => false, + } + } + + fn assign_target_contains_try(target: &AssignTarget) -> bool { + match target { + AssignTarget::Field { object, .. } => Self::expr_contains_try(object), + AssignTarget::Index { object, index } => Self::expr_contains_try(object) || Self::expr_contains_try(index), + AssignTarget::Var(_) | AssignTarget::StaticBinding(_) | AssignTarget::Static(_) => false, + } + } } diff --git a/src/frontend/typechecker/check_expr/comps.rs b/src/frontend/typechecker/check_expr/comps.rs index 8dcd294a3..6b84de5a9 100644 --- a/src/frontend/typechecker/check_expr/comps.rs +++ b/src/frontend/typechecker/check_expr/comps.rs @@ -94,6 +94,7 @@ impl TypeChecker { let prev_in_async_body = self.in_async_body; self.in_async_body = false; + let prev_return_error_type = self.current_return_error_type.take(); let param_types: Vec<_> = params .iter() @@ -114,6 +115,7 @@ impl TypeChecker { .collect(); let return_ty = self.check_expr(body); + self.current_return_error_type = prev_return_error_type; self.in_async_body = prev_in_async_body; self.symbols.exit_scope(); diff --git a/src/frontend/typechecker/check_expr/control_flow.rs b/src/frontend/typechecker/check_expr/control_flow.rs index 48f7028b7..fc94b7730 100644 --- a/src/frontend/typechecker/check_expr/control_flow.rs +++ b/src/frontend/typechecker/check_expr/control_flow.rs @@ -311,14 +311,16 @@ impl TypeChecker { return ResolvedType::Unknown; } - if let (Some(inner_err), Some(expected_err)) = (inner_ty.result_err_type(), &self.current_return_error_type) - && !self.types_compatible(inner_err, expected_err) - { - self.errors.push(errors::incompatible_error_type( - &expected_err.to_string(), - &inner_err.to_string(), - span, - )); + match (inner_ty.result_err_type(), self.current_return_error_type.clone()) { + (Some(inner_err), Some(expected_err)) if !self.types_compatible(inner_err, &expected_err) => { + self.errors.push(errors::incompatible_error_type( + &expected_err.to_string(), + &inner_err.to_string(), + span, + )); + } + (Some(_), None) => self.errors.push(errors::try_without_result_return(span)), + _ => {} } inner_ty.result_ok_type().cloned().unwrap_or(ResolvedType::Unknown) diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index d42ff74cf..1fbcf4a43 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -5351,6 +5351,44 @@ def foo() -> Result[int, str]: assert!(result.is_err()); } +#[test] +fn test_try_requires_result_return_type() { + let source = r#" +def foo() -> int: + x: Result[int, str] = Ok(42) + return x? +"#; + let errors = check_str_err(source, "try in non-Result function should fail typechecking"); + assert!( + errors + .iter() + .any(|err| err.message.contains("enclosing function does not return Result")), + "expected non-Result enclosing function diagnostic, got {errors:?}" + ); +} + +#[test] +fn test_try_does_not_cross_closure_boundary() { + let source = r#" +def parse_value() -> Result[int, str]: + return Ok(42) + +def foo() -> Result[int, str]: + callback = () => parse_value()? + return Ok(callback()) +"#; + let errors = check_str_err( + source, + "try in closure should not target enclosing Result-returning function", + ); + assert!( + errors + .iter() + .any(|err| err.message.contains("enclosing function does not return Result")), + "expected closure boundary diagnostic, got {errors:?}" + ); +} + #[test] fn test_sleep_requires_float() { let source = r#" @@ -10189,10 +10227,12 @@ def main() -> None: fn test_stdlib_import_only_facades_reexport_imported_types() { let source = r#" from std.datetime.civil import Date, TimeDelta +from std.datetime.error import DateTimeError -def main() -> None: +def main() -> Result[None, DateTimeError]: renewal = Date.fromisoformat("2026-04-14")? + TimeDelta.days(30) print(renewal.isoformat()) + return Ok(None) "#; assert_check_ok(source); } diff --git a/tests/codegen_snapshot_tests.rs b/tests/codegen_snapshot_tests.rs index 1219e2eca..ef3235d69 100644 --- a/tests/codegen_snapshot_tests.rs +++ b/tests/codegen_snapshot_tests.rs @@ -1236,6 +1236,34 @@ fn test_collections_codegen() { ); } +#[test] +fn test_issue633_question_mark_list_comprehension_codegen_uses_loop() { + let source = r#" +def parse_value(value: int) -> Result[int, str]: + return Ok(value) + + +def parse_all(values: list[int]) -> Result[list[int], str]: + return Ok([parse_value(value)? for value in values]) + + +def main() -> None: + match parse_all([1, 2, 3]): + Ok(values) => println(values[0]) + Err(err) => println(err) +"#; + let rust_code = generate_rust(source); + let compact = rust_code.split_whitespace().collect::(); + assert!( + compact.contains("letmut__incan_list=Vec::new();forvaluein(values).iter().copied(){__incan_list.push(parse_value(value)?);}__incan_list"), + "expected issue633 comprehension to lower to an outer-function loop, got:\n{rust_code}" + ); + assert!( + !compact.contains(".map(|value|parse_value(value)?)"), + "question-mark comprehension must not lower into an element-returning Rust map closure:\n{rust_code}" + ); +} + #[test] fn test_list_repeat_codegen() { let source = load_test_file("list_repeat"); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 596d3c9b7..bd51805b9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4133,6 +4133,80 @@ def main() -> None: Ok(()) } + #[test] + fn test_question_mark_list_comprehension_propagates_result_issue633() -> Result<(), Box> { + let output = run_incan_source( + r#" +def parse_value(value: int) -> Result[int, str]: + if value == 2: + return Err("bad value") + return Ok(value) + + +def parse_all(values: list[int]) -> Result[list[int], str]: + return Ok([parse_value(value)? for value in values]) + + +def main() -> None: + match parse_all([1, 2, 3]): + Ok(values) => println(values[0]) + Err(err) => println(err) +"#, + ); + assert!( + output.status.success(), + "question-mark list comprehension regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!(lines, vec!["bad value"], "unexpected issue633 output:\n{stdout}"); + Ok(()) + } + + #[test] + fn test_question_mark_dict_comprehension_propagates_result_issue633() -> Result<(), Box> { + let output = run_incan_source( + r#" +def parse_key(value: int) -> Result[str, str]: + if value == 2: + return Err("bad key") + return Ok(str(value)) + + +def parse_map(values: list[int]) -> Result[dict[str, int], str]: + return Ok({parse_key(value)?: value for value in values}) + + +def main() -> None: + match parse_map([1, 2, 3]): + Ok(values) => println(values["1"]) + Err(err) => println(err) +"#, + ); + assert!( + output.status.success(), + "question-mark dict comprehension regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!(lines, vec!["bad key"], "unexpected issue633 dict output:\n{stdout}"); + Ok(()) + } + #[test] fn test_result_map_err_accepts_capturing_inline_closure() -> Result<(), Box> { let output = Command::new(incan_debug_binary()) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 088681b00..140cecf5a 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, and test-runner source-module ordering for public aliases of imported items (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, and `?` propagation in comprehension bodies (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 08dedfcc5886a28543ae2e2ee1540a2b0a1fcc10 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 19:09:42 +0200 Subject: [PATCH 15/58] bugfix - preserve decorated function signatures in API metadata (#636) (#637) --- Cargo.lock | 18 ++--- Cargo.toml | 2 +- src/frontend/api_metadata.rs | 80 ++++++++++++++++++- .../docs-site/docs/release_notes/0_3.md | 2 +- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e1387d58..8db128a1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc6" +version = "0.3.0-rc7" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index cda1140de..f94dc328d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc6" +version = "0.3.0-rc7" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/frontend/api_metadata.rs b/src/frontend/api_metadata.rs index a9816f43f..64fe5c46d 100644 --- a/src/frontend/api_metadata.rs +++ b/src/frontend/api_metadata.rs @@ -560,12 +560,44 @@ fn api_function( docstring, decorators: decorators_metadata(&function.decorators, checker), type_params: type_params(&export.type_params), - params: params(&export.params), - return_type: type_ref_from_resolved(&export.return_type), - is_async: export.is_async, + params: source_function_params(function, checker), + return_type: source_function_return_type(function, checker), + is_async: function.is_async(), } } +/// Build the source-declared callable parameter surface for API documentation metadata. +/// +/// User-defined decorators can rebind a public function symbol to an ordinary callable value. That callable type is the +/// right contract for lowering and invocation, but function API docs are attached to the source declaration and should +/// validate against the declaration's named parameters instead of an anonymous function-type projection. +fn source_function_params(function: &FunctionDecl, checker: &TypeChecker) -> Vec { + function + .params + .iter() + .map(|param| ParamExport { + name: param.node.name.clone(), + ty: type_ref_from_resolved(&crate::frontend::symbols::resolve_type( + ¶m.node.ty.node, + &checker.symbols, + )), + kind: match param.node.kind { + crate::frontend::ast::ParamKind::Normal => ParamKindExport::Normal, + crate::frontend::ast::ParamKind::RestPositional => ParamKindExport::RestPositional, + crate::frontend::ast::ParamKind::RestKeyword => ParamKindExport::RestKeyword, + }, + has_default: param.node.default.is_some(), + }) + .collect() +} + +fn source_function_return_type(function: &FunctionDecl, checker: &TypeChecker) -> TypeRef { + type_ref_from_resolved(&crate::frontend::symbols::resolve_type( + &function.return_type.node, + &checker.symbols, + )) +} + fn api_model( model: &ModelDecl, span: Span, @@ -1861,6 +1893,48 @@ pub def avg(values: List[float]) -> float: Ok(()) } + #[test] + fn checked_api_metadata_preserves_decorated_function_source_signature() -> Result<(), String> { + let source = r#" +def keep(func: (int) -> int) -> (int) -> int: + return func + +@keep +pub def decorated(value: int) -> int: + """Return the input value. + + Args: + value: Input value. + """ + return value +"#; + let metadata = metadata_for(source).map_err(|errs| format!("{errs:?}"))?; + let function = metadata + .declarations + .iter() + .find_map(|decl| match decl { + ApiDeclaration::Function(function) if function.name == "decorated" => Some(function), + _ => None, + }) + .ok_or_else(|| "expected decorated function metadata".to_string())?; + + assert_eq!(function.params.len(), 1); + assert_eq!(function.params[0].name, "value"); + assert_eq!( + function.params[0].ty, + TypeRef::Named { + name: "int".to_string(), + } + ); + + let diagnostics = validate_checked_api_docstrings(&[metadata]); + assert!( + diagnostics.is_empty(), + "expected decorated source signature to satisfy docstring validation, got {diagnostics:?}" + ); + Ok(()) + } + #[test] fn checked_api_docstring_validation_matches_overloaded_method_by_params() -> Result<(), String> { let source = r#" diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 140cecf5a..ecd8b8e42 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, and `?` propagation in comprehension bodies (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, and decorated-function source signatures in checked API metadata (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From b4b933088bd7e487c9139b60e25b5a427e47b1b1 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 20:52:12 +0200 Subject: [PATCH 16/58] bugfix - 638 materialize imported const str arguments (#639) --- Cargo.lock | 18 ++-- Cargo.toml | 2 +- src/backend/ir/conversions.rs | 83 ++++++++++++++----- src/backend/ir/emit/expressions/methods.rs | 6 +- .../ir/emit/expressions/methods/fast_paths.rs | 4 +- tests/integration_tests.rs | 58 +++++++++++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 7 files changed, 138 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8db128a1c..c3e0273d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc7" +version = "0.3.0-rc8" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index f94dc328d..74193a411 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc7" +version = "0.3.0-rc8" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/conversions.rs b/src/backend/ir/conversions.rs index c86ad6c71..25e2abfd3 100644 --- a/src/backend/ir/conversions.rs +++ b/src/backend/ir/conversions.rs @@ -519,16 +519,16 @@ fn determine_owned_storage_conversion(expr: &IrExpr, target_ty: Option<&IrType>) match (&expr.kind, target_ty) { (IrExprKind::String(_), Some(IrType::String)) => Conversion::ToString, (IrExprKind::StaticRead { .. }, Some(IrType::String | IrType::Generic(_))) - if matches!(expr.ty, IrType::StaticStr) => + if is_borrowed_string_like_type(&expr.ty) => { Conversion::ToString } - (IrExprKind::StaticRead { .. }, None) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, - (_, Some(IrType::String)) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (IrExprKind::StaticRead { .. }, None) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, + (_, Some(IrType::String)) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, (IrExprKind::String(_), Some(IrType::Generic(_))) => Conversion::ToString, - (_, Some(IrType::Generic(_))) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (_, Some(IrType::Generic(_))) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, (IrExprKind::String(_), None) => Conversion::ToString, - (_, None) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (_, None) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, _ if borrowed_expr_needs_owned_materialization(expr, target_ty) => Conversion::Clone, (IrExprKind::Var { access, .. }, Some(IrType::String)) if matches!(expr.ty, IrType::String) => match access { VarAccess::Move => Conversion::None, @@ -595,6 +595,16 @@ fn is_result_like_type(ty: &IrType) -> bool { } } +/// Return whether a source value has Rust borrowed/static string shape while representing Incan `str`. +fn is_borrowed_string_like_type(ty: &IrType) -> bool { + matches!(ty, IrType::StaticStr | IrType::StrRef | IrType::FrozenStr) +} + +/// Return whether an owned Incan sink needs borrowed/static string materialization. +fn borrowed_string_like_needs_owned_string(source_ty: &IrType, target_ty: Option<&IrType>) -> bool { + is_borrowed_string_like_type(source_ty) && matches!(target_ty, None | Some(IrType::String | IrType::Generic(_))) +} + /// Whether a value type came from Rust interop and can reasonably cross an Incan `str` boundary via `ToString`. /// /// Lowering maps `ResolvedType::RustPath` to `IrType::Struct(path)`, so the stable signal left in IR is a Rust-style @@ -685,23 +695,23 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: (IrExprKind::String(_), Some(IrType::String)) => Conversion::ToString, // Static const reads still represent Incan `str` at ordinary call sites. (IrExprKind::StaticRead { .. }, Some(IrType::String | IrType::Generic(_))) - if matches!(expr.ty, IrType::StaticStr) => + if is_borrowed_string_like_type(&expr.ty) => { Conversion::ToString } - (IrExprKind::StaticRead { .. }, None) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, - // Const `str` values lower as `&'static str` but still follow Incan owned-string semantics at call - // sites. - (_, Some(IrType::String)) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (IrExprKind::StaticRead { .. }, None) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, + // Const/imported `str` values can lower as borrowed/static Rust string shapes but still follow Incan + // owned-string semantics at call sites. + (_, Some(IrType::String)) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, // String literal to generic type param (e.g. assert_eq[T]) → owned String. // Typechecker constrains `T`; this keeps Incan `str` semantics in generic calls. (IrExprKind::String(_), Some(IrType::Generic(_))) => Conversion::ToString, // Generic `T` instantiated with Incan `str` must still materialize to owned `String`. - (_, Some(IrType::Generic(_))) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (_, Some(IrType::Generic(_))) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, // String literal with unknown target (enum variants, etc.) → .to_string() (IrExprKind::String(_), None) => Conversion::ToString, // Const `str` values need the same owned-string materialization when the target is inferred. - (_, None) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (_, None) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, // Borrowed method-chain results such as `box.as_ref()` must materialize owned values at Incan call // boundaries. _ if borrowed_expr_needs_owned_materialization(expr, target_ty) => Conversion::Clone, @@ -740,9 +750,12 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: match (&expr.kind, target_ty) { // String literal → .to_string() (IrExprKind::String(_), _) => Conversion::ToString, - (IrExprKind::StaticRead { .. }, _) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, - // Const `str` values remain owned `str` at the Incan surface even inside return-context calls. - (_, _) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (IrExprKind::StaticRead { .. }, _) if borrowed_string_like_needs_owned_string(&expr.ty, target_ty) => { + Conversion::ToString + } + // Const/imported `str` values remain owned `str` at the Incan surface even inside return-context + // calls. + (_, _) if borrowed_string_like_needs_owned_string(&expr.ty, target_ty) => Conversion::ToString, _ if borrowed_expr_needs_owned_materialization(expr, target_ty) => Conversion::Clone, _ if rust_value_needs_stringification(expr, target_ty) => Conversion::ToString, @@ -837,11 +850,11 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: // String literal assigned to String variable → .to_string() (IrExprKind::String(_), Some(IrType::String)) => Conversion::ToString, (IrExprKind::StaticRead { .. }, Some(IrType::String | IrType::Generic(_))) - if matches!(expr.ty, IrType::StaticStr) => + if is_borrowed_string_like_type(&expr.ty) => { Conversion::ToString } - (_, Some(IrType::String)) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (_, Some(IrType::String)) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, _ if borrowed_expr_needs_owned_materialization(expr, target_ty) => Conversion::Clone, (IrExprKind::Field { .. }, _) if matches!(expr.ty, IrType::String) && field_read_needs_owned_materialization(expr) => @@ -860,10 +873,10 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: match (&expr.kind, target_ty) { // String literal returned when function returns String → .to_string() (IrExprKind::String(_), Some(IrType::String)) => Conversion::ToString, - (IrExprKind::StaticRead { .. }, Some(IrType::String)) if matches!(expr.ty, IrType::StaticStr) => { + (IrExprKind::StaticRead { .. }, Some(IrType::String)) if is_borrowed_string_like_type(&expr.ty) => { Conversion::ToString } - (_, Some(IrType::String)) if matches!(expr.ty, IrType::StaticStr) => Conversion::ToString, + (_, Some(IrType::String)) if is_borrowed_string_like_type(&expr.ty) => Conversion::ToString, _ if borrowed_expr_needs_owned_materialization(expr, target_ty) => Conversion::Clone, // Non-Copy vars can move on last use; otherwise materialize an owned return value. (IrExprKind::Var { access, .. }, _) if !expr.ty.is_copy() => match access { @@ -1075,6 +1088,22 @@ mod tests { assert_eq!(conv, Conversion::ToString); } + #[test] + fn test_incan_function_frozen_str_var_to_string() { + let expr = IrExpr::new( + IrExprKind::Var { + name: "s".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::FrozenStr, + ); + let target = IrType::String; + + let conv = determine_conversion(&expr, Some(&target), ConversionContext::IncanFunctionArg); + assert_eq!(conv, Conversion::ToString); + } + #[test] fn test_incan_function_static_str_var_to_generic() { let expr = IrExpr::new( @@ -1091,6 +1120,22 @@ mod tests { assert_eq!(conv, Conversion::ToString); } + #[test] + fn test_assignment_frozen_str_var_to_string() { + let expr = IrExpr::new( + IrExprKind::Var { + name: "s".to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::FrozenStr, + ); + let target = IrType::String; + + let conv = determine_conversion(&expr, Some(&target), ConversionContext::Assignment); + assert_eq!(conv, Conversion::ToString); + } + #[test] fn test_incan_function_rust_path_value_to_string_param() { let expr = IrExpr::new( diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index 9937f6106..0482f1c64 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -37,9 +37,9 @@ use string_methods::emit_string_method; /// /// This deduplicates the pattern of: /// - Detecting `FrozenStr` receivers -/// - Unwrapping them via `.as_str()` +/// - Viewing them through `AsRef` pub(super) struct ReceiverInfo { - /// The receiver token stream (possibly wrapped in `.as_str()` for FrozenStr). + /// The receiver token stream, possibly viewed as `&str` for frozen/imported string values. pub(super) r: TokenStream, /// A borrow of the receiver: `&#r`. pub(super) r_borrow: TokenStream, @@ -50,7 +50,7 @@ impl ReceiverInfo { fn new(receiver_ty: &IrType, emitted: TokenStream) -> Self { let is_frozen_str = matches!(receiver_ty, IrType::FrozenStr); let r = if is_frozen_str { - quote! { #emitted.as_str() } + quote! { <_ as AsRef>::as_ref(&#emitted) } } else { emitted }; diff --git a/src/backend/ir/emit/expressions/methods/fast_paths.rs b/src/backend/ir/emit/expressions/methods/fast_paths.rs index 879cf0341..c2ffa2228 100644 --- a/src/backend/ir/emit/expressions/methods/fast_paths.rs +++ b/src/backend/ir/emit/expressions/methods/fast_paths.rs @@ -152,10 +152,10 @@ fn is_owned_string_type(ty: &IrType) -> bool { fn borrowed_str_tokens(ty: &IrType, emitted: TokenStream) -> TokenStream { match ty { IrType::StaticStr | IrType::StrRef => emitted, - IrType::FrozenStr => quote! { #emitted.as_str() }, + IrType::FrozenStr => quote! { <_ as AsRef>::as_ref(&#emitted) }, IrType::Ref(inner) | IrType::RefMut(inner) => match peel_refs(inner) { IrType::StaticStr | IrType::StrRef => emitted, - IrType::FrozenStr => quote! { #emitted.as_str() }, + IrType::FrozenStr => quote! { <_ as AsRef>::as_ref(#emitted) }, _ => quote! { <_ as AsRef>::as_ref(#emitted) }, }, _ => quote! { <_ as AsRef>::as_ref(&#emitted) }, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index bd51805b9..6f22c4863 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -9049,6 +9049,64 @@ def test_imported_pub_static_scalar_read() -> None: ); } + #[test] + fn e2e_imported_const_str_materializes_at_test_call_sites() { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "imported_const_str_materialization" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + let tests_dir = dir.join("tests"); + + if let Err(err) = std::fs::create_dir_all(&src_dir) { + panic!("failed to create src dir: {}", err); + } + if let Err(err) = std::fs::create_dir_all(&tests_dir) { + panic!("failed to create tests dir: {}", err); + } + if let Err(err) = std::fs::write(src_dir.join("registry.incn"), "pub const TOKEN: str = \"token\"\n") { + panic!("failed to write registry source: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_imported_const_str.incn"), + r#" +from std.testing import assert_eq +from registry import TOKEN + +def identity(value: str) -> str: + return value + +def test_imported_const_str_call_arguments_materialize() -> None: + local: str = TOKEN + assert_eq(identity(TOKEN), "token") + assert_eq(identity(TOKEN.to_string()), "token") + assert_eq(identity(local), "token") + assert_eq(TOKEN.upper(), "TOKEN") +"#, + ) { + panic!("failed to write imported const string test: {}", err); + } + + let output = run_incan_test(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "expected imported const str materialization test to succeed.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + !stderr.contains("str_as_str") && !stderr.contains("expected `String`, found `&str`"), + "imported const str should not leak raw Rust string shapes.\nstderr:\n{}", + stderr, + ); + } + #[test] fn e2e_empty_list_arguments_in_tests_preserve_string_element_type() -> Result<(), Box> { let dir = write_test_project( diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index ecd8b8e42..976a1e57d 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, and decorated-function source signatures in checked API metadata (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, and imported/decorator `const str` argument materialization (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 16f80d409464745799f7866687cc9b8fcba6bdc5 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 22 May 2026 22:29:12 +0200 Subject: [PATCH 17/58] bugfix - materialize imported decorator const str arguments (#638) (#641) --- Cargo.lock | 18 +++---- Cargo.toml | 2 +- src/backend/ir/lower/decl/functions.rs | 14 +++++- src/backend/ir/lower/decl/methods.rs | 5 +- tests/integration_tests.rs | 66 ++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3e0273d6..cae11f76a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc8" +version = "0.3.0-rc9" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 74193a411..eb984f30d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc8" +version = "0.3.0-rc9" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/lower/decl/functions.rs b/src/backend/ir/lower/decl/functions.rs index 1c6a6f829..94421b2cc 100644 --- a/src/backend/ir/lower/decl/functions.rs +++ b/src/backend/ir/lower/decl/functions.rs @@ -175,6 +175,15 @@ impl AstLowering { format!("__incan_decorated_{name}") } + /// Return the span used for synthetic decorator callee nodes. + /// + /// The full decorator factory call keeps the source decorator span for typechecker handoff. Nested synthetic + /// callees must not reuse that span because expression metadata is span-keyed and the factory result type would + /// otherwise overwrite the callee's callable signature during lowering. + pub(in crate::backend::ir::lower) fn decorator_synthetic_callee_span() -> ast::Span { + ast::Span::default() + } + /// Build an expression that resolves a decorator's path through ordinary expression lowering. pub(in crate::backend::ir::lower) fn decorator_path_expr( decorator: &ast::Decorator, @@ -220,14 +229,15 @@ impl AstLowering { is_absolute: decorator.node.path.is_absolute, segments: path[..path.len() - 1].to_vec(), }; - let base = Self::decorator_path_expr_from_import_path(&base_path, decorator.span); + let base = + Self::decorator_path_expr_from_import_path(&base_path, Self::decorator_synthetic_callee_span()); let method = path.last().cloned().unwrap_or_default(); Spanned::new( Expr::MethodCall(Box::new(base), method, Vec::new(), args), decorator.span, ) } else { - let callee = Self::decorator_path_expr(&decorator.node, decorator.span); + let callee = Self::decorator_path_expr(&decorator.node, Self::decorator_synthetic_callee_span()); Spanned::new(Expr::Call(Box::new(callee), Vec::new(), args), decorator.span) } } else { diff --git a/src/backend/ir/lower/decl/methods.rs b/src/backend/ir/lower/decl/methods.rs index 6e55c1e99..15759dbdf 100644 --- a/src/backend/ir/lower/decl/methods.rs +++ b/src/backend/ir/lower/decl/methods.rs @@ -73,14 +73,15 @@ impl AstLowering { is_absolute: decorator.node.path.is_absolute, segments: path[..path.len() - 1].to_vec(), }; - let base = Self::decorator_path_expr_from_import_path(&base_path, decorator.span); + let base = + Self::decorator_path_expr_from_import_path(&base_path, Self::decorator_synthetic_callee_span()); let method_name = path.last().cloned().unwrap_or_default(); Spanned::new( ast::Expr::MethodCall(Box::new(base), method_name, Vec::new(), args), decorator.span, ) } else { - let callee = Self::decorator_path_expr(&decorator.node, decorator.span); + let callee = Self::decorator_path_expr(&decorator.node, Self::decorator_synthetic_callee_span()); Spanned::new(ast::Expr::Call(Box::new(callee), Vec::new(), args), decorator.span) } } else { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 6f22c4863..4e8411cf8 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -9107,6 +9107,72 @@ def test_imported_const_str_call_arguments_materialize() -> None: ); } + #[test] + fn e2e_imported_decorator_factory_const_str_argument_materializes() { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "imported_decorator_const_str_materialization" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + let tests_dir = dir.join("tests"); + + if let Err(err) = std::fs::create_dir_all(&src_dir) { + panic!("failed to create src dir: {}", err); + } + if let Err(err) = std::fs::create_dir_all(&tests_dir) { + panic!("failed to create tests dir: {}", err); + } + if let Err(err) = std::fs::write( + src_dir.join("registry.incn"), + r#" +pub const TOKEN: str = "probe.value" + +def keep_int(func: (int) -> int) -> (int) -> int: + return func + +pub def registered(_name: str) -> Callable[(int) -> int, (int) -> int]: + return keep_int +"#, + ) { + panic!("failed to write registry source: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_imported_decorator_const_str.incn"), + r#" +from std.testing import assert_eq +from registry import TOKEN, registered + +@registered(TOKEN) +def increment(value: int) -> int: + return value + 1 + +def test_imported_decorator_factory_const_str_argument_materializes() -> None: + assert_eq(increment(1), 2) +"#, + ) { + panic!("failed to write imported decorator const string test: {}", err); + } + + let output = run_incan_test(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "expected imported decorator factory const str materialization test to succeed.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + !stderr.contains("expected `String`, found `&str`"), + "decorator factory const str argument should materialize as an owned string.\nstderr:\n{}", + stderr, + ); + } + #[test] fn e2e_empty_list_arguments_in_tests_preserve_string_element_type() -> Result<(), Box> { let dir = write_test_project( From c46a153ba8f061ee726e4e812f6eef7093af1fa1 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 23 May 2026 08:49:24 +0200 Subject: [PATCH 18/58] chore - speed up test suite (#642) --- .config/nextest.toml | 11 + Makefile | 12 +- src/backend/project/cargo_toml.rs | 5 +- src/backend/project/generator.rs | 75 + src/backend/project/runner.rs | 40 +- src/cli/test_runner/execution.rs | 15 + src/lsp/backend.rs | 6 +- tests/cli_integration.rs | 211 +- tests/generated_rust_artifact_tests.rs | 25 +- ...nerated_rust_callability_artifact_tests.rs | 91 +- tests/generated_rust_native_consumer_tests.rs | 4 + tests/integration_tests.rs | 15502 +++++++--------- tests/std_encoding_algorithm_modules.rs | 82 +- 13 files changed, 6985 insertions(+), 9094 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 5f040bc2c..9101ea19e 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -4,6 +4,9 @@ [store] dir = "target/nextest" +[test-groups] +nested-cargo = { max-threads = 12 } + [profile.default] # Fail fast: stop after the first test failure during local development. fail-fast = true @@ -20,3 +23,11 @@ status-level = "slow" final-status-level = "slow" slow-timeout = "30s" leak-timeout = "2s" + +[[profile.default.overrides]] +filter = 'binary_id(incan::integration_tests) | binary_id(incan::cli_integration) | binary_id(incan::std_encoding_algorithm_modules) | binary_id(incan::generated_rust_artifact_tests) | binary_id(incan::generated_rust_callability_artifact_tests) | binary_id(incan::generated_rust_native_consumer_tests)' +test-group = 'nested-cargo' + +[[profile.ci.overrides]] +filter = 'binary_id(incan::integration_tests) | binary_id(incan::cli_integration) | binary_id(incan::std_encoding_algorithm_modules) | binary_id(incan::generated_rust_artifact_tests) | binary_id(incan::generated_rust_callability_artifact_tests) | binary_id(incan::generated_rust_native_consumer_tests)' +test-group = 'nested-cargo' diff --git a/Makefile b/Makefile index d1943a25a..ce81dc66c 100644 --- a/Makefile +++ b/Makefile @@ -6,12 +6,16 @@ TEST_VERBOSE ?= 0 ifeq ($(strip $(NEXTEST)),) ifeq ($(TEST_VERBOSE),1) -TEST_CMD = cargo test --all --verbose +TEST_CMD = cargo test --all --features lsp --verbose else -TEST_CMD = cargo test --all +TEST_CMD = cargo test --all --features lsp endif else -TEST_CMD = cargo nextest run --all --status-level all +ifeq ($(TEST_VERBOSE),1) +TEST_CMD = cargo nextest run --all --features lsp --status-level all +else +TEST_CMD = cargo nextest run --all --features lsp --status-level slow --final-status-level slow +endif endif # After `make build` / `make build-fast`, symlink ~/.cargo/bin/incan → target/debug/incan so `incan` on PATH (IDE run, @@ -202,7 +206,6 @@ pre-commit-full-gate: t2=$$(date +%s); \ echo "\033[1mRunning tests...\033[0m"; \ $(TEST_CMD); \ - cargo test --features lsp unchecked_lookup_hover --lib; \ echo "\033[32mDONE\033[0m"; \ t3=$$(date +%s); \ echo "\033[1mRunning clippy...\033[0m"; \ @@ -322,7 +325,6 @@ smoke-test-benchmarks-incan: .PHONY: smoke-test-core smoke-test-core: @$(MAKE) smoke-test-release - @$(MAKE) test-rust-inspect @$(MAKE) smoke-test-canary @$(MAKE) smoke-test-web-example @$(MAKE) smoke-test-nested-project-example diff --git a/src/backend/project/cargo_toml.rs b/src/backend/project/cargo_toml.rs index 5457ecac0..0efb08401 100644 --- a/src/backend/project/cargo_toml.rs +++ b/src/backend/project/cargo_toml.rs @@ -251,10 +251,11 @@ impl ProjectGenerator { }; // ---- Build bin/lib target ---- + let target_name = self.cargo_target_name(); let (bin, lib) = if self.is_binary { ( vec![BinTarget { - name: self.name.clone(), + name: target_name, path: "src/main.rs".into(), }], None, @@ -263,7 +264,7 @@ impl ProjectGenerator { ( vec![], Some(LibTarget { - name: self.name.clone(), + name: target_name, path: "src/lib.rs".into(), }), ) diff --git a/src/backend/project/generator.rs b/src/backend/project/generator.rs index 7df69e71c..521a30766 100644 --- a/src/backend/project/generator.rs +++ b/src/backend/project/generator.rs @@ -14,8 +14,10 @@ use std::path::{Path, PathBuf}; use crate::manifest::DependencySpec; use incan_core::lang::rust_keywords; +use sha2::{Digest as _, Sha256}; const MOD_INSERT_MARKER: &str = "// __INCAN_INSERT_MODS__"; +pub(crate) const GENERATED_CARGO_TARGET_DIR_ENV: &str = "INCAN_GENERATED_CARGO_TARGET_DIR"; // ============================================================================ // RFC 023: Stdlib module naming @@ -151,6 +153,79 @@ impl ProjectGenerator { self.run_profile = profile; } + /// Resolve the optional generated-project Cargo target override. + /// + /// This is primarily used by integration tests and smoke gates that compile many generated Rust projects from one + /// parent workspace. It lets those projects share dependency artifacts while keeping ordinary user invocations on + /// the parent-scoped default target directory. + pub(super) fn generated_cargo_target_dir_override() -> Option { + let raw = std::env::var_os(GENERATED_CARGO_TARGET_DIR_ENV)?; + let raw = PathBuf::from(raw); + if raw.as_os_str().is_empty() { + return None; + } + Some(Self::resolve_target_dir(raw)) + } + + pub(super) fn resolve_target_dir(target_dir: PathBuf) -> PathBuf { + if target_dir.is_absolute() { + target_dir + } else if let Ok(cwd) = std::env::current_dir() { + cwd.join(target_dir) + } else { + target_dir + } + } + + /// Cargo target name used for the generated binary or library target. + /// + /// When a caller opts into a broad shared target directory, multiple unrelated generated projects can have the same + /// user-facing project name (`main`, `consumer`, etc.). Cargo writes root binaries and libraries at + /// `target//`, so shared target dirs need a unique target name to avoid stale binary reuse + /// and parallel build collisions. Library target names stay stable because native Rust consumers import them as + /// crate names from generated library artifacts. + pub(super) fn cargo_target_name(&self) -> String { + if self.is_binary && Self::generated_cargo_target_dir_override().is_some() { + Self::shared_target_safe_name(&self.name, &self.output_dir) + } else { + self.name.clone() + } + } + + pub(super) fn shared_target_safe_name(name: &str, output_dir: &Path) -> String { + let mut normalized = name + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + if normalized.is_empty() { + normalized.push_str("incan_project"); + } + if !normalized + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_') + { + normalized.insert(0, '_'); + } + + let absolute_output_dir = if output_dir.is_absolute() { + output_dir.to_path_buf() + } else if let Ok(cwd) = std::env::current_dir() { + cwd.join(output_dir) + } else { + output_dir.to_path_buf() + }; + + let mut hasher = Sha256::new(); + hasher.update(name.as_bytes()); + hasher.update(b"\0"); + hasher.update(absolute_output_dir.to_string_lossy().as_bytes()); + let digest_bytes = hasher.finalize(); + let digest = hex::encode(&digest_bytes[..8]); + + format!("{normalized}_{digest}") + } + /// Ensure the generated `src/` directory exists. fn ensure_generated_src_dir(&self) -> io::Result { let src_dir = self.output_dir.join("src"); diff --git a/src/backend/project/runner.rs b/src/backend/project/runner.rs index fafa0dbee..a041eda3a 100644 --- a/src/backend/project/runner.rs +++ b/src/backend/project/runner.rs @@ -46,16 +46,14 @@ impl ProjectGenerator { /// tests, and benchmark checks. Sharing a parent-scoped target dir lets those generated crates reuse compiled /// dependencies. fn cargo_target_dir(&self) -> PathBuf { + if let Some(target_dir) = Self::generated_cargo_target_dir_override() { + return target_dir; + } + let base_dir = self.output_dir.parent().unwrap_or(self.output_dir.as_path()); let target_dir = base_dir.join(".cargo-target"); - if target_dir.is_absolute() { - target_dir - } else if let Ok(cwd) = std::env::current_dir() { - cwd.join(target_dir) - } else { - target_dir - } + Self::resolve_target_dir(target_dir) } /// Build the project using cargo. @@ -167,14 +165,14 @@ impl ProjectGenerator { /// Get the path to the built binary. pub fn binary_path(&self) -> PathBuf { - self.cargo_target_dir().join("release").join(&self.name) + self.cargo_target_dir().join("release").join(self.cargo_target_name()) } /// Get the path to the binary produced for `incan run`. pub fn run_binary_path(&self) -> PathBuf { self.cargo_target_dir() .join(self.run_profile_binary_dir()) - .join(&self.name) + .join(self.cargo_target_name()) } } @@ -258,4 +256,28 @@ mod tests { ); Ok(()) } + + #[test] + fn shared_target_safe_name_distinguishes_same_project_name_by_output_dir() -> Result<(), Box> + { + let tmp = tempfile::tempdir()?; + let first = ProjectGenerator::shared_target_safe_name("demo-app", &tmp.path().join("one")); + let second = ProjectGenerator::shared_target_safe_name("demo-app", &tmp.path().join("two")); + + assert_ne!(first, second); + assert!(first.starts_with("demo_app_"), "unexpected target name: {first}"); + assert!( + first.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_'), + "target name should be Rust-identifier safe: {first}" + ); + Ok(()) + } + + #[test] + fn relative_target_dirs_resolve_against_current_working_dir() -> Result<(), Box> { + let cwd = std::env::current_dir()?; + let target_dir = ProjectGenerator::resolve_target_dir(PathBuf::from("target/shared-generated")); + assert_eq!(target_dir, cwd.join("target/shared-generated")); + Ok(()) + } } diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 6aad0842c..5bb91af8f 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -435,8 +435,23 @@ fn normalize_runner_assert_statements(ast: &mut Program) { /// By default this reuses the project's main `target/` so existing dependency artifacts are shared across regular /// builds and `incan test` runs for better DX. /// +/// Set `INCAN_TEST_SHARED_TARGET_DIR` to force all generated test harnesses into a caller-provided target directory. +/// This is primarily useful for integration tests that create many throwaway project roots but should still reuse the +/// same compiled harness dependencies. +/// /// Set `INCAN_TEST_ISOLATED_TARGET_DIR` to one of `1|true|yes|on` to use `target/incan_test_runner` instead. fn shared_cargo_target_dir(project_root: &Path) -> PathBuf { + if let Ok(shared_target_dir) = std::env::var("INCAN_TEST_SHARED_TARGET_DIR") { + let shared_target_dir = PathBuf::from(shared_target_dir); + if shared_target_dir.is_absolute() { + return shared_target_dir; + } + if let Ok(cwd) = std::env::current_dir() { + return cwd.join(shared_target_dir); + } + return shared_target_dir; + } + let absolute_project_root = if project_root.is_absolute() { project_root.to_path_buf() } else if let Ok(cwd) = std::env::current_dir() { diff --git a/src/lsp/backend.rs b/src/lsp/backend.rs index a29305a93..dbb7f7092 100644 --- a/src/lsp/backend.rs +++ b/src/lsp/backend.rs @@ -1054,7 +1054,7 @@ mod lsp_api_metadata_preview_tests { } #[test] - fn checked_api_previews_use_callable_rebound_function_signature() -> Result<(), String> { + fn checked_api_previews_preserve_source_signature_for_callable_rebound() -> Result<(), String> { let source = r#" pub def endpoint() -> str: return "raw" @@ -1089,8 +1089,8 @@ pub def endpoint() -> str: .ok_or_else(|| "expected checked function preview".to_string())?; assert!( - preview.markdown.contains("pub def endpoint(id: int) -> bool"), - "expected rebound callable signature in LSP preview, got:\n{}", + preview.markdown.contains("pub def endpoint() -> str"), + "expected source declaration signature in LSP preview, got:\n{}", preview.markdown ); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 9f9e3c211..e4acb4126 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -24,6 +24,10 @@ fn run_incan(current_dir: &Path, args: &[&str]) -> Result Result<(), Box> { +fn run_accepts_generic_rust_param_scenarios_share_one_generated_project() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let main_path = write_minimal_project( tmp.path(), - "cli_borrowed_generic_rust_param_project", + "cli_generic_rust_param_scenarios", r#" [rust-dependencies] borrow_helper = { path = "rust/borrow_helper" } +decode_helper = { path = "rust/decode_helper" } +decode_trait_helper = { path = "rust/decode_trait_helper" } +prost = { path = "rust/prost" } +prost-types = { path = "rust/prost-types" } "#, )?; fs::write( &main_path, + r#"from borrowed_generic import borrowed_generic_case +from by_value_decode import by_value_decode_case +from cross_crate_decode import cross_crate_decode_case +from trait_by_value_decode import trait_by_value_decode_case + +def main() -> None: + println(borrowed_generic_case()) + println(by_value_decode_case()) + println(trait_by_value_decode_case()) + println(cross_crate_decode_case()) +"#, + )?; + fs::write( + tmp.path().join("src").join("borrowed_generic.incn"), r#"from rust::borrow_helper import takes_ref model Payload: name: str -def main() -> None: +pub def borrowed_generic_case() -> str: payload = Payload(name="demo") - println(takes_ref(payload)) + return f"borrowed:{takes_ref(payload)}" +"#, + )?; + fs::write( + tmp.path().join("src").join("by_value_decode.incn"), + r#"from rust::decode_helper import FileDescriptorSet +from rust::std::io import Cursor + +pub def by_value_decode_case() -> str: + mut cursor = Cursor.new(b"abc") + match FileDescriptorSet.decode(cursor): + Ok(_) => return "by_value:ok" + Err(_) => return "by_value:err" "#, )?; + fs::write( + tmp.path().join("src").join("trait_by_value_decode.incn"), + r#"from rust::decode_trait_helper import FileDescriptorSet, Message + +pub def trait_by_value_decode_case() -> str: + encoded = b"abc" + match FileDescriptorSet.decode(encoded.as_slice()): + Ok(_) => return "trait_by_value:ok" + Err(_) => return "trait_by_value:err" +"#, + )?; + fs::write( + tmp.path().join("src").join("cross_crate_decode.incn"), + r#"from rust::prost import Message +from rust::prost_types import FileDescriptorSet, ProducerPlan + +pub def cross_crate_decode_case() -> str: + producer = ProducerPlan.new() + encoded = producer.encode_to_vec() + match FileDescriptorSet.decode(encoded.as_slice()): + Ok(_) => return "cross_crate:ok" + Err(_) => return "cross_crate:err" +"#, + )?; + let helper_src = tmp.path().join("rust").join("borrow_helper").join("src"); fs::create_dir_all(&helper_src)?; fs::write( @@ -647,46 +706,6 @@ edition = "2021" helper_src.join("lib.rs"), "pub fn takes_ref(_value: &TValue) -> i64 { 1 }\n", )?; - - let output = run_incan( - tmp.path(), - &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], - )?; - - assert_success(&output, "incan run with borrowed generic Rust param"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains('1'), - "expected borrowed generic Rust helper output, got:\n{stdout}" - ); - Ok(()) -} - -#[test] -fn run_accepts_by_value_generic_decode_rust_param_issue609() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_minimal_project( - tmp.path(), - "cli_by_value_generic_decode_project", - r#" - -[rust-dependencies] -decode_helper = { path = "rust/decode_helper" } -"#, - )?; - fs::write( - &main_path, - r#"from rust::decode_helper import FileDescriptorSet -from rust::std::io import Cursor - - -def main() -> None: - mut cursor = Cursor.new(b"abc") - match FileDescriptorSet.decode(cursor): - Ok(_) => println("ok") - Err(_) => println("err") -"#, - )?; let helper_src = tmp.path().join("rust").join("decode_helper").join("src"); fs::create_dir_all(&helper_src)?; fs::write( @@ -715,45 +734,6 @@ impl FileDescriptorSet { Ok(Self) } } -"#, - )?; - - let output = run_incan( - tmp.path(), - &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], - )?; - - assert_success(&output, "incan run with by-value generic decode Rust param"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("ok"), - "expected by-value generic decode helper output, got:\n{stdout}" - ); - Ok(()) -} - -#[test] -fn run_accepts_trait_provided_by_value_generic_decode_rust_param_issue612() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_minimal_project( - tmp.path(), - "cli_trait_by_value_generic_decode_project", - r#" - -[rust-dependencies] -decode_trait_helper = { path = "rust/decode_trait_helper" } -"#, - )?; - fs::write( - &main_path, - r#"from rust::decode_trait_helper import FileDescriptorSet, Message - - -def main() -> None: - encoded = b"abc" - match FileDescriptorSet.decode(encoded.as_slice()): - Ok(_) => println("ok") - Err(_) => println("err") "#, )?; let helper_src = tmp.path().join("rust").join("decode_trait_helper").join("src"); @@ -788,51 +768,6 @@ impl Message for FileDescriptorSet { Ok(Self) } } -"#, - )?; - - let output = run_incan( - tmp.path(), - &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], - )?; - - assert_success( - &output, - "incan run with trait-provided by-value generic decode Rust param", - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("ok"), - "expected trait-provided by-value generic decode helper output, got:\n{stdout}" - ); - Ok(()) -} - -#[test] -fn run_accepts_cross_crate_trait_decode_slice_param_issue612() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_minimal_project( - tmp.path(), - "cli_cross_crate_trait_decode_project", - r#" - -[rust-dependencies] -prost = { path = "rust/prost" } -prost-types = { path = "rust/prost-types" } -"#, - )?; - fs::write( - &main_path, - r#"from rust::prost import Message -from rust::prost_types import FileDescriptorSet, ProducerPlan - - -def main() -> None: - producer = ProducerPlan.new() - encoded = producer.encode_to_vec() - match FileDescriptorSet.decode(encoded.as_slice()): - Ok(_) => println("ok") - Err(_) => println("err") "#, )?; let prost_src = tmp.path().join("rust").join("prost").join("src"); @@ -903,14 +838,12 @@ impl prost::Message for FileDescriptorSet { &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], )?; - assert_success( - &output, - "incan run with cross-crate trait-provided decode over explicit slice", - ); + assert_success(&output, "incan run with batched generic Rust param scenarios"); let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("ok"), - "expected cross-crate trait-provided decode helper output, got:\n{stdout}" + assert_eq!( + stdout.trim(), + "borrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok", + "expected batched generic Rust param output, got:\n{stdout}" ); Ok(()) } @@ -1720,16 +1653,14 @@ def main() -> None: "#, )?; - let output_dir = tmp.path().join("consumer_out"); - let consumer_build = run_incan( + let consumer_check = run_incan( &consumer_root, &[ - "build", + "--check", consumer_main.to_str().ok_or("consumer main path was not valid UTF-8")?, - output_dir.to_str().ok_or("output path was not valid UTF-8")?, ], )?; - assert_success(&consumer_build, "pub consumer build for public alias issue617"); + assert_success(&consumer_check, "pub consumer check for public alias issue617"); Ok(()) } diff --git a/tests/generated_rust_artifact_tests.rs b/tests/generated_rust_artifact_tests.rs index 9619d7856..fee173405 100644 --- a/tests/generated_rust_artifact_tests.rs +++ b/tests/generated_rust_artifact_tests.rs @@ -28,6 +28,10 @@ fn run_incan(current_dir: &Path, args: &[&str]) -> Result Result<(), Box Result<(), Box> { +fn generated_library_and_pub_dependency_consumer_artifacts_match_baseline() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let project_root = tmp.path().join("artifact_widgets_project"); let src_dir = project_root.join("src"); @@ -204,25 +208,6 @@ fn generated_library_artifact_matches_baseline() -> Result<(), Box Result<(), Box> { - let tmp = tempfile::tempdir()?; - let producer_root = tmp.path().join("artifact_widgets_project"); - let producer_src = producer_root.join("src"); - fs::create_dir_all(&producer_src)?; - fs::write( - producer_root.join("incan.toml"), - "[project]\nname = \"artifact_widgets_core\"\nversion = \"0.1.0\"\n", - )?; - write_fixture(&producer_src.join("widgets.incn"), "library_widgets.incn")?; - write_fixture(&producer_src.join("lib.incn"), "library_lib.incn")?; - - let producer_build = run_incan(&producer_root, &["build", "--lib"])?; - assert_success(&producer_build, "incan build --lib producer artifact"); - let consumer_root = tmp.path().join("artifact_consumer_project"); let consumer_src = consumer_root.join("src"); fs::create_dir_all(&consumer_src)?; diff --git a/tests/generated_rust_callability_artifact_tests.rs b/tests/generated_rust_callability_artifact_tests.rs index 8efe1ad67..47ecf67e8 100644 --- a/tests/generated_rust_callability_artifact_tests.rs +++ b/tests/generated_rust_callability_artifact_tests.rs @@ -23,6 +23,10 @@ fn run_incan(current_dir: &Path, args: &[&str]) -> Result String { - let mut out = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' && chars.peek() == Some(&'[') { - let _ = chars.next(); - for c in chars.by_ref() { - if c == 'm' { - break; - } - } - continue; - } - out.push(ch); - } - out -} - fn write_fixture_file(root: &Path, relative_path: &str, contents: &str) -> Result<(), Box> { let path = root.join(relative_path); if let Some(parent) = path.parent() { @@ -133,7 +110,7 @@ fn function_param_ty<'a>( } #[test] -fn build_lib_emits_package_facing_callable_artifact_layout() -> Result<(), Box> { +fn generated_callable_artifact_and_consumers_share_producer_build() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let producer = build_producer(tmp.path())?; let artifact = producer.join("target/lib"); @@ -178,26 +155,19 @@ fn build_lib_emits_package_facing_callable_artifact_layout() -> Result<(), Box Result<(), Box> -{ - let tmp = tempfile::tempdir()?; - build_producer(tmp.path())?; - let (consumer, main_path) = write_consumer( + let (owned_consumer, owned_main_path) = write_consumer( tmp.path(), "owned_consumer", include_str!("fixtures/generated_rust_callability/consumer_owned/src/main.incn"), )?; - let out_dir = consumer.join("out"); + let out_dir = owned_consumer.join("out"); let build_output = run_incan( - &consumer, + &owned_consumer, &[ "build", - main_path.to_str().ok_or("main path was not valid UTF-8")?, + owned_main_path.to_str().ok_or("main path was not valid UTF-8")?, out_dir.to_str().ok_or("out path was not valid UTF-8")?, ], )?; @@ -218,48 +188,5 @@ fn consumer_can_call_owned_callable_export_across_generated_package_boundary() - "expected final generated Rust project to call imported callable export, got:\n{generated_main}" ); - let run_output = run_incan( - &consumer, - &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], - )?; - assert_success(&run_output, "consumer incan run for owned callable import"); - assert_eq!(String::from_utf8_lossy(&run_output.stdout).trim(), "2\n3\n4"); - Ok(()) -} - -#[test] -fn borrowed_callable_export_is_characterized_as_current_pub_consumer_blocker() -> Result<(), Box> -{ - let tmp = tempfile::tempdir()?; - build_producer(tmp.path())?; - let (consumer, main_path) = write_consumer( - tmp.path(), - "borrowed_consumer", - include_str!("fixtures/generated_rust_callability/consumer_borrowed_blocker/src/main.incn"), - )?; - - let out_dir = consumer.join("out"); - let build_output = run_incan( - &consumer, - &[ - "build", - main_path.to_str().ok_or("main path was not valid UTF-8")?, - out_dir.to_str().ok_or("out path was not valid UTF-8")?, - ], - )?; - assert_failure(&build_output, "consumer incan build for borrowed callable import"); - - let stderr = strip_ansi_escapes(&String::from_utf8_lossy(&build_output.stderr)); - assert!( - stderr.contains("expected fn pointer") && stderr.contains("found fn item") && stderr.contains("observe"), - "expected borrowed callable mismatch to document current pub consumer blocker, got:\n{stderr}" - ); - let generated_main = fs::read_to_string(out_dir.join("src/main.rs"))?; - assert!( - generated_main.contains("fn observe(_: Payload)") - && generated_main.contains("inspect_payload(") - && generated_main.contains(", observe)"), - "expected final generated Rust project to show consumer observer shape before Cargo type failure, got:\n{generated_main}" - ); Ok(()) } diff --git a/tests/generated_rust_native_consumer_tests.rs b/tests/generated_rust_native_consumer_tests.rs index c76bc15be..2ed82e2d2 100644 --- a/tests/generated_rust_native_consumer_tests.rs +++ b/tests/generated_rust_native_consumer_tests.rs @@ -21,6 +21,10 @@ fn run_incan(current_dir: &Path, args: &[&str]) -> Result String { out } +/// Parse JSON log records from stdout that may also contain human logging or ordinary print lines. +fn parse_json_log_records(stdout: &str) -> Result, Box> { + stdout + .lines() + .filter(|line| line.trim_start().starts_with('{')) + .map(serde_json::from_str) + .collect::>() + .map_err(Into::into) +} + +/// Find a JSON logging record by its string body. +fn json_record_by_body<'a>(records: &'a [serde_json::Value], body: &str) -> Option<&'a serde_json::Value> { + records + .iter() + .find(|record| record["Body"]["StringValue"] == serde_json::json!(body)) +} + static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); /// Create a throwaway project name that does not collide under parallel nextest workers. @@ -83,18 +100,7 @@ fn assert_runtime_error_cli( ) -> Result<(), Box> { let (_tmp, main_path) = write_runtime_error_project(source)?; - let check_output = Command::new(incan_debug_binary()) - .arg("--check") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - check_output.status.success(), - "expected --check to succeed so the failure is runtime.\nstderr:\n{}", - String::from_utf8_lossy(&check_output.stderr) - ); - - let run_output = Command::new(incan_debug_binary()) + let run_output = incan_command() .arg("run") .arg(&main_path) .env("CARGO_NET_OFFLINE", "true") @@ -151,7 +157,7 @@ main = "src/main.incn" "#, )?; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .arg("run") .current_dir(tmp.path()) .env("CARGO_NET_OFFLINE", "true") @@ -173,10 +179,36 @@ main = "src/main.incn" } #[test] -fn std_logging_logger_surface_filters_and_preserves_bound_context() -> Result<(), Box> { - let source = r#"from std.logging import ColorPolicy, Level, LogStyle, basic_config, get_logger +fn std_logging_runtime_surfaces_share_one_generated_run() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_name = unique_test_project_name("std_logging_runtime_surfaces"); + let src_dir = tmp.path().join("src"); + fs::create_dir_all(&src_dir)?; + fs::write( + tmp.path().join("incan.toml"), + format!("[project]\nname = \"{project_name}\"\nversion = \"0.1.0\"\n"), + )?; + fs::write( + src_dir.join("worker.incn"), + r#"from std.logging import get_logger -def main() -> None: +pub def run_get_logger_worker() -> None: + log = get_logger() + log.info("worker ready") + +pub def run_ambient_worker() -> None: + log.info("worker ambient log ready") +"#, + )?; + let source = r#"from std.logging import ColorPolicy, Level, LogFormat, LogStyle, LoggerName, OutputTarget, basic_config, get_logger +from std.telemetry.core import TelemetryValue +from worker import run_ambient_worker, run_get_logger_worker + +model LocalLog: + def info(self, message: str) -> None: + println(f"local:{message}") + +def logger_context_case() -> None: basic_config(level=Level.WARNING, style=LogStyle.VERBOSE, color=ColorPolicy.NEVER, target="stdout") root = get_logger("app").bind({"shared": "root"}) child = root.child("loader").bind({"component": "loader"}) @@ -189,20 +221,100 @@ def main() -> None: root.error("root event") child.warning("child event", fields={"shared": "event"}) + +def json_record_shape_case() -> None: + basic_config(level=Level.DEBUG, format=LogFormat.JSON, target="stdout") + log = get_logger() + log.debug("json works", fields={"request_id": "abc", "component": "loader"}) + +def default_target_case() -> None: + basic_config(level=Level.INFO) + get_logger("app").info("stderr event") + +def shadow_case() -> None: + basic_config(level=Level.INFO, format=LogFormat.JSON, target="stdout") + log = LocalLog() + log.info("shadowed") + +def ambient_root_case() -> None: + basic_config(level=Level.INFO, format=LogFormat.JSON, target="stdout") + log.info("snippet ambient") + +def structured_fields_case() -> None: + basic_config(level=Level.INFO, format=LogFormat.JSON, target="stdout") + log.info("structured", fields={ + "rows": 42, + "ok": true, + "ratio": 1.5, + "missing": None, + "items": TelemetryValue.array([TelemetryValue.int(1), TelemetryValue.bool(false)]), + "nested": TelemetryValue.map({"child": TelemetryValue.string("yes")}), + }) + +def telemetry_constructor_case() -> None: + text = TelemetryValue.string("alpha") + payload = TelemetryValue.map({ + "items": TelemetryValue.array([TelemetryValue.int(42), TelemetryValue.bool(true)]), + "empty": TelemetryValue.none(), + "encoded": TelemetryValue.bytes("ff"), + "ratio": TelemetryValue.float(1.5), + }) + println(f"telemetry:{text.display_text()}") + println(f"telemetry:{payload.display_text()}") + +def validator_case() -> None: + match LoggerName.from_underlying(""): + Ok(_) => println("unexpected accepted empty logger name") + Err(err) => println(f"validation:empty_logger:{err.to_string()}") + match LoggerName.from_underlying(".app"): + Ok(_) => println("unexpected accepted edge logger name") + Err(err) => println(f"validation:edge_logger:{err.to_string()}") + match LoggerName.from_underlying("app..db"): + Ok(_) => println("unexpected accepted segmented logger name") + Err(err) => println(f"validation:segmented_logger:{err.to_string()}") + match OutputTarget.from_underlying("bogus"): + Ok(_) => println("unexpected accepted output target") + Err(err) => println(f"validation:output_target:{err.to_string()}") + +def human_styles_case() -> None: + basic_config(level=Level.INFO, style=LogStyle.MINIMAL, target="stdout") + get_logger("app").info("minimal event") + basic_config(level=Level.INFO, style=LogStyle.SHORT, target="stdout") + get_logger("app").info("short event") + basic_config(level=Level.INFO, style=LogStyle.COMPLETE, target="stdout") + get_logger("app").info("complete event") + basic_config(level=Level.INFO, style=LogStyle.VERBOSE, target="stdout") + get_logger("app").info("verbose event") + run_get_logger_worker() + run_ambient_worker() + +def main() -> None: + logger_context_case() + json_record_shape_case() + default_target_case() + shadow_case() + ambient_root_case() + structured_fields_case() + telemetry_constructor_case() + validator_case() + human_styles_case() "#; + let main_path = src_dir.join("main.incn"); + fs::write(&main_path, source)?; - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) + let output = incan_command() + .args(["run", main_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "expected std.logging source surface run to succeed.\nstdout:\n{}\nstderr:\n{}", + "expected combined std.logging source surface run to succeed.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( !stdout.contains("silent info"), "expected INFO event to be filtered by source basic_config, got:\n{stdout}" @@ -225,44 +337,34 @@ def main() -> None: stdout.contains("logger=app.loader"), "expected child logger name, got:\n{stdout}" ); - - Ok(()) -} - -#[test] -fn std_logging_source_json_renderer_preserves_record_shape() -> Result<(), Box> { - let source = r#"from std.logging import Level, LogFormat, basic_config, get_logger - -def main() -> None: - basic_config(level=Level.DEBUG, format=LogFormat.JSON, target="stdout") - log = get_logger() - log.debug("json works", fields={"request_id": "abc", "component": "loader"}) -"#; - - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "expected source-defined std.logging JSON run to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + !stdout.contains("stderr event") && stderr.contains("stderr event"), + "expected default logging target to route the event to stderr.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - let stdout = String::from_utf8_lossy(&output.stdout); - let records: Vec = stdout - .lines() - .filter(|line| !line.trim().is_empty()) - .map(serde_json::from_str) - .collect::>()?; - assert_eq!(records.len(), 1, "expected one JSON log record, got:\n{stdout}"); - let record = &records[0]; + assert!( + stdout.contains("local:shadowed") && !stdout.contains(r#""Body":{"Type":"string","StringValue":"shadowed"}"#), + "expected local log binding to remain ordinary source, got:\n{stdout}" + ); + for expected in [ + "validation:empty_logger:std.logging logger names must not be empty", + "validation:edge_logger:std.logging logger names must not start or end with '.'", + "validation:segmented_logger:std.logging logger names must not contain empty segments", + "validation:output_target:std.logging target must be 'stdout' or 'stderr'", + ] { + assert!(stdout.contains(expected), "expected `{expected}`, got:\n{stdout}"); + } + assert!( + !stdout.contains("unexpected accepted"), + "expected std.logging validators to reject invalid values, got:\n{stdout}" + ); + + let records = parse_json_log_records(&stdout)?; + let record = json_record_by_body(&records, "json works") + .ok_or_else(|| std::io::Error::other(format!("missing `json works` record in:\n{stdout}")))?; assert_eq!(record["SeverityText"], serde_json::json!("DEBUG")); assert_eq!(record["SeverityNumber"], serde_json::json!(5)); - assert_eq!(record["InstrumentationScope"]["Name"], serde_json::json!("root")); + assert_eq!(record["InstrumentationScope"]["Name"], serde_json::json!("main")); assert_eq!(record["Body"]["Type"], serde_json::json!("string")); - assert_eq!(record["Body"]["StringValue"], serde_json::json!("json works")); assert_eq!(record["Attributes"]["request_id"]["Type"], serde_json::json!("string")); assert_eq!( record["Attributes"]["request_id"]["StringValue"], @@ -279,2093 +381,1623 @@ def main() -> None: "expected user fields to stay under Attributes, got:\n{record}" ); + let ambient = json_record_by_body(&records, "snippet ambient") + .ok_or_else(|| std::io::Error::other(format!("missing `snippet ambient` record in:\n{stdout}")))?; + assert_eq!(ambient["InstrumentationScope"]["Name"], serde_json::json!("main")); + + let structured = json_record_by_body(&records, "structured") + .ok_or_else(|| std::io::Error::other(format!("missing `structured` record in:\n{stdout}")))?; + let attributes = &structured["Attributes"]; + assert_eq!(attributes["rows"]["Type"], serde_json::json!("int")); + assert_eq!(attributes["rows"]["IntValue"], serde_json::json!(42)); + assert_eq!(attributes["ok"]["Type"], serde_json::json!("bool")); + assert_eq!(attributes["ok"]["BoolValue"], serde_json::json!(true)); + assert_eq!(attributes["ratio"]["Type"], serde_json::json!("float")); + assert_eq!(attributes["ratio"]["FloatValue"], serde_json::json!(1.5)); + assert_eq!(attributes["missing"]["Type"], serde_json::json!("none")); + assert_eq!(attributes["items"]["Type"], serde_json::json!("array")); + assert_eq!(attributes["nested"]["Type"], serde_json::json!("map")); + assert!( + structured.get("rows").is_none() && structured.get("nested").is_none(), + "expected structured fields to stay under Attributes, got:\n{structured}" + ); + + let log_lines: Vec<&str> = stdout.lines().filter(|line| line.contains("[INFO]")).collect(); + let short_line = log_lines + .iter() + .copied() + .find(|line| line.contains("short event")) + .unwrap_or(""); + let complete_line = log_lines + .iter() + .copied() + .find(|line| line.contains("complete event")) + .unwrap_or(""); + + assert!( + stdout.contains("[INFO] minimal event"), + "expected minimal line, got:\n{stdout}" + ); + assert_eq!( + short_line.find(" [INFO] short event"), + Some(8), + "expected short style to use compact time-of-day timestamp, got:\n{stdout}" + ); + assert!( + complete_line.contains('T') && complete_line.contains("Z [INFO] complete event"), + "expected complete style to use full datetime timestamp, got:\n{stdout}" + ); + assert!( + stdout.contains("[INFO] verbose event\n logger=app"), + "expected verbose style to add logger metadata on a second line, got:\n{stdout}" + ); + assert!( + stdout.contains("telemetry:alpha") + && stdout.contains(r#""Type":"map""#) + && stdout.contains(r#""items":{"Type":"array""#) + && stdout.contains(r#""IntValue":42"#) + && stdout.contains(r#""BoolValue":true"#) + && stdout.contains(r#""BytesValue":"ff""#) + && stdout.contains(r#""FloatValue":1.5"#), + "expected telemetry value constructors to preserve structured values, got:\n{stdout}" + ); + assert!( + stdout.contains("worker ready") + && stdout.contains("worker ambient log ready") + && stdout.contains("logger=worker") + && !stdout.contains("logger=std.logging"), + "expected worker module logging to infer logger=worker, got:\n{stdout}" + ); + Ok(()) } #[test] -fn std_logging_default_target_writes_stderr() -> Result<(), Box> { - let source = r#"from std.logging import Level, basic_config, get_logger +fn validated_newtype_runtime_scenarios() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +type Attempts = newtype int: + def from_underlying(n: int) -> Result[Self, ValidationError]: + if n <= 0: + return Err(ValidationError("attempts must be >= 1")) + return Ok(Attempts(n)) -def main() -> None: - basic_config(level=Level.INFO) - get_logger("app").info("stderr event") -"#; +def retry(attempts: Attempts) -> None: + println(f"retry={attempts.0}") - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) +def main() -> None: + retry(3) + attempts: Attempts = 4 + println(f"local={attempts.0}") +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "expected std.logging stderr target run to succeed.\nstdout:\n{}\nstderr:\n{}", + "validated-newtype success program failed.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stdout.contains("stderr event") && stderr.contains("stderr event"), - "expected default logging target to route the event to stderr.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); + assert!(stdout.contains("retry=3"), "unexpected stdout:\n{stdout}"); + assert!(stdout.contains("local=4"), "unexpected stdout:\n{stdout}"); + + assert_runtime_error_cli( + r#" +type Attempts = newtype int: + def from_underlying(n: int) -> Result[Self, ValidationError]: + if n <= 0: + return Err(ValidationError("attempts must be >= 1")) + return Ok(Attempts(n)) + +def retry(attempts: Attempts) -> None: + return + +def read_attempts(attempts: Attempts) -> int: + return attempts.0 + +def main() -> None: + println(f"ok={read_attempts(Attempts(1))}") + retry(0) +"#, + "ValidationError", + &["Attempts::from_underlying", "attempts must be >= 1"], + )?; + + assert_runtime_error_cli( + r#" +type PositiveInt = newtype int: + def from_underlying(n: int) -> Result[Self, ValidationError]: + if n <= 0: + return Err(ValidationError("positive int must be greater than zero")) + return Ok(PositiveInt(n)) + +model Bounds: + low: PositiveInt + high: PositiveInt + +def width(bounds: Bounds) -> int: + return bounds.high.0 - bounds.low.0 + +def main() -> None: + println(f"width={width(Bounds(low=1, high=2))}") + _ = Bounds(low=0, high=-1) +"#, + "ValidationError", + &[ + "Bounds validation failed with 2 error(s)", + "low: positive int must be greater than zero", + "high: positive int must be greater than zero", + ], + )?; Ok(()) } #[test] -fn std_logging_default_logger_infers_source_module() -> Result<(), Box> { +fn rfc028_user_defined_operators_run_end_to_end() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let src_dir = tmp.path().join("src"); fs::create_dir_all(&src_dir)?; fs::write( tmp.path().join("incan.toml"), r#"[project] -name = "std_logging_module_source" +name = "rfc028_user_defined_operators" version = "0.1.0" "#, )?; fs::write( src_dir.join("main.incn"), - r#"from std.logging import Level, LogStyle, basic_config -from worker import run_worker + r#"model Money: + cents: int -def main() -> None: - basic_config(level=Level.INFO, style=LogStyle.VERBOSE, target="stdout") - run_worker() -"#, - )?; - fs::write( - src_dir.join("worker.incn"), - r#"from std.logging import get_logger + def __add__(self, other: Money) -> Money: + return Money(cents=self.cents + other.cents) -pub def run_worker() -> None: - log = get_logger() - log.info("worker ready") + def __lt__(self, other: Money) -> bool: + return self.cents < other.cents + + +model Row: + value: int + + def __getitem__(self, index: int) -> int: + return self.value + index + + def __setitem__(self, index: int, value: int) -> None: + pass + + +model OpBox: + value: int + + def __matmul__(self, other: OpBox) -> OpBox: + return OpBox(value=self.value + other.value) + + def __invert__(self) -> OpBox: + return OpBox(value=0 - self.value) + + +def main() -> None: + total = Money(cents=100) + Money(cents=25) + println(total.cents) + println(Money(cents=25) < Money(cents=100)) + row = Row(value=4) + row[3] = 9 + println(row[3]) + mat = OpBox(value=2) @ OpBox(value=3) + println(mat.value) + inverted = ~OpBox(value=8) + println(inverted.value) "#, )?; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .arg("run") .arg("src/main.incn") .current_dir(tmp.path()) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "expected module-aware std.logging run to succeed.\nstdout:\n{}\nstderr:\n{}", + "expected RFC 028 operator program to run.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("worker ready") && stdout.contains("logger=worker") && !stdout.contains("logger=root"), - "expected get_logger() in worker.incn to infer logger=worker, got:\n{stdout}" + stdout.contains("125") && stdout.contains("true") && stdout.contains("7") && stdout.contains("5"), + "unexpected RFC 028 operator output:\n{stdout}" ); Ok(()) } -#[test] -fn std_logging_ambient_log_infers_source_module() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let src_dir = tmp.path().join("src"); - fs::create_dir_all(&src_dir)?; - fs::write( - tmp.path().join("incan.toml"), +/// Locate the `incan` binary for subprocess tests. +/// +/// Uses `CARGO_BIN_EXE_incan` when present (integration tests under `cargo test`) so we always run the artifact from +/// the current build, including when `CARGO_TARGET_DIR` is not the default `target/`. +fn incan_debug_binary() -> std::path::PathBuf { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_incan") { + return path.into(); + } + if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") { + let p = std::path::PathBuf::from(&target_dir).join("debug/incan"); + if p.exists() { + return p; + } + } + std::path::PathBuf::from("target/debug/incan") +} + +fn shared_generated_cargo_target_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("incan_generated_shared_target") +} + +fn incan_command() -> Command { + let mut command = Command::new(incan_debug_binary()); + command.env("INCAN_GENERATED_CARGO_TARGET_DIR", shared_generated_cargo_target_dir()); + command +} + +fn is_incan_fixture(path: &Path) -> bool { + matches!(path.extension().and_then(|e| e.to_str()), Some("incn") | Some("incan")) +} + +/// Make a temporary test directory to be able to run the CLI tests. +fn make_temp_test_dir() -> std::path::PathBuf { + let mut dir = std::env::temp_dir(); + let uniq = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + dir.push(format!("incan_cli_test_{}", uniq)); + let Ok(()) = std::fs::create_dir_all(&dir) else { + panic!("failed to create temp test dir"); + }; + dir +} + +fn write_cycle_explicit_call_site_generics_project(dir: &Path) -> Result> { + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + dir.join("incan.toml"), r#"[project] -name = "std_logging_ambient_log" +name = "cycle_explicit_call_site_generics" version = "0.1.0" "#, )?; - fs::write( - src_dir.join("main.incn"), - r#"from std.logging import Level, LogStyle, basic_config -from worker import run_worker + std::fs::write( + src_dir.join("dataset.incn"), + r#"from session import collect_with_active_session -def main() -> None: - basic_config(level=Level.INFO, style=LogStyle.VERBOSE, target="stdout") - run_worker() +pub model DataSet[T]: + value: T + +pub def collect_with_dataset[T](dataset: DataSet[T]) -> T: + return collect_with_active_session[T](dataset) "#, )?; - fs::write( - src_dir.join("worker.incn"), - r#"pub def run_worker() -> None: - log.info("worker ambient log ready") + std::fs::write( + src_dir.join("session.incn"), + r#"from dataset import DataSet + +pub def collect_with_active_session[T](dataset: DataSet[T]) -> T: + return dataset.value "#, )?; + let main_path = src_dir.join("main.incn"); + std::fs::write( + &main_path, + r#"from dataset import DataSet, collect_with_dataset - let output = Command::new(incan_debug_binary()) - .arg("run") - .arg("src/main.incn") - .current_dir(tmp.path()) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected ambient std.logging log run to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("worker ambient log ready") - && stdout.contains("logger=worker") - && !stdout.contains("logger=root") - && !stdout.contains("logger=std.logging"), - "expected ambient log in worker.incn to infer logger=worker, got:\n{stdout}" - ); - - Ok(()) +def main() -> None: + let ds = DataSet(value=1) + println(collect_with_dataset[int](ds)) +"#, + )?; + Ok(main_path) } +/// Regression (GitHub #247): `incan fmt` on disk must preserve body docstrings for all public block-like type +/// declarations, and [`exported_type_like_docs`] must still see them after the CLI round-trip. +/// +/// `format_files` delegates to [`incan::format::format_source`]; this still covers subprocess + I/O if those paths +/// diverge from in-process formatting. #[test] -fn std_logging_ambient_log_is_shadowable() -> Result<(), Box> { - let source = r#"from std.logging import Level, LogFormat, basic_config +fn test_cli_fmt_preserves_block_decl_docstrings_and_export_doc_surface() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("block_docstrings_cli.incn"); + fs::write(&path, BLOCK_DOCSTRING_PUBLIC_TYPE_LIKE)?; + let status = incan_command().arg("fmt").arg(&path).status()?; + assert!(status.success(), "incan fmt failed"); -model LocalLog: - def info(self, message: str) -> None: - println(f"local:{message}") + let formatted = fs::read_to_string(&path)?; + let tokens = lexer::lex(&formatted) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + let ast = parser::parse(&tokens) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; -def main() -> None: - basic_config(level=Level.INFO, format=LogFormat.JSON, target="stdout") - log = LocalLog() - log.info("shadowed") -"#; + fn assert_markers(doc: Option<&str>, ctx: &str) -> Result<(), Box> { + let Some(doc) = doc else { + return Err(std::io::Error::other(format!("{ctx}: missing docstring after CLI fmt")).into()); + }; + let t = doc.trim(); + if !t.contains("Line A documents the class API.") { + return Err(std::io::Error::other(format!("{ctx}: missing marker A in {t:?}")).into()); + } + if !t.contains("Line B keeps interior newlines after trim().") { + return Err(std::io::Error::other(format!("{ctx}: missing marker B in {t:?}")).into()); + } + Ok(()) + } - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + let docs = exported_type_like_docs(&ast); + assert_eq!(docs.len(), 5, "expected five public type-like exports with docs"); + let mut by_name: std::collections::HashMap = std::collections::HashMap::new(); + for d in docs { + by_name.insert(d.name.clone(), d); + } - assert!( - output.status.success(), - "expected local log binding to shadow ambient std.logging.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("local:shadowed") && !stdout.contains("InstrumentationScope"), - "expected local log binding to remain ordinary source, got:\n{stdout}" - ); + let m = by_name + .get("CliModelProbe") + .ok_or_else(|| std::io::Error::other("missing CliModelProbe"))?; + assert_eq!(m.kind, ExportedTypeLikeKind::Model); + assert_markers(m.docstring.as_deref(), "model")?; + + let c = by_name + .get("CliClassProbe") + .ok_or_else(|| std::io::Error::other("missing CliClassProbe"))?; + assert_eq!(c.kind, ExportedTypeLikeKind::Class); + assert_markers(c.docstring.as_deref(), "class")?; + + let e = by_name + .get("CliEnumProbe") + .ok_or_else(|| std::io::Error::other("missing CliEnumProbe"))?; + assert_eq!(e.kind, ExportedTypeLikeKind::Enum); + assert_markers(e.docstring.as_deref(), "enum")?; + + let t = by_name + .get("CliTraitProbe") + .ok_or_else(|| std::io::Error::other("missing CliTraitProbe"))?; + assert_eq!(t.kind, ExportedTypeLikeKind::Trait); + assert_markers(t.docstring.as_deref(), "trait")?; + + let n = by_name + .get("CliNewtypeProbe") + .ok_or_else(|| std::io::Error::other("missing CliNewtypeProbe"))?; + assert_eq!(n.kind, ExportedTypeLikeKind::Newtype); + assert_markers(n.docstring.as_deref(), "newtype")?; Ok(()) } #[test] -fn std_logging_ambient_log_snippet_falls_back_to_root() -> Result<(), Box> { - let source = r#"from std.logging import Level, LogFormat, basic_config - -def main() -> None: - basic_config(level=Level.INFO, format=LogFormat.JSON, target="stdout") - log.info("snippet ambient") -"#; - - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; +fn test_cli_fmt_accepts_assert_identity_bool_literals() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("assert_identity_bool_literals.incn"); + fs::write( + &path, + r#" +def check_flags(ready: bool, done: bool) -> None: + assert ready is true, "ready should be true" + assert done is false +"#, + )?; + let output = incan_command().arg("fmt").arg(&path).output()?; assert!( output.status.success(), - "expected metadata-free ambient log to fall back to root.\nstdout:\n{}\nstderr:\n{}", + "expected `incan fmt` to accept assert identity checks against bool literals.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(r#""InstrumentationScope":{"Name":"root""#) && stdout.contains("snippet ambient"), - "expected ambient log in -c snippet to emit with root logger, got:\n{stdout}" - ); - Ok(()) } +/// Regression (GitHub #484): parenthesized logical chains should wrap at obvious boolean breakpoints. #[test] -fn std_logging_rejects_invalid_logger_names() -> Result<(), Box> { - let cases = [ - ( - "empty logger name", - r#"from std.logging import get_logger - -def main() -> None: - get_logger("").info("should not emit") -"#, - "std.logging logger names must not be empty", - ), - ( - "empty logger segment", - r#"from std.logging import get_logger +fn test_cli_fmt_wraps_long_parenthesized_logical_expression_chain() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("long_logical_chain.incn"); + fs::write( + &path, + r#"model Item: + kind_name: str + predicate_kind_name: str + source_name: str -def main() -> None: - get_logger("app..db").info("should not emit") -"#, - "std.logging logger names must not contain empty segments", - ), - ( - "invalid child suffix", - r#"from std.logging import get_logger -def main() -> None: - get_logger("app").child(".db").info("should not emit") +def matches(item: Item) -> bool: + return (item.kind_name == "filter" and item.predicate_kind_name == "bool_literal" and item.source_name == "rewritten_prism_node") "#, - "std.logging logger names must not contain empty segments", - ), - ]; - - for (case, source, expected) in cases { - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + )?; - assert!( - !output.status.success(), - "expected {case} to fail.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let combined = format!( - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains(expected), - "expected {case} validation message `{expected}`, got:\n{combined}" - ); - } + let status = incan_command().arg("fmt").arg(&path).status()?; + assert!(status.success(), "incan fmt failed"); - Ok(()) -} + let formatted = fs::read_to_string(&path)?; + let expected = r#"model Item: + kind_name: str + predicate_kind_name: str + source_name: str -#[test] -fn std_logging_rejects_invalid_output_target() -> Result<(), Box> { - let source = r#"from std.logging import Level, basic_config, get_logger -def main() -> None: - basic_config(level=Level.INFO, target="bogus") - get_logger("app").info("should not emit") +def matches(item: Item) -> bool: + return ( + item.kind_name == "filter" + and item.predicate_kind_name == "bool_literal" + and item.source_name == "rewritten_prism_node" + ) "#; - - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - + assert_eq!(formatted, expected); assert!( - !output.status.success(), - "expected invalid std.logging target to fail.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let combined = format!( - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + formatted.lines().all(|line| line.len() <= 120), + "expected formatted output to stay within 120 columns:\n{formatted}" ); + + let output = incan_command().arg("--check").arg(&path).output()?; assert!( - combined.contains("std.logging target must be 'stdout' or 'stderr'"), - "expected target validation message, got:\n{combined}" + output.status.success(), + "expected wrapped expression to parse/typecheck after CLI fmt; stderr={}", + String::from_utf8_lossy(&output.stderr) ); Ok(()) } +/// Regression (GitHub #289): `incan fmt` must preserve escaped newlines in f-strings as textual `\\n`. #[test] -fn std_logging_json_preserves_structured_field_values() -> Result<(), Box> { - let source = r#"from std.logging import Level, LogFormat, basic_config -from std.telemetry.core import TelemetryValue +fn test_cli_fmt_preserves_fstring_escaped_newline_roundtrip() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("fstring_escaped_newline.incn"); + fs::write( + &path, + r#"def main() -> str: + return f"a\n{1}" +"#, + )?; -def main() -> None: - basic_config(level=Level.INFO, format=LogFormat.JSON, target="stdout") - log.info("structured", fields={ - "rows": 42, - "ok": true, - "ratio": 1.5, - "missing": None, - "items": TelemetryValue.array([TelemetryValue.int(1), TelemetryValue.bool(false)]), - "nested": TelemetryValue.map({"child": TelemetryValue.string("yes")}), - }) -"#; + let status = incan_command().arg("fmt").arg(&path).status()?; + assert!(status.success(), "incan fmt failed"); - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + let formatted = fs::read_to_string(&path)?; + assert!( + formatted.contains(r#"f"a\n{1}""#), + "expected formatted output to preserve escaped newline text, got:\n{}", + formatted + ); + let output = incan_command().arg("--check").arg(&path).output()?; assert!( output.status.success(), - "expected structured std.logging fields to compile and emit JSON.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), + "expected formatted file to parse/typecheck after CLI fmt; stderr={}", String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - let records: Vec = stdout - .lines() - .filter(|line| !line.trim().is_empty()) - .map(serde_json::from_str) - .collect::>()?; - assert_eq!(records.len(), 1, "expected one JSON log record, got:\n{stdout}"); - let attributes = &records[0]["Attributes"]; - assert_eq!(attributes["rows"]["Type"], serde_json::json!("int")); - assert_eq!(attributes["rows"]["IntValue"], serde_json::json!(42)); - assert_eq!(attributes["ok"]["Type"], serde_json::json!("bool")); - assert_eq!(attributes["ok"]["BoolValue"], serde_json::json!(true)); - assert_eq!(attributes["ratio"]["Type"], serde_json::json!("float")); - assert_eq!(attributes["ratio"]["FloatValue"], serde_json::json!(1.5)); - assert_eq!(attributes["missing"]["Type"], serde_json::json!("none")); - assert_eq!(attributes["items"]["Type"], serde_json::json!("array")); - assert_eq!(attributes["nested"]["Type"], serde_json::json!("map")); - assert!( - records[0].get("rows").is_none() && records[0].get("nested").is_none(), - "expected structured fields to stay under Attributes, got:\n{}", - records[0] - ); Ok(()) } +/// Regression (GitHub #336 / RFC 053): the CLI formatter must apply the vertical-spacing contract on disk. #[test] -fn std_traits_convert_usage_runs() -> Result<(), Box> { - let source = include_str!("codegen_snapshots/std_traits_convert_usage.incn"); +fn test_cli_fmt_applies_rfc053_vertical_spacing_contract() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("rfc053_vertical_spacing.incn"); + fs::write( + &path, + r#"type UserId = str +# comment about the alias - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; +model User: + """ + First paragraph. - assert!( - output.status.success(), - "expected std.traits.convert classmethod usage to compile and run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout), "42\n3\n"); - Ok(()) -} + Second paragraph. + """ + id: UserId -#[test] -fn std_logging_human_styles_render_distinct_shapes() -> Result<(), Box> { - let source = r#"from std.logging import Level, LogStyle, basic_config, get_logger +trait Service: + def connect(self) -> None: ... + def reset(self) -> None: + pass +"#, + )?; -def main() -> None: - basic_config(level=Level.INFO, style=LogStyle.MINIMAL, target="stdout") - get_logger("app").info("minimal event") - basic_config(level=Level.INFO, style=LogStyle.SHORT, target="stdout") - get_logger("app").info("short event") - basic_config(level=Level.INFO, style=LogStyle.COMPLETE, target="stdout") - get_logger("app").info("complete event") - basic_config(level=Level.INFO, style=LogStyle.VERBOSE, target="stdout") - get_logger("app").info("verbose event") -"#; + let status = incan_command().arg("fmt").arg(&path).status()?; + assert!(status.success(), "incan fmt failed"); - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + let formatted = fs::read_to_string(&path)?; + let expected = r#"type UserId = str +# comment about the alias - assert!( - output.status.success(), - "expected std.logging human style run to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let log_lines: Vec<&str> = stdout.lines().filter(|line| line.contains("[INFO]")).collect(); - let short_line = log_lines - .iter() - .copied() - .find(|line| line.contains("short event")) - .unwrap_or(""); - let complete_line = log_lines - .iter() - .copied() - .find(|line| line.contains("complete event")) - .unwrap_or(""); - assert!( - stdout.contains("[INFO] minimal event"), - "expected minimal line, got:\n{stdout}" - ); - assert_eq!( - short_line.find(" [INFO] short event"), - Some(8), - "expected short style to use compact time-of-day timestamp, got:\n{stdout}" - ); - assert!( - complete_line.contains("T") && complete_line.contains("Z [INFO] complete event"), - "expected complete style to use full datetime timestamp, got:\n{stdout}" - ); - assert!( - stdout.contains("[INFO] verbose event\n logger=app"), - "expected verbose style to add logger metadata on a second line, got:\n{stdout}" - ); +model User: + """ + First paragraph. - Ok(()) -} + Second paragraph. + """ -#[test] -fn telemetry_value_class_constructors_are_callable() -> Result<(), Box> { - let source = r#"from std.telemetry.core import TelemetryValue + id: UserId -def main() -> None: - text = TelemetryValue.string("alpha") - payload = TelemetryValue.map({ - "items": TelemetryValue.array([TelemetryValue.int(42), TelemetryValue.bool(true)]), - "empty": TelemetryValue.none(), - "encoded": TelemetryValue.bytes("ff"), - "ratio": TelemetryValue.float(1.5), - }) - println(text.display_text()) - println(payload.display_text()) -"#; - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; +trait Service: + def connect(self) -> None - assert!( - output.status.success(), - "expected telemetry value constructors to run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("alpha") - && stdout.contains(r#""Type":"map""#) - && stdout.contains(r#""items":{"Type":"array""#) - && stdout.contains(r#""IntValue":42"#) - && stdout.contains(r#""BoolValue":true"#) - && stdout.contains(r#""BytesValue":"ff""#) - && stdout.contains(r#""FloatValue":1.5"#), - "expected class constructors to preserve structured telemetry values, got:\n{stdout}" - ); + def reset(self) -> None: + pass +"#; + assert_eq!(formatted, expected); + + let tokens = lexer::lex(&formatted) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + parser::parse(&tokens) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; Ok(()) } +/// Regression (GitHub #336 / RFC 053): top-level type/function-shaped declarations keep two blank lines even when +/// adjacent to module statics. #[test] -fn validated_newtype_runtime_success_coerces_approved_sites() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -type Attempts = newtype int: - def from_underlying(n: int) -> Result[Self, ValidationError]: - if n <= 0: - return Err(ValidationError("attempts must be >= 1")) - return Ok(Attempts(n)) - -def retry(attempts: Attempts) -> None: - println(f"retry={attempts.0}") - -def main() -> None: - retry(3) - attempts: Attempts = 4 - println(f"local={attempts.0}") +fn test_cli_fmt_keeps_two_blank_lines_between_static_and_function() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("rfc053_static_function_spacing.incn"); + fs::write( + &path, + r#"static prism_store_node_counts: list[int] = [] +pub def allocate_prism_store_id() -> int: + return len(prism_store_node_counts) "#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "validated-newtype success program failed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("retry=3"), "unexpected stdout:\n{stdout}"); - assert!(stdout.contains("local=4"), "unexpected stdout:\n{stdout}"); - - Ok(()) -} - -#[test] -fn validated_newtype_runtime_fail_fast_reports_validation_error() -> Result<(), Box> { - assert_runtime_error_cli( - r#" -type Attempts = newtype int: - def from_underlying(n: int) -> Result[Self, ValidationError]: - if n <= 0: - return Err(ValidationError("attempts must be >= 1")) - return Ok(Attempts(n)) - -def retry(attempts: Attempts) -> None: - return + )?; -def read_attempts(attempts: Attempts) -> int: - return attempts.0 + let status = incan_command().arg("fmt").arg(&path).status()?; + assert!(status.success(), "incan fmt failed"); -def main() -> None: - println(f"ok={read_attempts(Attempts(1))}") - retry(0) -"#, - "ValidationError", - &["Attempts::from_underlying", "attempts must be >= 1"], - ) -} + let formatted = fs::read_to_string(&path)?; + let expected = r#"static prism_store_node_counts: list[int] = [] -#[test] -fn validated_newtype_runtime_aggregates_model_field_errors() -> Result<(), Box> { - assert_runtime_error_cli( - r#" -type PositiveInt = newtype int: - def from_underlying(n: int) -> Result[Self, ValidationError]: - if n <= 0: - return Err(ValidationError("positive int must be greater than zero")) - return Ok(PositiveInt(n)) -model Bounds: - low: PositiveInt - high: PositiveInt +pub def allocate_prism_store_id() -> int: + return len(prism_store_node_counts) +"#; + assert_eq!(formatted, expected); -def width(bounds: Bounds) -> int: - return bounds.high.0 - bounds.low.0 + let tokens = lexer::lex(&formatted) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + parser::parse(&tokens) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; -def main() -> None: - println(f"width={width(Bounds(low=1, high=2))}") - _ = Bounds(low=0, high=-1) -"#, - "ValidationError", - &[ - "Bounds validation failed with 2 error(s)", - "low: positive int must be greater than zero", - "high: positive int must be greater than zero", - ], - ) + Ok(()) } +/// Regression (GitHub #336 / RFC 053): a trailing own-line comment after a multi-line construct must stay after the +/// full suite, not get reinserted after the construct header. #[test] -fn rfc028_user_defined_operators_run_end_to_end() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let src_dir = tmp.path().join("src"); - fs::create_dir_all(&src_dir)?; +fn test_cli_fmt_keeps_trailing_comment_after_multiline_function() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("rfc053_trailing_comment_after_function.incn"); fs::write( - tmp.path().join("incan.toml"), - r#"[project] -name = "rfc028_user_defined_operators" -version = "0.1.0" + &path, + r#"def load_user(id: str) -> str: + return id + +# TODO: split retries "#, )?; - fs::write( - src_dir.join("main.incn"), - r#"model Money: - cents: int - - def __add__(self, other: Money) -> Money: - return Money(cents=self.cents + other.cents) - - def __lt__(self, other: Money) -> bool: - return self.cents < other.cents + let status = incan_command().arg("fmt").arg(&path).status()?; + assert!(status.success(), "incan fmt failed"); -model Row: - value: int - - def __getitem__(self, index: int) -> int: - return self.value + index - - def __setitem__(self, index: int, value: int) -> None: - pass - + let formatted = fs::read_to_string(&path)?; + let expected = r#"def load_user(id: str) -> str: + return id +# TODO: split retries +"#; + assert_eq!(formatted, expected); -model OpBox: - value: int + let tokens = lexer::lex(&formatted) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + parser::parse(&tokens) + .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; - def __matmul__(self, other: OpBox) -> OpBox: - return OpBox(value=self.value + other.value) + Ok(()) +} - def __invert__(self) -> OpBox: - return OpBox(value=0 - self.value) +/// Regression (GitHub #394): multiline function parameter lists must accept a trailing comma. +#[test] +fn test_cli_check_accepts_trailing_comma_in_multiline_function_params() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let path = dir.join("trailing_param_comma.incn"); + fs::write( + &path, + r#"def identity( + value: int, +) -> int: + return value def main() -> None: - total = Money(cents=100) + Money(cents=25) - println(total.cents) - println(Money(cents=25) < Money(cents=100)) - row = Row(value=4) - row[3] = 9 - println(row[3]) - mat = OpBox(value=2) @ OpBox(value=3) - println(mat.value) - inverted = ~OpBox(value=8) - println(inverted.value) + println(identity(1)) "#, )?; - let output = Command::new(incan_debug_binary()) - .arg("run") - .arg("src/main.incn") - .current_dir(tmp.path()) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + let output = incan_command().arg("--check").arg(&path).output()?; assert!( output.status.success(), - "expected RFC 028 operator program to run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), + "expected multiline trailing parameter comma to parse/typecheck; stderr={}", String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("125") && stdout.contains("true") && stdout.contains("7") && stdout.contains("5"), - "unexpected RFC 028 operator output:\n{stdout}" - ); Ok(()) } -/// Locate the `incan` binary for subprocess tests. -/// -/// Uses `CARGO_BIN_EXE_incan` when present (integration tests under `cargo test`) so we always run the artifact from -/// the current build, including when `CARGO_TARGET_DIR` is not the default `target/`. -fn incan_debug_binary() -> std::path::PathBuf { - if let Ok(path) = std::env::var("CARGO_BIN_EXE_incan") { - return path.into(); - } - if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") { - let p = std::path::PathBuf::from(&target_dir).join("debug/incan"); - if p.exists() { - return p; - } - } - std::path::PathBuf::from("target/debug/incan") -} +/// Regression: float compound-assign with int RHS should typecheck (Python-like / promotion). +#[test] +fn test_compound_assign_float_with_int_rhs() { + let program = r#" +def main() -> None: + mut y: float = 100.0 + y /= 3 + y %= 7 + println(y) +"#; -fn is_incan_fixture(path: &Path) -> bool { - matches!(path.extension().and_then(|e| e.to_str()), Some("incn") | Some("incan")) + let result = compile_source(program); + assert!(result.is_ok(), "Expected program to typecheck, got {:?}", result.err()); } -/// Make a temporary test directory to be able to run the CLI tests. -fn make_temp_test_dir() -> std::path::PathBuf { - let mut dir = std::env::temp_dir(); - let uniq = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - dir.push(format!("incan_cli_test_{}", uniq)); - let Ok(()) = std::fs::create_dir_all(&dir) else { - panic!("failed to create temp test dir"); +/// Test that all valid fixtures compile successfully +#[test] +fn test_valid_fixtures() { + let fixtures_dir = Path::new("tests/fixtures/valid"); + if !fixtures_dir.exists() { + return; // Skip if fixtures not present + } + + let mut matched = 0usize; + let Ok(entries) = fs::read_dir(fixtures_dir) else { + panic!("failed to read directory {}", fixtures_dir.display()); }; - dir + for entry in entries { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if is_incan_fixture(&path) { + matched += 1; + let result = compile_file(&path); + if let Err(errs) = result { + panic!( + "Expected {} to compile successfully, got errors: {:?}", + path.display(), + errs + ); + } + } + } + assert!(matched > 0, "No .incn fixtures found in {}", fixtures_dir.display()); } -fn write_cycle_explicit_call_site_generics_project(dir: &Path) -> Result> { - let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; - std::fs::write( - dir.join("incan.toml"), - r#"[project] -name = "cycle_explicit_call_site_generics" -version = "0.1.0" -"#, - )?; - std::fs::write( - src_dir.join("dataset.incn"), - r#"from session import collect_with_active_session - -pub model DataSet[T]: - value: T - -pub def collect_with_dataset[T](dataset: DataSet[T]) -> T: - return collect_with_active_session[T](dataset) -"#, - )?; - std::fs::write( - src_dir.join("session.incn"), - r#"from dataset import DataSet - -pub def collect_with_active_session[T](dataset: DataSet[T]) -> T: - return dataset.value -"#, - )?; - let main_path = src_dir.join("main.incn"); - std::fs::write( - &main_path, - r#"from dataset import DataSet, collect_with_dataset - -def main() -> None: - let ds = DataSet(value=1) - println(collect_with_dataset[int](ds)) -"#, - )?; - Ok(main_path) -} - -/// Regression (GitHub #247): `incan fmt` on disk must preserve body docstrings for all public block-like type -/// declarations, and [`exported_type_like_docs`] must still see them after the CLI round-trip. -/// -/// `format_files` delegates to [`incan::format::format_source`]; this still covers subprocess + I/O if those paths -/// diverge from in-process formatting. +/// Test that invalid fixtures produce errors #[test] -fn test_cli_fmt_preserves_block_decl_docstrings_and_export_doc_surface() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("block_docstrings_cli.incn"); - fs::write(&path, BLOCK_DOCSTRING_PUBLIC_TYPE_LIKE)?; - let status = Command::new(incan_debug_binary()).arg("fmt").arg(&path).status()?; - assert!(status.success(), "incan fmt failed"); - - let formatted = fs::read_to_string(&path)?; - let tokens = lexer::lex(&formatted) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; - let ast = parser::parse(&tokens) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; - - fn assert_markers(doc: Option<&str>, ctx: &str) -> Result<(), Box> { - let Some(doc) = doc else { - return Err(std::io::Error::other(format!("{ctx}: missing docstring after CLI fmt")).into()); - }; - let t = doc.trim(); - if !t.contains("Line A documents the class API.") { - return Err(std::io::Error::other(format!("{ctx}: missing marker A in {t:?}")).into()); - } - if !t.contains("Line B keeps interior newlines after trim().") { - return Err(std::io::Error::other(format!("{ctx}: missing marker B in {t:?}")).into()); - } - Ok(()) +fn test_invalid_fixtures() { + let fixtures_dir = Path::new("tests/fixtures/invalid"); + if !fixtures_dir.exists() { + return; // Skip if fixtures not present } - let docs = exported_type_like_docs(&ast); - assert_eq!(docs.len(), 5, "expected five public type-like exports with docs"); - let mut by_name: std::collections::HashMap = std::collections::HashMap::new(); - for d in docs { - by_name.insert(d.name.clone(), d); + let mut matched = 0usize; + let Ok(entries) = fs::read_dir(fixtures_dir) else { + panic!("failed to read directory {}", fixtures_dir.display()); + }; + for entry in entries { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if is_incan_fixture(&path) { + matched += 1; + let result = compile_file(&path); + assert!( + result.is_err(), + "Expected {} to fail compilation, but it succeeded", + path.display() + ); + } } - - let m = by_name - .get("CliModelProbe") - .ok_or_else(|| std::io::Error::other("missing CliModelProbe"))?; - assert_eq!(m.kind, ExportedTypeLikeKind::Model); - assert_markers(m.docstring.as_deref(), "model")?; - - let c = by_name - .get("CliClassProbe") - .ok_or_else(|| std::io::Error::other("missing CliClassProbe"))?; - assert_eq!(c.kind, ExportedTypeLikeKind::Class); - assert_markers(c.docstring.as_deref(), "class")?; - - let e = by_name - .get("CliEnumProbe") - .ok_or_else(|| std::io::Error::other("missing CliEnumProbe"))?; - assert_eq!(e.kind, ExportedTypeLikeKind::Enum); - assert_markers(e.docstring.as_deref(), "enum")?; - - let t = by_name - .get("CliTraitProbe") - .ok_or_else(|| std::io::Error::other("missing CliTraitProbe"))?; - assert_eq!(t.kind, ExportedTypeLikeKind::Trait); - assert_markers(t.docstring.as_deref(), "trait")?; - - let n = by_name - .get("CliNewtypeProbe") - .ok_or_else(|| std::io::Error::other("missing CliNewtypeProbe"))?; - assert_eq!(n.kind, ExportedTypeLikeKind::Newtype); - assert_markers(n.docstring.as_deref(), "newtype")?; - - Ok(()) + assert!(matched > 0, "No .incn fixtures found in {}", fixtures_dir.display()); } #[test] -fn test_cli_fmt_accepts_assert_identity_bool_literals() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("assert_identity_bool_literals.incn"); - fs::write( - &path, - r#" -def check_flags(ready: bool, done: bool) -> None: - assert ready is true, "ready should be true" - assert done is false -"#, - )?; - - let output = Command::new(incan_debug_binary()).arg("fmt").arg(&path).output()?; +fn test_help_is_banner_free() -> Result<(), Box> { + let output = incan_command().arg("--help").output()?; assert!( output.status.success(), - "expected `incan fmt` to accept assert identity checks against bool literals.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), + "incan --help failed: status={:?} stderr={}", + output.status, String::from_utf8_lossy(&output.stderr) ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stdout.contains("░░███") && !stderr.contains("░░███"), + "logo leaked into help output" + ); Ok(()) } -/// Regression (GitHub #484): parenthesized logical chains should wrap at obvious boolean breakpoints. #[test] -fn test_cli_fmt_wraps_long_parenthesized_logical_expression_chain() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("long_logical_chain.incn"); - fs::write( - &path, - r#"model Item: - kind_name: str - predicate_kind_name: str - source_name: str - - -def matches(item: Item) -> bool: - return (item.kind_name == "filter" and item.predicate_kind_name == "bool_literal" and item.source_name == "rewritten_prism_node") -"#, - )?; - - let status = Command::new(incan_debug_binary()).arg("fmt").arg(&path).status()?; - assert!(status.success(), "incan fmt failed"); - - let formatted = fs::read_to_string(&path)?; - let expected = r#"model Item: - kind_name: str - predicate_kind_name: str - source_name: str - - -def matches(item: Item) -> bool: - return ( - item.kind_name == "filter" - and item.predicate_kind_name == "bool_literal" - and item.source_name == "rewritten_prism_node" - ) -"#; - assert_eq!(formatted, expected); - assert!( - formatted.lines().all(|line| line.len() <= 120), - "expected formatted output to stay within 120 columns:\n{formatted}" - ); - - let output = Command::new(incan_debug_binary()).arg("--check").arg(&path).output()?; +fn test_version_is_single_line_and_banner_free() -> Result<(), Box> { + let output = incan_command().arg("--version").output()?; assert!( output.status.success(), - "expected wrapped expression to parse/typecheck after CLI fmt; stderr={}", + "incan --version failed: status={:?} stderr={}", + output.status, String::from_utf8_lossy(&output.stderr) ); - + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stdout.contains("░░███") && !stderr.contains("░░███"), + "logo leaked into version output" + ); + assert_eq!(stdout.lines().count(), 1, "expected single-line version output"); Ok(()) } -/// Regression (GitHub #289): `incan fmt` must preserve escaped newlines in f-strings as textual `\\n`. #[test] -fn test_cli_fmt_preserves_fstring_escaped_newline_roundtrip() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("fstring_escaped_newline.incn"); - fs::write( - &path, - r#"def main() -> str: - return f"a\n{1}" -"#, - )?; - - let status = Command::new(incan_debug_binary()).arg("fmt").arg(&path).status()?; - assert!(status.success(), "incan fmt failed"); +fn lifecycle_new_version_and_env_commands_work() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_dir = tmp.path().join("greeter"); - let formatted = fs::read_to_string(&path)?; + let new_output = incan_command() + .args(["new", "greeter", "--yes", "--dir"]) + .arg(&project_dir) + .args([ + "--description", + "A generated greeting app", + "--author", + "Danny ", + "--license", + "MIT", + ]) + .output()?; assert!( - formatted.contains(r#"f"a\n{1}""#), - "expected formatted output to preserve escaped newline text, got:\n{}", - formatted + new_output.status.success(), + "incan new failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&new_output.stdout), + String::from_utf8_lossy(&new_output.stderr) ); - let output = Command::new(incan_debug_binary()).arg("--check").arg(&path).output()?; + let manifest_path = project_dir.join("incan.toml"); + let initial_manifest = fs::read_to_string(&manifest_path)?; + assert!(initial_manifest.contains(r#"name = "greeter""#)); + assert!(initial_manifest.contains(r#"description = "A generated greeting app""#)); + assert!(initial_manifest.contains(r#"authors = ["Danny "]"#)); + assert!(initial_manifest.contains(r#"license = "MIT""#)); + assert!(project_dir.join("src/main.incn").exists()); + assert!(project_dir.join("tests/test_main.incn").exists()); + + let empty_list_output = incan_command() + .args(["env", "list"]) + .current_dir(&project_dir) + .output()?; assert!( - output.status.success(), - "expected formatted file to parse/typecheck after CLI fmt; stderr={}", - String::from_utf8_lossy(&output.stderr) + empty_list_output.status.success(), + "env list on fresh project failed: {}", + String::from_utf8_lossy(&empty_list_output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&empty_list_output.stdout).trim(), + "default", + "fresh projects should expose the ambient default env" ); - Ok(()) -} - -/// Regression (GitHub #336 / RFC 053): the CLI formatter must apply the vertical-spacing contract on disk. -#[test] -fn test_cli_fmt_applies_rfc053_vertical_spacing_contract() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("rfc053_vertical_spacing.incn"); - fs::write( - &path, - r#"type UserId = str -# comment about the alias - -model User: - """ - First paragraph. - - - Second paragraph. - """ - id: UserId - -trait Service: - def connect(self) -> None: ... - def reset(self) -> None: - pass -"#, - )?; - - let status = Command::new(incan_debug_binary()).arg("fmt").arg(&path).status()?; - assert!(status.success(), "incan fmt failed"); - - let formatted = fs::read_to_string(&path)?; - let expected = r#"type UserId = str -# comment about the alias - - -model User: - """ - First paragraph. + let default_overview_output = incan_command() + .args(["env", "show"]) + .current_dir(&project_dir) + .output()?; + assert!( + default_overview_output.status.success(), + "env show overview on fresh project failed: {}", + String::from_utf8_lossy(&default_overview_output.stderr) + ); + let default_overview_stdout = String::from_utf8_lossy(&default_overview_output.stdout); + assert!(default_overview_stdout.contains("Name")); + assert!(default_overview_stdout.contains("default")); - Second paragraph. - """ + let default_show_output = incan_command() + .args(["env", "show", "default"]) + .current_dir(&project_dir) + .output()?; + assert!( + default_show_output.status.success(), + "env show default on fresh project failed: {}", + String::from_utf8_lossy(&default_show_output.stderr) + ); + assert!( + String::from_utf8_lossy(&default_show_output.stdout).contains("overlay chain: project -> default"), + "unexpected env show default output:\n{}", + String::from_utf8_lossy(&default_show_output.stdout) + ); - id: UserId + let dry_run = incan_command() + .args(["version", "patch", "--dry-run"]) + .current_dir(&project_dir) + .output()?; + assert!( + dry_run.status.success(), + "dry-run failed: {}", + String::from_utf8_lossy(&dry_run.stderr) + ); + assert!( + String::from_utf8_lossy(&dry_run.stdout).contains("new version: 0.1.1"), + "unexpected dry-run output:\n{}", + String::from_utf8_lossy(&dry_run.stdout) + ); + assert_eq!( + fs::read_to_string(&manifest_path)?, + initial_manifest, + "dry-run must not modify incan.toml" + ); + let version_output = incan_command() + .args(["version", "patch"]) + .current_dir(&project_dir) + .output()?; + assert!( + version_output.status.success(), + "version bump failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&version_output.stdout), + String::from_utf8_lossy(&version_output.stderr) + ); + assert!(fs::read_to_string(&manifest_path)?.contains(r#"version = "0.1.1""#)); -trait Service: - def connect(self) -> None + let set_output = incan_command() + .args([ + "version", + "--set", + "2.0.0-rc.1", + "--project", + manifest_path.to_str().ok_or("manifest path is not valid UTF-8")?, + ]) + .current_dir(tmp.path()) + .output()?; + assert!( + set_output.status.success(), + "version set failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&set_output.stdout), + String::from_utf8_lossy(&set_output.stderr) + ); + assert!(fs::read_to_string(&manifest_path)?.contains(r#"version = "2.0.0-rc.1""#)); - def reset(self) -> None: - pass -"#; - assert_eq!(formatted, expected); + let keep_prerelease_output = incan_command() + .args([ + "version", + "patch", + "--keep-prerelease", + "--project", + project_dir.to_str().ok_or("project path is not valid UTF-8")?, + ]) + .current_dir(tmp.path()) + .output()?; + assert!( + keep_prerelease_output.status.success(), + "version keep-prerelease failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&keep_prerelease_output.stdout), + String::from_utf8_lossy(&keep_prerelease_output.stderr) + ); + assert!(fs::read_to_string(&manifest_path)?.contains(r#"version = "2.0.1-rc.1""#)); - let tokens = lexer::lex(&formatted) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; - parser::parse(&tokens) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + let missing_request_output = incan_command() + .args([ + "version", + "--project", + project_dir.to_str().ok_or("project path is not valid UTF-8")?, + ]) + .current_dir(tmp.path()) + .output()?; + assert!(!missing_request_output.status.success()); + assert!( + String::from_utf8_lossy(&missing_request_output.stderr).contains("requires a bump name or `--set `"), + "unexpected missing-request stderr:\n{}", + String::from_utf8_lossy(&missing_request_output.stderr) + ); - Ok(()) -} + let conflicting_request_output = incan_command() + .args([ + "version", + "patch", + "--set", + "3.0.0", + "--project", + project_dir.to_str().ok_or("project path is not valid UTF-8")?, + ]) + .current_dir(tmp.path()) + .output()?; + assert!(!conflicting_request_output.status.success()); + assert!( + String::from_utf8_lossy(&conflicting_request_output.stderr) + .contains("accepts either a bump name or `--set `, not both"), + "unexpected conflicting-request stderr:\n{}", + String::from_utf8_lossy(&conflicting_request_output.stderr) + ); -/// Regression (GitHub #336 / RFC 053): top-level type/function-shaped declarations keep two blank lines even when -/// adjacent to module statics. -#[test] -fn test_cli_fmt_keeps_two_blank_lines_between_static_and_function() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("rfc053_static_function_spacing.incn"); fs::write( - &path, - r#"static prism_store_node_counts: list[int] = [] -pub def allocate_prism_store_id() -> int: - return len(prism_store_node_counts) -"#, + &manifest_path, + format!( + "{}\n[rust-dependencies.serde]\nversion = \"1.0\"\nfeatures = [\"derive\"]\n\n[tool.incan.envs.default]\nenv-vars = {{ INCAN_NO_BANNER = \"1\" }}\n\n[tool.incan.envs.unit]\ncwd = \".\"\n\n[tool.incan.envs.unit.rust-dependencies.serde]\nversion = \"1.0\"\nfeatures = [\"alloc\"]\n\n[tool.incan.envs.unit.scripts]\nprobe = [\"{}\", \"--version\"]\n", + fs::read_to_string(&manifest_path)?, + incan_debug_binary().display() + ), )?; - let status = Command::new(incan_debug_binary()).arg("fmt").arg(&path).status()?; - assert!(status.success(), "incan fmt failed"); - - let formatted = fs::read_to_string(&path)?; - let expected = r#"static prism_store_node_counts: list[int] = [] - + let list_output = incan_command() + .args(["env", "list"]) + .current_dir(project_dir.join("src")) + .output()?; + assert!( + list_output.status.success(), + "env list failed: {}", + String::from_utf8_lossy(&list_output.stderr) + ); + let list_stdout = String::from_utf8_lossy(&list_output.stdout); + assert!(list_stdout.contains("default")); + assert!(list_stdout.contains("unit")); -pub def allocate_prism_store_id() -> int: - return len(prism_store_node_counts) -"#; - assert_eq!(formatted, expected); + let list_json_output = incan_command() + .args([ + "env", + "list", + "--format", + "json", + "--project", + project_dir.to_str().ok_or("project path is not valid UTF-8")?, + ]) + .current_dir(tmp.path()) + .output()?; + assert!( + list_json_output.status.success(), + "env list json failed: {}", + String::from_utf8_lossy(&list_json_output.stderr) + ); + let list_json: serde_json::Value = serde_json::from_slice(&list_json_output.stdout)?; + assert_eq!(list_json, serde_json::json!(["default", "unit"])); - let tokens = lexer::lex(&formatted) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; - parser::parse(&tokens) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + let show_output = incan_command() + .args(["env", "show", "unit"]) + .current_dir(&project_dir) + .output()?; + assert!( + show_output.status.success(), + "env show failed: {}", + String::from_utf8_lossy(&show_output.stderr) + ); + let show_stdout = String::from_utf8_lossy(&show_output.stdout); + assert!(show_stdout.contains("overlay chain: project -> default -> unit")); + assert!(show_stdout.contains("INCAN_NO_BANNER=1")); + assert!(show_stdout.contains("Dependencies")); + assert!(show_stdout.contains("serde")); + assert!(show_stdout.contains("alloc")); + assert!(show_stdout.contains("derive")); - Ok(()) -} + let show_overview_output = incan_command() + .args(["env", "show"]) + .current_dir(&project_dir) + .output()?; + assert!( + show_overview_output.status.success(), + "env show overview failed: {}", + String::from_utf8_lossy(&show_overview_output.stderr) + ); + let show_overview_stdout = String::from_utf8_lossy(&show_overview_output.stdout); + assert!(show_overview_stdout.contains("default")); + assert!(show_overview_stdout.contains("unit")); + assert!(show_overview_stdout.contains("Scripts")); -/// Regression (GitHub #336 / RFC 053): a trailing own-line comment after a multi-line construct must stay after the -/// full suite, not get reinserted after the construct header. -#[test] -fn test_cli_fmt_keeps_trailing_comment_after_multiline_function() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("rfc053_trailing_comment_after_function.incn"); - fs::write( - &path, - r#"def load_user(id: str) -> str: - return id - -# TODO: split retries -"#, - )?; - - let status = Command::new(incan_debug_binary()).arg("fmt").arg(&path).status()?; - assert!(status.success(), "incan fmt failed"); + let show_overview_json_output = incan_command() + .args([ + "env", + "show", + "--format", + "json", + "--project", + manifest_path.to_str().ok_or("manifest path is not valid UTF-8")?, + ]) + .current_dir(tmp.path()) + .output()?; + assert!( + show_overview_json_output.status.success(), + "env show overview json failed: {}", + String::from_utf8_lossy(&show_overview_json_output.stderr) + ); + let show_overview_json: serde_json::Value = serde_json::from_slice(&show_overview_json_output.stdout)?; + let show_overview_array = show_overview_json.as_array().ok_or("expected array json output")?; + assert_eq!(show_overview_array.len(), 2); + assert!(show_overview_array.iter().any(|entry| entry["name"] == "default")); + assert!(show_overview_array.iter().any(|entry| entry["name"] == "unit")); - let formatted = fs::read_to_string(&path)?; - let expected = r#"def load_user(id: str) -> str: - return id -# TODO: split retries -"#; - assert_eq!(formatted, expected); + let show_json_output = incan_command() + .args(["env", "show", "unit", "--format", "json"]) + .current_dir(&project_dir) + .output()?; + assert!( + show_json_output.status.success(), + "env show json failed: {}", + String::from_utf8_lossy(&show_json_output.stderr) + ); + let show_json: serde_json::Value = serde_json::from_slice(&show_json_output.stdout)?; + assert_eq!(show_json["env"], "unit"); + assert_eq!(show_json["dependencies"]["serde"]["version"], "1.0"); - let tokens = lexer::lex(&formatted) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; - parser::parse(&tokens) - .map_err(|errs| std::io::Error::other(errs.iter().map(|e| e.message.clone()).collect::>().join("\n")))?; + let dry_run_env = incan_command() + .args(["env", "run", "unit", "probe", "--dry-run"]) + .current_dir(&project_dir) + .output()?; + assert!( + dry_run_env.status.success(), + "env dry-run failed: {}", + String::from_utf8_lossy(&dry_run_env.stderr) + ); + assert!( + String::from_utf8_lossy(&dry_run_env.stdout).contains("--version"), + "unexpected env dry-run output:\n{}", + String::from_utf8_lossy(&dry_run_env.stdout) + ); + let run_env = incan_command() + .args(["env", "run", "unit", "probe"]) + .current_dir(&project_dir) + .output()?; + assert!( + run_env.status.success(), + "env run failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run_env.stdout), + String::from_utf8_lossy(&run_env.stderr) + ); + assert!(String::from_utf8_lossy(&run_env.stdout).starts_with("incan ")); Ok(()) } -/// Regression (GitHub #394): multiline function parameter lists must accept a trailing comma. #[test] -fn test_cli_check_accepts_trailing_comma_in_multiline_function_params() -> Result<(), Box> { - let dir = make_temp_test_dir(); - let path = dir.join("trailing_param_comma.incn"); +fn env_run_nested_incan_run_uses_dependency_overlay_override() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path(); + fs::create_dir_all(project_root.join("src"))?; fs::write( - &path, - r#"def identity( - value: int, -) -> int: - return value + project_root.join("incan.toml"), + format!( + r#"[project] +name = "env_overlay_exec" +version = "0.1.0" +[rust-dependencies.serde_json] +version = "999.0.0" -def main() -> None: - println(identity(1)) +[tool.incan.envs.unit.scripts] +run = ["{}", "run", "src/main.incn"] + +[tool.incan.envs.unit.rust-dependencies.serde_json] +version = "1.0" "#, + incan_debug_binary().display() + ), )?; + fs::write( + project_root.join("src/main.incn"), + r#"import rust::serde_json as json - let output = Command::new(incan_debug_binary()).arg("--check").arg(&path).output()?; - assert!( - output.status.success(), - "expected multiline trailing parameter comma to parse/typecheck; stderr={}", - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) -} - -/// Regression: float compound-assign with int RHS should typecheck (Python-like / promotion). -#[test] -fn test_compound_assign_float_with_int_rhs() { - let program = r#" def main() -> None: - mut y: float = 100.0 - y /= 3 - y %= 7 - println(y) -"#; - - let result = compile_source(program); - assert!(result.is_ok(), "Expected program to typecheck, got {:?}", result.err()); -} - -/// Test that all valid fixtures compile successfully -#[test] -fn test_valid_fixtures() { - let fixtures_dir = Path::new("tests/fixtures/valid"); - if !fixtures_dir.exists() { - return; // Skip if fixtures not present - } - - let mut matched = 0usize; - let Ok(entries) = fs::read_dir(fixtures_dir) else { - panic!("failed to read directory {}", fixtures_dir.display()); - }; - for entry in entries { - let Ok(entry) = entry else { continue }; - let path = entry.path(); - if is_incan_fixture(&path) { - matched += 1; - let result = compile_file(&path); - if let Err(errs) = result { - panic!( - "Expected {} to compile successfully, got errors: {:?}", - path.display(), - errs - ); - } - } - } - assert!(matched > 0, "No .incn fixtures found in {}", fixtures_dir.display()); -} - -/// Test that invalid fixtures produce errors -#[test] -fn test_invalid_fixtures() { - let fixtures_dir = Path::new("tests/fixtures/invalid"); - if !fixtures_dir.exists() { - return; // Skip if fixtures not present - } - - let mut matched = 0usize; - let Ok(entries) = fs::read_dir(fixtures_dir) else { - panic!("failed to read directory {}", fixtures_dir.display()); - }; - for entry in entries { - let Ok(entry) = entry else { continue }; - let path = entry.path(); - if is_incan_fixture(&path) { - matched += 1; - let result = compile_file(&path); - assert!( - result.is_err(), - "Expected {} to fail compilation, but it succeeded", - path.display() - ); - } - } - assert!(matched > 0, "No .incn fixtures found in {}", fixtures_dir.display()); -} + pass +"#, + )?; -#[test] -fn test_help_is_banner_free() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()).arg("--help").output()?; + let bare_run = incan_command() + .args(["run", "src/main.incn"]) + .current_dir(project_root) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( - output.status.success(), - "incan --help failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) + !bare_run.status.success(), + "plain run unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&bare_run.stdout), + String::from_utf8_lossy(&bare_run.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let bare_stderr = strip_ansi_escapes(&String::from_utf8_lossy(&bare_run.stderr)); assert!( - !stdout.contains("░░███") && !stderr.contains("░░███"), - "logo leaked into help output" + bare_stderr.contains("serde_json") && bare_stderr.contains("999.0.0"), + "expected invalid pinned dependency diagnostic, got:\n{}", + bare_stderr ); - Ok(()) -} -#[test] -fn test_version_is_single_line_and_banner_free() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()).arg("--version").output()?; + let env_run = incan_command() + .args(["env", "run", "unit", "run"]) + .current_dir(project_root) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( - output.status.success(), - "incan --version failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) + env_run.status.success(), + "env-backed nested run failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&env_run.stdout), + String::from_utf8_lossy(&env_run.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let env_stderr = strip_ansi_escapes(&String::from_utf8_lossy(&env_run.stderr)); assert!( - !stdout.contains("░░███") && !stderr.contains("░░███"), - "logo leaked into version output" + !env_stderr.contains("999.0.0"), + "nested env-backed run should use the overlay manifest instead of the broken base pin, got:\n{}", + env_stderr ); - assert_eq!(stdout.lines().count(), 1, "expected single-line version output"); Ok(()) } #[test] -fn lifecycle_new_version_and_env_commands_work() -> Result<(), Box> { +fn env_run_nested_incan_env_show_prefers_parent_project_override() -> Result<(), Box> { let tmp = tempfile::tempdir()?; - let project_dir = tmp.path().join("greeter"); + let project_root = tmp.path(); + fs::create_dir_all(project_root.join("child"))?; + fs::write( + project_root.join("incan.toml"), + format!( + r#"[project] +name = "parent_project" +version = "0.1.0" - let new_output = Command::new(incan_debug_binary()) - .args(["new", "greeter", "--yes", "--dir"]) - .arg(&project_dir) - .args([ - "--description", - "A generated greeting app", - "--author", - "Danny ", - "--license", - "MIT", - ]) - .output()?; - assert!( - new_output.status.success(), - "incan new failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&new_output.stdout), - String::from_utf8_lossy(&new_output.stderr) - ); +[tool.incan.envs.unit] +cwd = "child" +env-vars = {{ PARENT = "1" }} - let manifest_path = project_dir.join("incan.toml"); - let initial_manifest = fs::read_to_string(&manifest_path)?; - assert!(initial_manifest.contains(r#"name = "greeter""#)); - assert!(initial_manifest.contains(r#"description = "A generated greeting app""#)); - assert!(initial_manifest.contains(r#"authors = ["Danny "]"#)); - assert!(initial_manifest.contains(r#"license = "MIT""#)); - assert!(project_dir.join("src/main.incn").exists()); - assert!(project_dir.join("tests/test_main.incn").exists()); +[tool.incan.envs.unit.scripts] +inspect = ["{}", "env", "show", "unit", "--format", "json"] +"#, + incan_debug_binary().display() + ), + )?; + fs::write( + project_root.join("child/incan.toml"), + r#"[project] +name = "child_project" +version = "0.1.0" - let empty_list_output = Command::new(incan_debug_binary()) - .args(["env", "list"]) - .current_dir(&project_dir) +[tool.incan.envs.unit] +env-vars = { CHILD = "1" } +"#, + )?; + + let bare_show = incan_command() + .args(["env", "show", "unit", "--format", "json"]) + .current_dir(project_root.join("child")) .output()?; assert!( - empty_list_output.status.success(), - "env list on fresh project failed: {}", - String::from_utf8_lossy(&empty_list_output.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&empty_list_output.stdout).trim(), - "default", - "fresh projects should expose the ambient default env" + bare_show.status.success(), + "bare child env show failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&bare_show.stdout), + String::from_utf8_lossy(&bare_show.stderr) ); + let bare_json: serde_json::Value = serde_json::from_slice(&bare_show.stdout)?; + assert_eq!(bare_json["env_vars"]["CHILD"], "1"); + assert!(bare_json["env_vars"].get("PARENT").is_none()); - let default_overview_output = Command::new(incan_debug_binary()) - .args(["env", "show"]) - .current_dir(&project_dir) + let env_show = incan_command() + .args(["env", "run", "unit", "inspect"]) + .current_dir(project_root) .output()?; assert!( - default_overview_output.status.success(), - "env show overview on fresh project failed: {}", - String::from_utf8_lossy(&default_overview_output.stderr) + env_show.status.success(), + "env-backed nested env show failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&env_show.stdout), + String::from_utf8_lossy(&env_show.stderr) ); - let default_overview_stdout = String::from_utf8_lossy(&default_overview_output.stdout); - assert!(default_overview_stdout.contains("Name")); - assert!(default_overview_stdout.contains("default")); + let nested_json: serde_json::Value = serde_json::from_slice(&env_show.stdout)?; + assert_eq!(nested_json["env_vars"]["PARENT"], "1"); + assert!(nested_json["env_vars"].get("CHILD").is_none()); + Ok(()) +} - let default_show_output = Command::new(incan_debug_binary()) - .args(["env", "show", "default"]) - .current_dir(&project_dir) - .output()?; +#[test] +fn test_parse_error_is_banner_free() { + let Ok(output) = incan_command().arg("--definitely-not-a-flag").output() else { + panic!("failed to run incan with invalid args"); + }; assert!( - default_show_output.status.success(), - "env show default on fresh project failed: {}", - String::from_utf8_lossy(&default_show_output.stderr) + !output.status.success(), + "expected invalid args to fail, status={:?}", + output.status ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - String::from_utf8_lossy(&default_show_output.stdout).contains("overlay chain: project -> default"), - "unexpected env show default output:\n{}", - String::from_utf8_lossy(&default_show_output.stdout) + !stdout.contains("░░███") && !stderr.contains("░░███"), + "logo leaked into parse error output" ); +} + +#[test] +fn test_fstring_unknown_symbol_cli_caret_points_to_interpolation() { + let source = "def main() -> str:\n return f\"value: {unknown_var}\"\n"; + let Ok(output) = incan_command().args(["run", "-c", source]).output() else { + panic!("failed to run incan with f-string source"); + }; - let dry_run = Command::new(incan_debug_binary()) - .args(["version", "patch", "--dry-run"]) - .current_dir(&project_dir) - .output()?; assert!( - dry_run.status.success(), - "dry-run failed: {}", - String::from_utf8_lossy(&dry_run.stderr) + !output.status.success(), + "expected unknown symbol compilation failure, status={:?}", + output.status ); + + let stderr_colored = String::from_utf8_lossy(&output.stderr); + let stderr = strip_ansi_escapes(&stderr_colored); assert!( - String::from_utf8_lossy(&dry_run.stdout).contains("new version: 0.1.1"), - "unexpected dry-run output:\n{}", - String::from_utf8_lossy(&dry_run.stdout) + stderr.contains("Unknown symbol 'unknown_var'"), + "expected unknown symbol diagnostic in stderr, got:\n{}", + stderr ); - assert_eq!( - fs::read_to_string(&manifest_path)?, - initial_manifest, - "dry-run must not modify incan.toml" + assert!( + stderr.contains("return f\"value: {unknown_var}\""), + "expected source line in diagnostic, got:\n{}", + stderr ); - let version_output = Command::new(incan_debug_binary()) - .args(["version", "patch"]) - .current_dir(&project_dir) - .output()?; - assert!( - version_output.status.success(), - "version bump failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&version_output.stdout), - String::from_utf8_lossy(&version_output.stderr) + let caret_line = match stderr.lines().find(|line| line.contains('^')) { + Some(line) => line, + None => panic!("expected caret line in diagnostic, got:\n{}", stderr), + }; + + let mut max_caret_run = 0usize; + let mut current_run = 0usize; + for c in caret_line.chars() { + if c == '^' { + current_run += 1; + if current_run > max_caret_run { + max_caret_run = current_run; + } + } else { + current_run = 0; + } + } + + assert_eq!( + max_caret_run, + "{unknown_var}".len(), + "expected caret width to match interpolation span; stderr:\n{}", + stderr ); - assert!(fs::read_to_string(&manifest_path)?.contains(r#"version = "0.1.1""#)); +} - let set_output = Command::new(incan_debug_binary()) - .args([ - "version", - "--set", - "2.0.0-rc.1", - "--project", - manifest_path.to_str().ok_or("manifest path is not valid UTF-8")?, - ]) - .current_dir(tmp.path()) +#[test] +fn test_fstring_list_interpolation_uses_structured_formatting() -> Result<(), Box> { + let source = r#"def debug_values[T](values: list[T]) -> str: + return f"{values:?}" + +def display_values[T](values: list[T]) -> str: + return f"{values}" + +def main() -> None: + columns: list[str] = ["id", "amount"] + println(f"debug: {columns:?}") + println(f"display: {columns}") + println(debug_values[str](["id", "amount"])) + println(display_values[str](["id", "amount"])) +"#; + let output = incan_command() + .args(["run", "-c", source]) + .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( - set_output.status.success(), - "version set failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&set_output.stdout), - String::from_utf8_lossy(&set_output.stderr) + output.status.success(), + "expected list f-string interpolation to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); - assert!(fs::read_to_string(&manifest_path)?.contains(r#"version = "2.0.0-rc.1""#)); - let keep_prerelease_output = Command::new(incan_debug_binary()) - .args([ - "version", - "patch", - "--keep-prerelease", - "--project", - project_dir.to_str().ok_or("project path is not valid UTF-8")?, - ]) - .current_dir(tmp.path()) - .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - keep_prerelease_output.status.success(), - "version keep-prerelease failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&keep_prerelease_output.stdout), - String::from_utf8_lossy(&keep_prerelease_output.stderr) + stdout.contains("debug: [\"id\", \"amount\"]"), + "expected debug list output, got:\n{stdout}" ); - assert!(fs::read_to_string(&manifest_path)?.contains(r#"version = "2.0.1-rc.1""#)); - - let missing_request_output = Command::new(incan_debug_binary()) - .args([ - "version", - "--project", - project_dir.to_str().ok_or("project path is not valid UTF-8")?, - ]) - .current_dir(tmp.path()) - .output()?; - assert!(!missing_request_output.status.success()); assert!( - String::from_utf8_lossy(&missing_request_output.stderr).contains("requires a bump name or `--set `"), - "unexpected missing-request stderr:\n{}", - String::from_utf8_lossy(&missing_request_output.stderr) + stdout.contains("display: [\"id\", \"amount\"]"), + "expected default list f-string output to use structured formatting, got:\n{stdout}" ); - - let conflicting_request_output = Command::new(incan_debug_binary()) - .args([ - "version", - "patch", - "--set", - "3.0.0", - "--project", - project_dir.to_str().ok_or("project path is not valid UTF-8")?, - ]) - .current_dir(tmp.path()) - .output()?; - assert!(!conflicting_request_output.status.success()); assert!( - String::from_utf8_lossy(&conflicting_request_output.stderr) - .contains("accepts either a bump name or `--set `, not both"), - "unexpected conflicting-request stderr:\n{}", - String::from_utf8_lossy(&conflicting_request_output.stderr) + stdout.lines().filter(|line| *line == "[\"id\", \"amount\"]").count() == 2, + "expected both generic list helpers to render, got:\n{stdout}" ); - fs::write( - &manifest_path, - format!( - "{}\n[rust-dependencies.serde]\nversion = \"1.0\"\nfeatures = [\"derive\"]\n\n[tool.incan.envs.default]\nenv-vars = {{ INCAN_NO_BANNER = \"1\" }}\n\n[tool.incan.envs.unit]\ncwd = \".\"\n\n[tool.incan.envs.unit.rust-dependencies.serde]\nversion = \"1.0\"\nfeatures = [\"alloc\"]\n\n[tool.incan.envs.unit.scripts]\nprobe = [\"{}\", \"--version\"]\n", - fs::read_to_string(&manifest_path)?, - incan_debug_binary().display() - ), - )?; - - let list_output = Command::new(incan_debug_binary()) - .args(["env", "list"]) - .current_dir(project_dir.join("src")) - .output()?; - assert!( - list_output.status.success(), - "env list failed: {}", - String::from_utf8_lossy(&list_output.stderr) - ); - let list_stdout = String::from_utf8_lossy(&list_output.stdout); - assert!(list_stdout.contains("default")); - assert!(list_stdout.contains("unit")); - - let list_json_output = Command::new(incan_debug_binary()) - .args([ - "env", - "list", - "--format", - "json", - "--project", - project_dir.to_str().ok_or("project path is not valid UTF-8")?, - ]) - .current_dir(tmp.path()) - .output()?; - assert!( - list_json_output.status.success(), - "env list json failed: {}", - String::from_utf8_lossy(&list_json_output.stderr) - ); - let list_json: serde_json::Value = serde_json::from_slice(&list_json_output.stdout)?; - assert_eq!(list_json, serde_json::json!(["default", "unit"])); + Ok(()) +} - let show_output = Command::new(incan_debug_binary()) - .args(["env", "show", "unit"]) - .current_dir(&project_dir) - .output()?; - assert!( - show_output.status.success(), - "env show failed: {}", - String::from_utf8_lossy(&show_output.stderr) - ); - let show_stdout = String::from_utf8_lossy(&show_output.stdout); - assert!(show_stdout.contains("overlay chain: project -> default -> unit")); - assert!(show_stdout.contains("INCAN_NO_BANNER=1")); - assert!(show_stdout.contains("Dependencies")); - assert!(show_stdout.contains("serde")); - assert!(show_stdout.contains("alloc")); - assert!(show_stdout.contains("derive")); +#[test] +fn fixed_call_unpack_runs_for_positional_and_keyword_shapes() -> Result<(), Box> { + let source = r#" +def total(a: int, b: int, *rest: int, **labels: str) -> int: + println(labels["city"]) + return a + b + rest[0] - let show_overview_output = Command::new(incan_debug_binary()) - .args(["env", "show"]) - .current_dir(&project_dir) - .output()?; - assert!( - show_overview_output.status.success(), - "env show overview failed: {}", - String::from_utf8_lossy(&show_overview_output.stderr) - ); - let show_overview_stdout = String::from_utf8_lossy(&show_overview_output.stdout); - assert!(show_overview_stdout.contains("default")); - assert!(show_overview_stdout.contains("unit")); - assert!(show_overview_stdout.contains("Scripts")); +def route(path: str, method: str) -> str: + return method + " " + path - let show_overview_json_output = Command::new(incan_debug_binary()) - .args([ - "env", - "show", - "--format", - "json", - "--project", - manifest_path.to_str().ok_or("manifest path is not valid UTF-8")?, - ]) - .current_dir(tmp.path()) - .output()?; - assert!( - show_overview_json_output.status.success(), - "env show overview json failed: {}", - String::from_utf8_lossy(&show_overview_json_output.stderr) - ); - let show_overview_json: serde_json::Value = serde_json::from_slice(&show_overview_json_output.stdout)?; - let show_overview_array = show_overview_json.as_array().ok_or("expected array json output")?; - assert_eq!(show_overview_array.len(), 2); - assert!(show_overview_array.iter().any(|entry| entry["name"] == "default")); - assert!(show_overview_array.iter().any(|entry| entry["name"] == "unit")); +class Counter: + def add(self, left: int, right: int) -> int: + return left + right - let show_json_output = Command::new(incan_debug_binary()) - .args(["env", "show", "unit", "--format", "json"]) - .current_dir(&project_dir) +def main() -> None: + xy: tuple[int, int] = (2, 3) + counter = Counter() + println(total(*xy, *[4], **{"city": "London"})) + println(route(**{"path": "/status", "method": "GET"})) + println(counter.add(*(5, 6))) +"#; + let output = incan_command() + .args(["run", "-c", source]) + .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( - show_json_output.status.success(), - "env show json failed: {}", - String::from_utf8_lossy(&show_json_output.stderr) - ); - let show_json: serde_json::Value = serde_json::from_slice(&show_json_output.stdout)?; - assert_eq!(show_json["env"], "unit"); - assert_eq!(show_json["dependencies"]["serde"]["version"], "1.0"); - let dry_run_env = Command::new(incan_debug_binary()) - .args(["env", "run", "unit", "probe", "--dry-run"]) - .current_dir(&project_dir) - .output()?; - assert!( - dry_run_env.status.success(), - "env dry-run failed: {}", - String::from_utf8_lossy(&dry_run_env.stderr) - ); assert!( - String::from_utf8_lossy(&dry_run_env.stdout).contains("--version"), - "unexpected env dry-run output:\n{}", - String::from_utf8_lossy(&dry_run_env.stdout) + output.status.success(), + "expected fixed call unpack program to run, status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); - - let run_env = Command::new(incan_debug_binary()) - .args(["env", "run", "unit", "probe"]) - .current_dir(&project_dir) - .output()?; - assert!( - run_env.status.success(), - "env run failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&run_env.stdout), - String::from_utf8_lossy(&run_env.stderr) + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["London", "9", "GET /status", "11"], + "unexpected fixed unpack runtime output:\n{stdout}" ); - assert!(String::from_utf8_lossy(&run_env.stdout).starts_with("incan ")); Ok(()) } #[test] -fn env_run_nested_incan_run_uses_dependency_overlay_override() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path(); - fs::create_dir_all(project_root.join("src"))?; - fs::write( - project_root.join("incan.toml"), - format!( - r#"[project] -name = "env_overlay_exec" -version = "0.1.0" +fn rfc046_computed_properties_run_as_getters() -> Result<(), Box> { + let source = r#"trait Named: + property label -> str -[rust-dependencies.serde_json] -version = "999.0.0" +model Money with Named: + cents: int -[tool.incan.envs.unit.scripts] -run = ["{}", "run", "src/main.incn"] + pub property adjusted -> int: + return self.cents + 1 -[tool.incan.envs.unit.rust-dependencies.serde_json] -version = "1.0" -"#, - incan_debug_binary().display() - ), - )?; - fs::write( - project_root.join("src/main.incn"), - r#"import rust::serde_json as json + property label -> str: + return "money" def main() -> None: - pass -"#, - )?; - - let bare_run = Command::new(incan_debug_binary()) - .args(["run", "src/main.incn"]) - .current_dir(project_root) + value = Money(cents=250) + println(value.adjusted) + println(value.label) +"#; + let output = incan_command() + .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( - !bare_run.status.success(), - "plain run unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&bare_run.stdout), - String::from_utf8_lossy(&bare_run.stderr) - ); - let bare_stderr = strip_ansi_escapes(&String::from_utf8_lossy(&bare_run.stderr)); - assert!( - bare_stderr.contains("serde_json") && bare_stderr.contains("999.0.0"), - "expected invalid pinned dependency diagnostic, got:\n{}", - bare_stderr - ); - let env_run = Command::new(incan_debug_binary()) - .args(["env", "run", "unit", "run"]) - .current_dir(project_root) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - env_run.status.success(), - "env-backed nested run failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&env_run.stdout), - String::from_utf8_lossy(&env_run.stderr) - ); - let env_stderr = strip_ansi_escapes(&String::from_utf8_lossy(&env_run.stderr)); assert!( - !env_stderr.contains("999.0.0"), - "nested env-backed run should use the overlay manifest instead of the broken base pin, got:\n{}", - env_stderr + output.status.success(), + "expected computed property program to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "251\nmoney\n"); Ok(()) } #[test] -fn env_run_nested_incan_env_show_prefers_parent_project_override() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path(); - fs::create_dir_all(project_root.join("child"))?; - fs::write( - project_root.join("incan.toml"), - format!( - r#"[project] -name = "parent_project" -version = "0.1.0" - -[tool.incan.envs.unit] -cwd = "child" -env-vars = {{ PARENT = "1" }} - -[tool.incan.envs.unit.scripts] -inspect = ["{}", "env", "show", "unit", "--format", "json"] -"#, - incan_debug_binary().display() +fn runtime_error_canonicalization_cases() -> Result<(), Box> { + let cases: &[(&str, &str, &[&str])] = &[ + ( + "def main() -> None:\n let values = {\"a\": 1}\n println(values[\"b\"])\n", + "KeyError", + &["not found in dict"], ), - )?; - fs::write( - project_root.join("child/incan.toml"), - r#"[project] -name = "child_project" -version = "0.1.0" + ( + "def main() -> None:\n let values = [1, 2, 3]\n println(values[99])\n", + "IndexError", + &["out of range for list"], + ), + ( + "def main() -> None:\n let values = [1, 2, 3]\n println(values.index(99))\n", + "ValueError", + &["value not found in list"], + ), + ( + "def main() -> None:\n println(int(\"abc\"))\n", + "ValueError", + &["cannot convert 'abc' to int"], + ), + ( + "def main() -> None:\n println(float(\"abc\"))\n", + "ValueError", + &["cannot convert 'abc' to float"], + ), + ( + "def main() -> None:\n mut values = [1, 2, 3]\n values.remove(99)\n", + "IndexError", + &["out of range for list"], + ), + ( + "def main() -> None:\n mut values = [1, 2, 3]\n values.swap(0, 99)\n", + "IndexError", + &["out of range for list"], + ), + ]; + for (source, expected_type, expected_substrings) in cases { + assert_runtime_error_cli(source, expected_type, expected_substrings)?; + } + Ok(()) +} -[tool.incan.envs.unit] -env-vars = { CHILD = "1" } +#[test] +fn test_fail_on_empty_collection() { + let dir = make_temp_test_dir(); + let test_file = dir.join("test_empty.incn"); + let Ok(()) = std::fs::write( + &test_file, + r#" +def helper() -> Unit: + pass "#, - )?; - - let bare_show = Command::new(incan_debug_binary()) - .args(["env", "show", "unit", "--format", "json"]) - .current_dir(project_root.join("child")) - .output()?; - assert!( - bare_show.status.success(), - "bare child env show failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&bare_show.stdout), - String::from_utf8_lossy(&bare_show.stderr) - ); - let bare_json: serde_json::Value = serde_json::from_slice(&bare_show.stdout)?; - assert_eq!(bare_json["env_vars"]["CHILD"], "1"); - assert!(bare_json["env_vars"].get("PARENT").is_none()); + ) else { + panic!("failed to write test file"); + }; - let env_show = Command::new(incan_debug_binary()) - .args(["env", "run", "unit", "inspect"]) - .current_dir(project_root) - .output()?; + let Ok(output) = incan_command().args(["test", dir.to_string_lossy().as_ref()]).output() else { + panic!("failed to run incan test"); + }; assert!( - env_show.status.success(), - "env-backed nested env show failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&env_show.stdout), - String::from_utf8_lossy(&env_show.stderr) + output.status.success(), + "expected empty collection to succeed by default: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - let nested_json: serde_json::Value = serde_json::from_slice(&env_show.stdout)?; - assert_eq!(nested_json["env_vars"]["PARENT"], "1"); - assert!(nested_json["env_vars"].get("CHILD").is_none()); - Ok(()) -} -#[test] -fn test_parse_error_is_banner_free() { - let Ok(output) = Command::new(incan_debug_binary()) - .arg("--definitely-not-a-flag") + let Ok(output) = incan_command() + .args(["test", "--fail-on-empty", dir.to_string_lossy().as_ref()]) .output() else { - panic!("failed to run incan with invalid args"); + panic!("failed to run incan test --fail-on-empty"); }; assert!( !output.status.success(), - "expected invalid args to fail, status={:?}", - output.status - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stdout.contains("░░███") && !stderr.contains("░░███"), - "logo leaked into parse error output" + "expected empty collection to fail with --fail-on-empty: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); } #[test] -fn test_fstring_unknown_symbol_cli_caret_points_to_interpolation() { - let source = "def main() -> str:\n return f\"value: {unknown_var}\"\n"; - let Ok(output) = Command::new(incan_debug_binary()).args(["run", "-c", source]).output() else { - panic!("failed to run incan with f-string source"); - }; +fn test_rfc052_module_static_counter_runs() { + let source = r#" +static counter: int = 0 - assert!( - !output.status.success(), - "expected unknown symbol compilation failure, status={:?}", - output.status - ); +def main() -> None: + counter = counter + 1 + counter += 2 + println(counter) +"#; + let Ok(output) = incan_command() + .args(["run", "-c", source]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan with static counter source"); + }; - let stderr_colored = String::from_utf8_lossy(&output.stderr); - let stderr = strip_ansi_escapes(&stderr_colored); assert!( - stderr.contains("Unknown symbol 'unknown_var'"), - "expected unknown symbol diagnostic in stderr, got:\n{}", - stderr + output.status.success(), + "expected static counter program to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); assert!( - stderr.contains("return f\"value: {unknown_var}\""), - "expected source line in diagnostic, got:\n{}", - stderr - ); - - let caret_line = match stderr.lines().find(|line| line.contains('^')) { - Some(line) => line, - None => panic!("expected caret line in diagnostic, got:\n{}", stderr), - }; - - let mut max_caret_run = 0usize; - let mut current_run = 0usize; - for c in caret_line.chars() { - if c == '^' { - current_run += 1; - if current_run > max_caret_run { - max_caret_run = current_run; - } - } else { - current_run = 0; - } - } - - assert_eq!( - max_caret_run, - "{unknown_var}".len(), - "expected caret width to match interpolation span; stderr:\n{}", - stderr + String::from_utf8_lossy(&output.stdout).contains('3'), + "expected static counter output to contain 3.\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) ); } #[test] -fn test_fstring_list_interpolation_uses_structured_formatting() -> Result<(), Box> { - let source = r#"def debug_values[T](values: list[T]) -> str: - return f"{values:?}" +fn test_rfc052_static_initializer_runs_before_main_without_static_reads() { + let source = r#" +def init_counter() -> int: + println("init") + return 1 -def display_values[T](values: list[T]) -> str: - return f"{values}" +static counter: int = init_counter() def main() -> None: - columns: list[str] = ["id", "amount"] - println(f"debug: {columns:?}") - println(f"display: {columns}") - println(debug_values[str](["id", "amount"])) - println(display_values[str](["id", "amount"])) + println("main") "#; - let output = Command::new(incan_debug_binary()) + let Ok(output) = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") - .output()?; + .output() + else { + panic!("failed to run incan with eager static initializer source"); + }; + assert!( output.status.success(), - "expected list f-string interpolation to run.\nstdout:\n{}\nstderr:\n{}", + "expected eager static initializer program to run.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); assert!( - stdout.contains("debug: [\"id\", \"amount\"]"), - "expected debug list output, got:\n{stdout}" - ); - assert!( - stdout.contains("display: [\"id\", \"amount\"]"), - "expected default list f-string output to use structured formatting, got:\n{stdout}" - ); - assert!( - stdout.lines().filter(|line| *line == "[\"id\", \"amount\"]").count() == 2, - "expected both generic list helpers to render, got:\n{stdout}" + lines.len() >= 2 && lines[0] == "init" && lines[1] == "main", + "expected initializer output before main output.\nstdout:\n{}", + stdout ); - - Ok(()) } #[test] -fn fixed_call_unpack_runs_for_positional_and_keyword_shapes() -> Result<(), Box> { +fn test_rfc052_static_alias_mutation_runs() { let source = r#" -def total(a: int, b: int, *rest: int, **labels: str) -> int: - println(labels["city"]) - return a + b + rest[0] - -def route(path: str, method: str) -> str: - return method + " " + path - -class Counter: - def add(self, left: int, right: int) -> int: - return left + right +static items: list[int] = [] def main() -> None: - xy: tuple[int, int] = (2, 3) - counter = Counter() - println(total(*xy, *[4], **{"city": "London"})) - println(route(**{"path": "/status", "method": "GET"})) - println(counter.add(*(5, 6))) + let live = items + live.append(1) + live.append(2) + println(len(items)) + println(len(live)) "#; - let output = Command::new(incan_debug_binary()) + let Ok(output) = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") - .output()?; + .output() + else { + panic!("failed to run incan with static alias source"); + }; assert!( output.status.success(), - "expected fixed call unpack program to run, status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, + "expected static alias program to run.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["London", "9", "GET /status", "11"], - "unexpected fixed unpack runtime output:\n{stdout}" + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.lines().filter(|line| line.trim() == "2").count() >= 2, + "expected static alias output to print 2 twice.\nstdout:\n{stdout}" ); - Ok(()) } #[test] -fn rfc046_computed_properties_run_as_getters() -> Result<(), Box> { - let source = r#"trait Named: - property label -> str - -model Money with Named: - cents: int - - pub property adjusted -> int: - return self.cents + 1 - - property label -> str: - return "money" +fn test_static_list_index_assignment_and_remove_compile_and_run() -> Result<(), Box> { + let source = r#" +static entries: list[int] = [] def main() -> None: - value = Money(cents=250) - println(value.adjusted) - println(value.label) + entries.append(1) + entries[0] = 2 + println(entries[0]) + entries.remove(0) + entries.append(3) + println(entries[0]) "#; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "expected computed property program to run.\nstdout:\n{}\nstderr:\n{}", + "expected static list index mutation program to run.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert_eq!(String::from_utf8_lossy(&output.stdout), "251\nmoney\n"); - Ok(()) -} - -#[test] -fn runtime_error_missing_dict_key_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n let values = {\"a\": 1}\n println(values[\"b\"])\n", - "KeyError", - &["not found in dict"], - ) -} - -#[test] -fn runtime_error_list_index_out_of_range_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n let values = [1, 2, 3]\n println(values[99])\n", - "IndexError", - &["out of range for list"], - ) -} - -#[test] -fn runtime_error_list_index_method_not_found_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n let values = [1, 2, 3]\n println(values.index(99))\n", - "ValueError", - &["value not found in list"], - ) -} - -#[test] -fn runtime_error_int_conversion_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n println(int(\"abc\"))\n", - "ValueError", - &["cannot convert 'abc' to int"], - ) -} - -#[test] -fn runtime_error_float_conversion_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n println(float(\"abc\"))\n", - "ValueError", - &["cannot convert 'abc' to float"], - ) -} - -#[test] -fn runtime_error_list_remove_out_of_range_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n mut values = [1, 2, 3]\n values.remove(99)\n", - "IndexError", - &["out of range for list"], - ) -} - -#[test] -fn runtime_error_list_swap_out_of_range_is_canonical() -> Result<(), Box> { - assert_runtime_error_cli( - "def main() -> None:\n mut values = [1, 2, 3]\n values.swap(0, 99)\n", - "IndexError", - &["out of range for list"], - ) -} - -#[test] -fn runtime_error_route_marker_runtime_misuse_is_explicit() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let web_macros_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("crates") - .join("incan_web_macros"); - let manifest = format!( - "[project]\nname = \"route_runtime_misuse\"\nversion = \"0.3.0-dev.1\"\n\n[rust-dependencies]\nincan_web_macros = {{ path = \"{}\" }}\n", - web_macros_path.display() - ); - let src_dir = tmp.path().join("src"); - fs::create_dir_all(&src_dir)?; - fs::write(tmp.path().join("incan.toml"), manifest)?; - let main_path = src_dir.join("main.incn"); - fs::write( - &main_path, - "from std.web import route\n\ndef main() -> None:\n route(\"/users\", methods=[\"GET\"])\n", - )?; - - let check_output = Command::new(incan_debug_binary()) - .arg("--check") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - check_output.status.success(), - "expected --check to succeed so the failure is runtime.\nstderr:\n{}", - String::from_utf8_lossy(&check_output.stderr) - ); - - let run_output = Command::new(incan_debug_binary()) - .arg("run") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - !run_output.status.success(), - "expected runtime failure, stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&run_output.stdout), - String::from_utf8_lossy(&run_output.stderr) - ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&run_output.stdout)); - let stderr = strip_ansi_escapes(&String::from_utf8_lossy(&run_output.stderr)); - let combined = format!("{stdout}\n{stderr}"); - assert!( - combined.contains("decorator marker 'incan_web_macros::route' cannot be called at runtime"), - "expected explicit decorator misuse runtime diagnostic, got:\n{combined}" - ); - Ok(()) -} - -#[test] -fn test_fail_on_empty_collection() { - let dir = make_temp_test_dir(); - let test_file = dir.join("test_empty.incn"); - let Ok(()) = std::fs::write( - &test_file, - r#" -def helper() -> Unit: - pass -"#, - ) else { - panic!("failed to write test file"); - }; - - let Ok(output) = Command::new(incan_debug_binary()) - .args(["test", dir.to_string_lossy().as_ref()]) - .output() - else { - panic!("failed to run incan test"); - }; - assert!( - output.status.success(), - "expected empty collection to succeed by default: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - - let Ok(output) = Command::new(incan_debug_binary()) - .args(["test", "--fail-on-empty", dir.to_string_lossy().as_ref()]) - .output() - else { - panic!("failed to run incan test --fail-on-empty"); - }; - assert!( - !output.status.success(), - "expected empty collection to fail with --fail-on-empty: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn test_rfc052_module_static_counter_runs() { - let source = r#" -static counter: int = 0 - -def main() -> None: - counter = counter + 1 - counter += 2 - println(counter) -"#; - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan with static counter source"); - }; - - assert!( - output.status.success(), - "expected static counter program to run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stdout).contains('3'), - "expected static counter output to contain 3.\nstdout:\n{}", - String::from_utf8_lossy(&output.stdout) - ); -} - -#[test] -fn test_rfc052_static_initializer_runs_before_main_without_static_reads() { - let source = r#" -def init_counter() -> int: - println("init") - return 1 - -static counter: int = init_counter() - -def main() -> None: - println("main") -"#; - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan with eager static initializer source"); - }; - - assert!( - output.status.success(), - "expected eager static initializer program to run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert!( - lines.len() >= 2 && lines[0] == "init" && lines[1] == "main", - "expected initializer output before main output.\nstdout:\n{}", - stdout - ); -} - -#[test] -fn test_rfc052_static_alias_mutation_runs() { - let source = r#" -static items: list[int] = [] - -def main() -> None: - let live = items - live.append(1) - live.append(2) - println(len(items)) - println(len(live)) -"#; - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan with static alias source"); - }; - - assert!( - output.status.success(), - "expected static alias program to run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.lines().filter(|line| line.trim() == "2").count() >= 2, - "expected static alias output to print 2 twice.\nstdout:\n{stdout}" - ); -} - -#[test] -fn test_static_list_index_assignment_and_remove_compile_and_run() -> Result<(), Box> { - let source = r#" -static entries: list[int] = [] - -def main() -> None: - entries.append(1) - entries[0] = 2 - println(entries[0]) - entries.remove(0) - entries.append(3) - println(entries[0]) -"#; - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected static list index mutation program to run.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, ["2", "3"], "unexpected static list mutation output"); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, ["2", "3"], "unexpected static list mutation output"); Ok(()) } @@ -2382,7 +2014,7 @@ def main() -> None: println(c[0]) println(c[3]) "#; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; @@ -2419,7 +2051,7 @@ def main() -> None: println(find_value(True)) println(find_value(False)) "#; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; @@ -2465,7 +2097,7 @@ def main() -> None: Some(parsed_status) => println(parsed_status.value()) None => println(0) "#; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; @@ -2500,7 +2132,7 @@ def main() -> None: println(len(b)) println(b[0]) "#; - let output = Command::new(incan_debug_binary()) + let output = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; @@ -2535,7 +2167,7 @@ def main() -> None: println(items[0]) println(items[1]) "#; - let Ok(output) = Command::new(incan_debug_binary()) + let Ok(output) = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output() @@ -2574,7 +2206,7 @@ def main() -> None: println(init_order[0]) println(init_order[1]) "#; - let Ok(output) = Command::new(incan_debug_binary()) + let Ok(output) = incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output() @@ -2604,7 +2236,7 @@ mod lexer_tests { use incan_core::lang::punctuation::PunctuationId; #[test] - fn test_floor_div_tokens() { + fn lexer_token_surface_cases() { let Ok(tokens) = lex("a //= b\nc // d") else { panic!("lex failed"); }; @@ -2612,10 +2244,7 @@ mod lexer_tests { let has_floor_div = tokens.iter().any(|t| t.kind.is_operator(OperatorId::SlashSlash)); assert!(has_floor_div_eq, "expected to see //= token"); assert!(has_floor_div, "expected to see // token"); - } - #[test] - fn test_rust_style_imports() { let Ok(tokens) = lex("import foo::bar::baz as fb") else { panic!("lex failed"); }; @@ -2627,1671 +2256,381 @@ mod lexer_tests { assert!(matches!(&tokens[5].kind, TokenKind::Ident(s) if s == "baz")); assert!(tokens[6].kind.is_keyword(KeywordId::As)); assert!(matches!(&tokens[7].kind, TokenKind::Ident(s) if s == "fb")); - } - #[test] - fn test_try_operator() { let Ok(tokens) = lex("result?") else { - panic!("lex failed"); - }; - assert!(matches!(&tokens[0].kind, TokenKind::Ident(s) if s == "result")); - assert!(tokens[1].kind.is_punctuation(PunctuationId::Question)); - } - - #[test] - fn test_fat_arrow() { - let Ok(tokens) = lex("x => y") else { - panic!("lex failed"); - }; - assert!(tokens[1].kind.is_punctuation(PunctuationId::FatArrow)); - } - - #[test] - fn test_case_keyword() { - let Ok(tokens) = lex("case Some(x):") else { - panic!("lex failed"); - }; - assert!(tokens[0].kind.is_keyword(KeywordId::Case)); - } - - #[test] - fn test_pass_keyword() { - let Ok(tokens) = lex("pass") else { - panic!("lex failed"); - }; - assert!(tokens[0].kind.is_keyword(KeywordId::Pass)); - } - - #[test] - fn test_mut_self() { - let Ok(tokens) = lex("mut self") else { - panic!("lex failed"); - }; - assert!(tokens[0].kind.is_keyword(KeywordId::Mut)); - assert!(tokens[1].kind.is_keyword(KeywordId::SelfKw)); - } - - #[test] - fn test_fstring() { - let Ok(tokens) = lex(r#"f"Hello {name}""#) else { - panic!("lex failed"); - }; - assert!(matches!(&tokens[0].kind, TokenKind::FString(_))); - } - - #[test] - fn test_yield_keyword() { - let Ok(tokens) = lex("yield value") else { - panic!("lex failed"); - }; - assert!(tokens[0].kind.is_keyword(KeywordId::Yield)); - assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "value")); - } - - #[test] - fn test_rust_keyword() { - let Ok(tokens) = lex("import rust::serde_json") else { - panic!("lex failed"); - }; - assert!(tokens[0].kind.is_keyword(KeywordId::Import)); - assert!(tokens[1].kind.is_keyword(KeywordId::Rust)); - assert!(tokens[2].kind.is_punctuation(PunctuationId::ColonColon)); - assert!(matches!(&tokens[3].kind, TokenKind::Ident(s) if s == "serde_json")); - } -} - -mod numeric_semantics_tests { - use incan::frontend::{lexer, parser, typechecker}; - - #[test] - fn test_python_like_numeric_ops_compile() { - let source = r#" -def main() -> None: - a: int = 7 - b: int = -3 - x = a / b # float - y = a // b # floor div - z = a % b # python remainder - f: float = 7.0 - g = f % 2.0 - h = f // 2.0 -"#; - let Ok(tokens) = lexer::lex(source) else { - panic!("lexing failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(()) = typechecker::check(&ast) else { - panic!("typecheck failed"); - }; - } -} - -/// End-to-end codegen tests -mod codegen_tests { - use super::{incan_debug_binary, strip_ansi_escapes}; - use incan::backend::IrCodegen; - use incan::frontend::{lexer, parser, typechecker}; - use std::fs; - use std::path::Path; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn run_incan_source(source: &str) -> std::process::Output { - Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output() - .unwrap_or_else(|e| panic!("failed to run incan source: {e}")) - } - - fn rustc_compile_ok(source: &str) -> Result<(), String> { - let mut dir = std::env::temp_dir(); - let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else { - panic!("system time before UNIX epoch"); - }; - let uniq = duration.as_nanos(); - dir.push(format!("incan_bench_smoke_{}", uniq)); - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - - let rs_path = dir.join("main.rs"); - let bin_path = dir.join("bin"); - std::fs::write(&rs_path, source).map_err(|e| e.to_string())?; - - let out = Command::new("rustc") - .arg("--edition=2021") - .arg(&rs_path) - .arg("-o") - .arg(&bin_path) - .output() - .map_err(|e| e.to_string())?; - - if out.status.success() { - Ok(()) - } else { - Err(String::from_utf8_lossy(&out.stderr).to_string()) - } - } - - fn make_temp_dir(prefix: &str) -> std::path::PathBuf { - let mut dir = std::env::temp_dir(); - let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else { - panic!("system time before UNIX epoch"); - }; - let uniq = duration.as_nanos(); - dir.push(format!("{}_{}", prefix, uniq)); - let Ok(()) = std::fs::create_dir_all(&dir) else { - panic!("failed to create temp dir"); - }; - dir - } - - #[test] - fn test_hello_world_codegen() { - let path = Path::new("examples/hello.incn"); - if !path.exists() { - return; // Skip if example not present - } - - let Ok(source) = fs::read_to_string(path) else { - panic!("failed to read {}", path.display()); - }; - let Ok(tokens) = lexer::lex(&source) else { - panic!("lexing failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(()) = typechecker::check(&ast) else { - panic!("typecheck failed"); - }; - let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { - panic!("codegen failed"); - }; - - // Verify the generated code contains expected elements - assert!(rust_code.contains("fn main()"), "Should have main function"); - assert!(rust_code.contains("println!"), "Should have println macro"); - assert!(rust_code.contains("Hello from Incan!"), "Should have the message"); - } - - #[test] - fn test_string_literal_match_patterns_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def describe(value: str) -> str: - match value: - case "star": - return "literal" - case other: - return other.upper() - -def describe_alt(value: str) -> str: - mut out = "" - match value: - "star" | "sun" => out += "literal" - other => out += other.upper() - return out - -def main() -> None: - println(describe("star")) - println(describe("fallback")) - println(describe_alt("sun")) - println(describe_alt("fallback")) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "string literal match pattern regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["literal", "FALLBACK", "literal", "FALLBACK"], - "unexpected string match output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_payload_enum_without_equality_payload_compiles() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -model Payload: - value: str - -enum Token: - Item(Payload) - Empty - -enum Mode: - Fast - Slow - -def describe(token: Token) -> str: - match token: - case Token.Item(payload): - return payload.value - case Token.Empty: - return "empty" - -def main() -> None: - if Mode.Fast == Mode.Fast: - println(describe(Token.Item(Payload(value="ok")))) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "payload enum derive regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["ok"], "unexpected payload enum output:\n{stdout}"); - Ok(()) - } - - #[test] - fn test_method_alias_codegen_rewrites_to_target_method() { - let source = r#" -model Stats: - value: int - mean = avg - - def avg(self) -> int: - return self.value - -def main() -> None: - let stats = Stats(value=10) - println(stats.mean()) -"#; - let Ok(tokens) = lexer::lex(source) else { - panic!("lex failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { - panic!("codegen failed"); - }; - assert!( - rust_code.contains(".avg("), - "expected method alias call to lower to target method, got:\n{rust_code}" - ); - assert!( - !rust_code.contains(".mean("), - "method alias must not emit an independent wrapper call, got:\n{rust_code}" - ); - } - - #[test] - fn test_run_c_import_this() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", "import this"]) - // This test should not require network access. We expect the workspace dependencies to already be available - // (the test suite built them) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run -c import this failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("The Zen of Incan") && stdout.contains("Readability counts"), - "stdout missing zen line; got:\n{}", - stdout - ); - Ok(()) - } - - #[test] - fn test_run_c_import_this_release_flag() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "--release", "-c", "import this"]) - // This test should not require network access. We expect the workspace dependencies to already be available - // (the test suite built them) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run --release -c import this failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("The Zen of Incan") && stdout.contains("Readability counts"), - "stdout missing zen line; got:\n{}", - stdout - ); - Ok(()) - } - - #[test] - fn test_variadic_rest_calls_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def collect(prefix: str, *items: int, **labels: str) -> int: - mut total: int = 0 - for item in items: - total = total + item - if labels["name"] == "direct": - return total - if labels["name"] == "callable": - return total - return total - -class Collector: - def collect(self, *items: int, **labels: str) -> int: - mut total: int = 0 - for item in items: - total = total + item - if labels["name"] == "method": - return total - return -100 - -def main() -> None: - f = collect - collector = Collector() - println(collect("x", 1, 2, name="direct") + f("x", 4, 5, name="callable") + collector.collect(6, 7, name="method")) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "variadic rest run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["25"], "unexpected variadic rest output:\n{stdout}"); - Ok(()) - } - - #[test] - fn test_string_and_bytes_iteration_compile_and_run() -> Result<(), Box> { - let output = run_incan_source( - "def main() -> None:\n mut out = \"\"\n for ch in \"Az\":\n out += ch\n for index, ch in enumerate(\"xy\"):\n out += f\"{index}{ch}\"\n mut total = 0\n for byte in b\"Az\":\n total += byte\n for index, byte in enumerate(b\"\\x01\\x02\"):\n total += index + byte\n println(out)\n println(total)\n", - ); - - assert!( - output.status.success(), - "incan run string/bytes iteration regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!(lines, vec!["Az0x1y", "191"]); - - Ok(()) - } - - #[test] - fn test_std_fs_compile_and_run_path_file_and_tree_operations() -> Result<(), Box> { - let base = std::env::temp_dir().join(format!("incan_std_fs_integration_{}", std::process::id())); - let root = base.join("root"); - let copied = base.join("copy"); - let moved = base.join("moved"); - let source = format!( - r#" -from std.fs import IoError, OpenOptions, Path -from rust::std::thread import sleep -from rust::std::time import Duration - -def run() -> Result[None, IoError]: - root = Path("{root}") - copied = Path("{copied}") - moved = Path("{moved}") - if moved.exists(): - moved.remove_tree()? - if copied.exists(): - copied.remove_tree()? - if root.exists(): - root.remove_tree()? - root.mkdir(true, true)? - root.joinpath("a.txt").write_text("alpha", "utf-8", "strict", None)? - root.joinpath("c.md").write_text("charlie", "utf-8", "strict", None)? - root.joinpath("sub").mkdir(true, true)? - root.joinpath("sub").joinpath("b.txt").write_text("bravo", "utf-8", "strict", None)? - println(len(root.glob("*.txt")?)) - println(len(root.rglob("*.txt")?)) - println(len(root.rglob("sub/[ab].txt")?)) - match root.joinpath("a.txt").open("r", -1, Some("definitely-not-an-encoding"), None, None): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match root.joinpath("a.txt").open("rbb+", -1, None, None, None): - Ok(_) => println("bad") - Err(err) => println(err.kind) - default_reader = root.joinpath("a.txt").open()? - println(default_reader.read(-1)?) - default_out = root.joinpath("default-open.txt") - default_writer = default_out.open("w")? - default_writer.write("delta")? - default_writer.flush()? - println(default_out.read_text("utf-8", "strict")?) - latin = root.joinpath("latin.txt") - latin.write_bytes(b"\xff")? - println(len(latin.read_text("windows-1252", "strict")?) > 0) - match latin.read_text("utf-8", "strict"): - Ok(_) => println("bad") - Err(err) => println(err.kind) - println(latin.read_text("utf-8", "replace")? != "") - latin_out = root.joinpath("latin-out.txt") - latin_out.write_text("€", "windows-1252", "strict", None)? - println(latin_out.read_text("windows-1252", "strict")? == "€") - latin_handle_out = root.joinpath("latin-handle-out.txt") - latin_handle = latin_handle_out.open("w", -1, Some("windows-1252"), Some("strict"), None)? - latin_handle.write("€")? - latin_handle.flush()? - println(latin_handle_out.read_text("windows-1252", "strict")? == "€") - text_handle = latin.open("r", -1, Some("windows-1252"), Some("strict"), None)? - println(len(text_handle.read(-1)?) > 0) - options_file = OpenOptions().write(true).create(true).truncate(true).open(root.joinpath("options.txt"))? - options_file.write_bytes(b"opts")? - options_file.flush()? - println(root.joinpath("options.txt").read_text("utf-8", "strict")?) - handle = root.joinpath("a.txt").open("rb", 0, None, None, None)? - chunk = handle.read_exact(2)? - println(len(chunk)) - source_modified = root.joinpath("a.txt").stat()?.modified_unix()? - root.copy(copied, true, true)? - copied_text = copied.joinpath("sub").joinpath("b.txt").read_text("utf-8", "strict")? - println(copied_text) - copied_modified = copied.joinpath("a.txt").stat()?.modified_unix()? - println(copied_modified == source_modified) - sleep(Duration.from_secs(1)) - copied.joinpath("a.txt").touch(true)? - touched_modified = copied.joinpath("a.txt").stat()?.modified_unix()? - println(touched_modified > copied_modified) - copied.move(moved)? - println(moved.joinpath("a.txt").exists()) - stat = moved.joinpath("a.txt").stat()? - println(stat.modified_unix()? > 0) - usage = moved.disk_usage()? - println(usage.total > 0 and usage.free > 0) - moved.remove_tree()? - root.remove_tree()? - return Ok(None) - -def main() -> None: - match run(): - Ok(_) => pass - Err(err) => println(err.message()) -"#, - root = root.display(), - copied = copied.display(), - moved = moved.display() - ); - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source.as_str()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run std.fs smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec![ - "1", - "2", - "1", - "invalid_input", - "invalid_input", - "alpha", - "delta", - "true", - "invalid_data", - "true", - "true", - "true", - "true", - "opts", - "2", - "bravo", - "true", - "true", - "true", - "true", - "true" - ], - "unexpected std.fs output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_std_hash_compile_and_run_digest_file_and_error_paths() -> Result<(), Box> { - // Keep std.hash's generated-project dependencies in the root Cargo graph so CI fetches them before this smoke - // runs the generated project under CARGO_NET_OFFLINE. - use blake2::Digest as _; - assert_eq!(blake2::Blake2s256::digest(b"abc").len(), 32); - assert_eq!(blake3::hash(b"abc").as_bytes().len(), 32); - assert_eq!(md5_010::Md5::digest(b"abc").len(), 16); - assert_eq!(sha1::Sha1::digest(b"abc").len(), 20); - assert_eq!(sha2::Sha256::digest(b"abc").len(), 32); - assert_eq!(sha3::Sha3_256::digest(b"abc").len(), 32); - let mut xxh32 = xxhash_rust::xxh32::Xxh32::default(); - xxh32.update(b"abc"); - assert_ne!(xxh32.digest(), 0); - let mut xxh64 = xxhash_rust::xxh64::Xxh64::default(); - xxh64.update(b"abc"); - assert_ne!(xxh64.digest(), 0); - let mut xxh3 = xxhash_rust::xxh3::Xxh3Default::new(); - xxh3.update(b"abc"); - assert_ne!(xxh3.digest(), 0); - - let payload = std::env::temp_dir().join(format!("incan_std_hash_integration_{}.txt", std::process::id())); - std::fs::write(&payload, b"abc")?; - - let source = format!( - r#" -from std.hash import ( - blake2b, - blake2s, - blake3, - HashError, - file_digest, - file_hash_u32, - file_hash_u64, - file_hash_u128, - md5, - reader_digest, - reader_hash_u32, - reader_hash_u64, - reader_hash_u128, - sha1, - sha224, - sha256, - sha384, - sha512, - sha3_224, - sha3_256, - sha3_384, - sha3_512, - shake128, - shake256, - xxh32, - xxh64, - xxh3_64, - xxh3_128, -) -from std.fs import Path -from std.io import BytesIO - -def run() -> Result[None, HashError]: - sha1_digest = sha1.digest(b"abc") - println(len(sha1_digest)) - println(sha1_digest == b"\xa9\x99\x3e\x36\x47\x06\x81\x6a\xba\x3e\x25\x71\x78\x50\xc2\x6c\x9c\xd0\xd8\x9d") - println(len(md5.digest(b"abc"))) - println(md5.digest(b"abc") == b"\x90\x01\x50\x98\x3c\xd2\x4f\xb0\xd6\x96\x3f\x7d\x28\xe1\x7f\x72") - println(len(sha224.digest(b"abc"))) - println(len(sha384.digest(b"abc"))) - println(len(sha512.digest(b"abc"))) - println(len(sha3_224.digest(b"abc"))) - println(len(sha3_256.digest(b"abc"))) - println(len(sha3_384.digest(b"abc"))) - println(len(sha3_512.digest(b"abc"))) - println(len(blake2b.digest(b"abc"))) - println(len(blake2s.digest(b"abc"))) - println(len(blake3.digest(b"abc"))) - - mut legacy = sha1.new() - legacy.update(b"a") - legacy.update(b"bc") - println(legacy.finalize_bytes() == sha1_digest) - - digest = sha256.digest(b"abc") - println(len(digest)) - - mut h = sha256.new() - h.update(b"a") - h.update(b"bc") - println(h.finalize_bytes() == digest) - - mut fast = xxh3_64.new() - fast.update(b"a") - fast.update(b"bc") - println(fast.finalize_u64() == xxh3_64.hash_u64(b"abc")) - - println(len(shake128.digest(b"abc", 8)?)) - println(len(shake256.digest(b"abc", 8)?)) - match shake128.digest(b"abc", 0): - Ok(_) => println("bad") - Err(err) => println(err.kind) - - path = Path("{payload}") - missing_path = Path("{missing_payload}") - match path.open("rb"): - Ok(file) => println(file_digest(file, "sha256", 1)? == digest) - Err(err) => return Err(HashError(kind=err.kind, algorithm="open", detail=err.detail)) - println(file_digest(path, "sha1", 1)? == sha1_digest) - println(file_digest(path, "sha256", 1)? == digest) - println(len(file_digest(path, "shake128", 1, 8)?)) - println(len(file_digest(path, "shake256", 2, 8)?)) - println(file_hash_u32(path, "xxh32", 1)? == xxh32.hash_u32(b"abc")) - println(file_hash_u64(path, "xxh3_64", 1)? == xxh3_64.hash_u64(b"abc")) - println(file_hash_u64(path, "xxh64", 2)? == xxh64.hash_u64(b"abc")) - println(file_hash_u128(path, "xxh3_128", 2)? == xxh3_128.hash_u128(b"abc")) - println(reader_digest(BytesIO(b"abc"), "sha256", 1)? == digest) - println(len(reader_digest(BytesIO(b"abc"), "shake256", 2, 8)?)) - println(reader_hash_u32(BytesIO(b"abc"), "xxh32", 2)? == xxh32.hash_u32(b"abc")) - println(reader_hash_u64(BytesIO(b"abc"), "xxh3_64", 2)? == xxh3_64.hash_u64(b"abc")) - println(reader_hash_u64(BytesIO(b"abc"), "xxh64", 2)? == xxh64.hash_u64(b"abc")) - println(reader_hash_u128(BytesIO(b"abc"), "xxh3_128", 2)? == xxh3_128.hash_u128(b"abc")) - - match file_hash_u64(path, "sha256", 1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match file_hash_u64(path, "unknown", 1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match reader_hash_u64(BytesIO(b"abc"), "sha256", 1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match reader_hash_u64(BytesIO(b"abc"), "unknown", 1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match file_digest(path, "shake128", 1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match file_digest(path, "sha256", 0): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match reader_digest(BytesIO(b"abc"), "sha256", 0): - Ok(_) => println("bad") - Err(err) => println(err.kind) - match file_digest(missing_path, "sha256", 1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - return Ok(None) - -def main() -> None: - match run(): - Ok(_) => pass - Err(err) => println(err.message()) -"#, - payload = payload.display(), - missing_payload = payload.with_extension("missing").display(), - ); - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source.as_str()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - let _ = std::fs::remove_file(&payload); - assert!( - output.status.success(), - "incan run std.hash smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec![ - "20", - "true", - "16", - "true", - "28", - "48", - "64", - "28", - "32", - "48", - "64", - "64", - "32", - "32", - "true", - "32", - "true", - "true", - "8", - "8", - "invalid_length", - "true", - "true", - "true", - "8", - "8", - "true", - "true", - "true", - "true", - "true", - "8", - "true", - "true", - "true", - "true", - "unsupported_width", - "unknown_algorithm", - "unsupported_width", - "unknown_algorithm", - "invalid_length", - "invalid_chunk_size", - "invalid_chunk_size", - "not_found" - ], - "unexpected std.hash output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_std_io_compile_and_run_bytesio_core_and_numeric_helpers() -> Result<(), Box> { - // Keep std.io's generated-project dependency in the root Cargo graph so CI fetches it before this smoke runs - // the generated project under CARGO_NET_OFFLINE. - let mut cache_anchor = [0u8; 4]; - ::write_u32(&mut cache_anchor, 258); - assert_eq!(cache_anchor, [2, 1, 0, 0]); - - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from std.io import BytesIO, Endian, IoError - -def run() -> Result[None, IoError]: - buf = BytesIO(b"abc\0rest") - first = buf.read(2)? - println(len(first)) - println(buf.tell()) - buf.rewind()? - nul: u8 = 0 - letter_t: u8 = 116 - until = buf.read_until(nul)? - println(len(until)) - println(buf.remaining()) - println(buf.skip_until(letter_t)?) - println(buf.remaining()) - match buf.read_exact(1): - Ok(_) => println("bad") - Err(err) => println(err.kind) - - out = BytesIO() - u32_value: u32 = 258 - i16_value: i16 = -2 - u128_value: u128 = 42 - f64_value: f64 = 1.5 - out.write(u32_value, Endian.Little)? - out.write(i16_value, Endian.Big)? - out.write(u128_value, Endian.Big)? - out.write(f64_value, Endian.Little)? - println(len(out.getvalue())) - out.rewind()? - read_u32: u32 = out.read(Endian.Little)? - read_i16: i16 = out.read(Endian.Big)? - read_u128: u128 = out.read(Endian.Big)? - read_f64: f64 = out.read(Endian.Little)? - println(read_u32) - println(read_i16) - println(read_u128) - println(read_f64 == f64_value) - - rewrite = BytesIO(b"abcd") - rewrite.seek(1, 0)? - xy: bytes = b"XY" - rewrite.write(xy)? - rewrite.truncate(Some(3))? - println(len(rewrite.getvalue())) - println(rewrite.remaining()) - return Ok(None) - -def main() -> None: - match run(): - Ok(_) => pass - Err(err) => println(err.message()) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run std.io smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec![ - "2", - "2", - "4", - "4", - "4", - "0", - "unexpected_eof", - "30", - "258", - "-2", - "42", - "true", - "3", - "0" - ], - "unexpected std.io output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_std_encoding_hex_compile_and_run_strict_surface() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/std_encoding_hex_surface.incn"]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run std.encoding.hex smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec![ - "417a00", - "3", - "417a00", - "417a00", - "FF", - "10", - "00", - "7f", - "invalid_length", - "invalid_character" - ], - "unexpected std.encoding.hex output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_std_fs_glob_string_api_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from std.fs.glob import filter_matches, matches - -def main() -> None: - println(matches("routes/users.incn", "routes/*.incn")) - println(matches("routes/users.incn", "routes/[a-z]*.incn")) - println(matches("routes/users.incn", "routes/[!0-9]*.incn")) - println(matches("routes/users.incn", "routes/?.incn")) - hits = filter_matches(["api/users", "docs/readme", "api/orders"], "api/*") - println(len(hits)) - println(hits[0]) - println(hits[1]) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "std.fs.glob string API failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["true", "true", "true", "false", "2", "api/users", "api/orders"], - "unexpected std.fs.glob output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_imported_default_constructor_fields_compile_and_run() -> Result<(), Box> { - let root = make_temp_dir("incan_imported_defaults"); - fs::create_dir_all(root.join("pkg"))?; - fs::write( - root.join("pkg").join("config.incn"), - r#" -pub model Config: - pub enabled: bool = false - pub retries: int = 3 -"#, - )?; - let main_path = root.join("default_ctor.incn"); - fs::write( - &main_path, - r#" -from pkg.config import Config - -def main() -> None: - cfg = Config() - println(cfg.enabled) - println(cfg.retries) -"#, - )?; - let output = Command::new(incan_debug_binary()) - .args(["run", main_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "imported default constructor regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "false\n3"); - Ok(()) - } - - #[test] - fn test_imported_value_enum_ordinal_map_compile_and_run() -> Result<(), Box> { - let root = make_temp_dir("incan_imported_ordinal_enum"); - fs::create_dir_all(root.join("pkg"))?; - fs::write( - root.join("pkg").join("status.incn"), - r#" -pub enum Status(str): - Open = "open" - Paid = "paid" - Cancelled = "cancelled" -"#, - )?; - let main_path = root.join("ordinal_enum.incn"); - fs::write( - &main_path, - r#" -from std.collections import OrdinalMap -from pkg.status import Status - -def main() -> None: - statuses: list[Status] = [Status.Open, Status.Paid, Status.Cancelled] - match OrdinalMap.from_keys(statuses): - Ok(columns) => match columns.require(Status.Paid): - Ok(value) => println(value) - Err(err) => println(err.message()) - Err(err) => println(err.message()) -"#, - )?; - let output = Command::new(incan_debug_binary()) - .args(["run", main_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "imported value-enum OrdinalMap regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "1"); - Ok(()) - } - - #[test] - fn test_imported_pascal_case_function_is_not_constructor() -> Result<(), Box> { - let root = make_temp_dir("incan_imported_pascal_case_function"); - fs::create_dir_all(root.join("pkg"))?; - fs::write( - root.join("pkg").join("factory.incn"), - r#" -pub def BytesIO(initial: int = 7) -> int: - return initial -"#, - )?; - let main_path = root.join("factory_call.incn"); - fs::write( - &main_path, - r#" -from pkg.factory import BytesIO - -def main() -> None: - println(BytesIO()) - println(BytesIO(3)) -"#, - )?; - let output = Command::new(incan_debug_binary()) - .args(["run", main_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "imported PascalCase function regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "7\n3"); - Ok(()) - } - - #[test] - fn test_imported_method_union_arg_compile_and_run() -> Result<(), Box> { - let root = make_temp_dir("incan_imported_method_union_arg"); - fs::create_dir_all(root.join("pkg"))?; - fs::write( - root.join("pkg").join("ops.incn"), - r#" -pub model LocalPath: - pub raw: str - -pub class Opener: - def accept(self, path: Union[LocalPath, str]) -> str: - return "ok" -"#, - )?; - let main_path = root.join("union_arg.incn"); - fs::write( - &main_path, - r#" -from pkg.ops import LocalPath, Opener - -def main() -> None: - println(Opener().accept(LocalPath(raw="a"))) - println(Opener().accept("b")) -"#, - )?; - let output = Command::new(incan_debug_binary()) - .args(["run", main_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "imported method union argument regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ok\nok"); - Ok(()) - } - - #[test] - fn test_std_fs_preserves_legacy_file_builtins() -> Result<(), Box> { - let path = std::env::temp_dir().join(format!("incan_std_fs_legacy_builtin_{}.txt", std::process::id())); - let source = format!( - r#" -def main() -> None: - match write_file("{path}", "legacy"): - Ok(_) => pass - Err(err) => println(err.to_string()) - match read_file("{path}"): - Ok(data) => println(data) - Err(err) => println(err.to_string()) -"#, - path = path.display() - ); - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source.as_str()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "legacy file builtins failed after std.fs registration: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "legacy", "unexpected legacy builtin output:\n{stdout}"); - let _ = std::fs::remove_file(path); - Ok(()) - } - - #[test] - fn test_match_rust_result_non_clone_payload_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from rust::std::fs import read_dir -from rust::std::path import Path as RustPath - -def main() -> None: - mut seen = False - match read_dir(RustPath.new(".")): - Ok(entries) => - for entry_result in entries: - match entry_result: - Ok(entry) => - seen = seen or entry.path().to_string_lossy().into_owned() != "" - Err(err) => println(err.to_string()) - Err(err) => println(err.to_string()) - println(seen) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "rust Result non-Clone match regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!(lines, vec!["true"], "unexpected output:\n{stdout}"); - Ok(()) - } - - #[test] - fn test_result_inspect_rust_result_non_clone_payload_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from rust::std::fs import read_dir -from rust::std::fs import ReadDir -from rust::std::path import Path as RustPath - -def observe_entries(_entries: ReadDir) -> None: - pass - -def main() -> None: - result = read_dir(RustPath.new(".")).inspect(observe_entries) - match result: - Ok(entries) => - mut seen = False - for entry_result in entries: - match entry_result: - Ok(entry) => - seen = seen or entry.path().to_string_lossy().into_owned() != "" - Err(err) => println(err.to_string()) - println(seen) - Err(err) => println(err.to_string()) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "Result.inspect Rust Result non-Clone regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec!["true"], - "unexpected Result.inspect non-Clone Rust Result output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_user_authored_result_tap_borrows_callback_payload() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from rust::std::fs import read_dir -from rust::std::fs import ReadDir -from rust::std::path import Path as RustPath + panic!("lex failed"); + }; + assert!(matches!(&tokens[0].kind, TokenKind::Ident(s) if s == "result")); + assert!(tokens[1].kind.is_punctuation(PunctuationId::Question)); -def observe_entries(_entries: ReadDir) -> None: - pass + let Ok(tokens) = lex("x => y") else { + panic!("lex failed"); + }; + assert!(tokens[1].kind.is_punctuation(PunctuationId::FatArrow)); -def tap[T, E](result: Result[T, E], f: Callable[T, None]) -> Result[T, E]: - match result: - Ok(value) => - f(value) - return Ok(value) - Err(error) => return Err(error) + let Ok(tokens) = lex("case Some(x):") else { + panic!("lex failed"); + }; + assert!(tokens[0].kind.is_keyword(KeywordId::Case)); -def main() -> None: - result = tap(read_dir(RustPath.new(".")), observe_entries) - match result: - Ok(entries) => - mut seen = False - for entry_result in entries: - match entry_result: - Ok(entry) => - seen = seen or entry.path().to_string_lossy().into_owned() != "" - Err(err) => println(err.to_string()) - println(seen) - Err(err) => println(err.to_string()) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "user-authored Result tap borrowed callback regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec!["true"], - "unexpected user-authored Result tap output:\n{stdout}" - ); - Ok(()) - } + let Ok(tokens) = lex("pass") else { + panic!("lex failed"); + }; + assert!(tokens[0].kind.is_keyword(KeywordId::Pass)); - #[test] - fn test_std_result_helpers_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from std.result import map as result_map, map_err as result_map_err -from std.result import and_then as result_and_then, or_else as result_or_else + let Ok(tokens) = lex("mut self") else { + panic!("lex failed"); + }; + assert!(tokens[0].kind.is_keyword(KeywordId::Mut)); + assert!(tokens[1].kind.is_keyword(KeywordId::SelfKw)); -def double(value: int) -> int: - return value * 2 + let Ok(tokens) = lex(r#"f"Hello {name}""#) else { + panic!("lex failed"); + }; + assert!(matches!(&tokens[0].kind, TokenKind::FString(_))); -def prefix(error: str) -> str: - return f"error: {error}" + let Ok(tokens) = lex("yield value") else { + panic!("lex failed"); + }; + assert!(tokens[0].kind.is_keyword(KeywordId::Yield)); + assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "value")); -def keep_even(value: int) -> Result[int, str]: - if value % 2 == 0: - return Ok(value) - return Err("odd") + let Ok(tokens) = lex("import rust::serde_json") else { + panic!("lex failed"); + }; + assert!(tokens[0].kind.is_keyword(KeywordId::Import)); + assert!(tokens[1].kind.is_keyword(KeywordId::Rust)); + assert!(tokens[2].kind.is_punctuation(PunctuationId::ColonColon)); + assert!(matches!(&tokens[3].kind, TokenKind::Ident(s) if s == "serde_json")); + } +} -def recover(_error: str) -> Result[int, str]: - return Ok(7) +mod numeric_semantics_tests { + use incan::frontend::{lexer, parser, typechecker}; + #[test] + fn test_python_like_numeric_ops_compile() { + let source = r#" def main() -> None: - ok_value: Result[int, str] = Ok(2) - err_value: Result[int, str] = Err("bad") - even_value: Result[int, str] = Ok(4) - missing_value: Result[int, str] = Err("missing") - match result_map(ok_value, double): - Ok(value) => println(value) - Err(error) => println(error) - match result_map_err(err_value, prefix): - Ok(value) => println(value) - Err(error) => println(error) - match result_and_then(even_value, keep_even): - Ok(value) => println(value) - Err(error) => println(error) - match result_or_else(missing_value, recover): - Ok(value) => println(value) - Err(error) => println(error) -"#, - ]) + a: int = 7 + b: int = -3 + x = a / b # float + y = a // b # floor div + z = a % b # python remainder + f: float = 7.0 + g = f % 2.0 + h = f // 2.0 +"#; + let Ok(tokens) = lexer::lex(source) else { + panic!("lexing failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(()) = typechecker::check(&ast) else { + panic!("typecheck failed"); + }; + } +} + +/// End-to-end codegen tests +mod codegen_tests { + use super::{incan_command, strip_ansi_escapes}; + use incan::backend::IrCodegen; + use incan::frontend::{lexer, parser, typechecker}; + use std::fs; + use std::path::Path; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn run_incan_source(source: &str) -> std::process::Output { + incan_command() + .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "std.result helper run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!( - lines, - vec!["4", "error: bad", "4", "7"], - "unexpected std.result helper output:\n{stdout}" - ); - Ok(()) + .output() + .unwrap_or_else(|e| panic!("failed to run incan source: {e}")) } - #[test] - fn test_result_methods_dogfood_std_result_helpers_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def double(value: int) -> int: - return value * 2 + fn rustc_compile_ok(source: &str) -> Result<(), String> { + let mut dir = std::env::temp_dir(); + let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else { + panic!("system time before UNIX epoch"); + }; + let uniq = duration.as_nanos(); + dir.push(format!("incan_bench_smoke_{}", uniq)); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; -def prefix(error: str) -> str: - return f"error: {error}" + let rs_path = dir.join("main.rs"); + let bin_path = dir.join("bin"); + std::fs::write(&rs_path, source).map_err(|e| e.to_string())?; -def keep_even(value: int) -> Result[int, str]: - if value % 2 == 0: - return Ok(value) - return Err("odd") + let out = Command::new("rustc") + .arg("--edition=2021") + .arg(&rs_path) + .arg("-o") + .arg(&bin_path) + .output() + .map_err(|e| e.to_string())?; -def recover(_error: str) -> Result[int, str]: - return Ok(7) + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).to_string()) + } + } -def main() -> None: - ok_value: Result[int, str] = Ok(2) - err_value: Result[int, str] = Err("bad") - missing_value: Result[int, str] = Err("missing") - match ok_value.map(double).and_then(keep_even): - Ok(value) => println(value) - Err(error) => println(error) - match err_value.map_err(prefix): - Ok(value) => println(value) - Err(error) => println(error) - match missing_value.or_else(recover).map(double): - Ok(value) => println(value) - Err(error) => println(error) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "Result method std.result helper run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!( - lines, - vec!["4", "error: bad", "14"], - "unexpected Result method std.result helper output:\n{stdout}" - ); - Ok(()) + fn make_temp_dir(prefix: &str) -> std::path::PathBuf { + let mut dir = std::env::temp_dir(); + let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else { + panic!("system time before UNIX epoch"); + }; + let uniq = duration.as_nanos(); + dir.push(format!("{}_{}", prefix, uniq)); + let Ok(()) = std::fs::create_dir_all(&dir) else { + panic!("failed to create temp dir"); + }; + dir } #[test] - fn test_result_map_err_accepts_callable_object_trait_adoption() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_hello_world_codegen() { + let path = Path::new("examples/hello.incn"); + if !path.exists() { + return; // Skip if example not present + } + + let Ok(source) = fs::read_to_string(path) else { + panic!("failed to read {}", path.display()); + }; + let Ok(tokens) = lexer::lex(&source) else { + panic!("lexing failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(()) = typechecker::check(&ast) else { + panic!("typecheck failed"); + }; + let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { + panic!("codegen failed"); + }; + + // Verify the generated code contains expected elements + assert!(rust_code.contains("fn main()"), "Should have main function"); + assert!(rust_code.contains("println!"), "Should have println macro"); + assert!(rust_code.contains("Hello from Incan!"), "Should have the message"); + } + + #[test] + fn test_string_literal_match_patterns_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -from std.traits.callable import Callable1 - -model Prefixer with Callable1[str, str]: - prefix: str +def describe(value: str) -> str: + match value: + case "star": + return "literal" + case other: + return other.upper() - def __call__(self, error: str) -> str: - return f"{self.prefix}: {error}" +def describe_alt(value: str) -> str: + mut out = "" + match value: + "star" | "sun" => out += "literal" + other => out += other.upper() + return out def main() -> None: - value: Result[int, str] = Err("bad") - match value.map_err(Prefixer(prefix="error")): - Ok(value) => println(value) - Err(error) => println(error) + println(describe("star")) + println(describe("fallback")) + println(describe_alt("sun")) + println(describe_alt("fallback")) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; + assert!( output.status.success(), - "Result.map_err callable-object regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "string literal match pattern regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec!["error: bad"], - "unexpected callable-object output:\n{stdout}" + vec!["literal", "FALLBACK", "literal", "FALLBACK"], + "unexpected string match output:\n{stdout}" ); Ok(()) } #[test] - fn test_result_method_closure_callbacks_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_payload_enum_without_equality_payload_compiles() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -def main() -> None: - prefix = "uuid" - value: Result[int, str] = Err("bad") - mapped = value.map_err((err) => f"{prefix}: {err}") - match mapped: - Ok(number) => println(number) - Err(error) => println(error) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "Result method closure callback regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!( - lines, - vec!["uuid: bad"], - "unexpected Result method closure callback output:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn test_question_mark_list_comprehension_propagates_result_issue633() -> Result<(), Box> { - let output = run_incan_source( - r#" -def parse_value(value: int) -> Result[int, str]: - if value == 2: - return Err("bad value") - return Ok(value) +model Payload: + value: str +enum Token: + Item(Payload) + Empty -def parse_all(values: list[int]) -> Result[list[int], str]: - return Ok([parse_value(value)? for value in values]) +enum Mode: + Fast + Slow +def describe(token: Token) -> str: + match token: + case Token.Item(payload): + return payload.value + case Token.Empty: + return "empty" def main() -> None: - match parse_all([1, 2, 3]): - Ok(values) => println(values[0]) - Err(err) => println(err) + if Mode.Fast == Mode.Fast: + println(describe(Token.Item(Payload(value="ok")))) "#, - ); + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( output.status.success(), - "question-mark list comprehension regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "payload enum derive regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!(lines, vec!["bad value"], "unexpected issue633 output:\n{stdout}"); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["ok"], "unexpected payload enum output:\n{stdout}"); Ok(()) } #[test] - fn test_question_mark_dict_comprehension_propagates_result_issue633() -> Result<(), Box> { - let output = run_incan_source( - r#" -def parse_key(value: int) -> Result[str, str]: - if value == 2: - return Err("bad key") - return Ok(str(value)) - - -def parse_map(values: list[int]) -> Result[dict[str, int], str]: - return Ok({parse_key(value)?: value for value in values}) + fn test_method_alias_codegen_rewrites_to_target_method() { + let source = r#" +model Stats: + value: int + mean = avg + def avg(self) -> int: + return self.value def main() -> None: - match parse_map([1, 2, 3]): - Ok(values) => println(values["1"]) - Err(err) => println(err) -"#, + let stats = Stats(value=10) + println(stats.mean()) +"#; + let Ok(tokens) = lexer::lex(source) else { + panic!("lex failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { + panic!("codegen failed"); + }; + assert!( + rust_code.contains(".avg("), + "expected method alias call to lower to target method, got:\n{rust_code}" ); assert!( - output.status.success(), - "question-mark dict comprehension regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + !rust_code.contains(".mean("), + "method alias must not emit an independent wrapper call, got:\n{rust_code}" ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!(lines, vec!["bad key"], "unexpected issue633 dict output:\n{stdout}"); - Ok(()) } #[test] - fn test_result_map_err_accepts_capturing_inline_closure() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def main() -> None: - prefix = "error" - value: Result[int, str] = Err("bad") - match value.map_err((error) => f"{prefix}: {error}"): - Ok(value) => println(value) - Err(error) => println(error) -"#, - ]) + fn test_run_c_import_this() -> Result<(), Box> { + let output = incan_command() + .args(["run", "-c", "import this"]) + // This test should not require network access. We expect the workspace dependencies to already be available + // (the test suite built them) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "Result.map_err inline closure regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run -c import this failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines = stdout - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - assert_eq!(lines, vec!["error: bad"], "unexpected inline closure output:\n{stdout}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("The Zen of Incan") && stdout.contains("Readability counts"), + "stdout missing zen line; got:\n{}", + stdout + ); Ok(()) } #[test] - fn test_static_str_index_and_slice_use_string_helpers() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -const ALPHABET: str = "abcdef" - -def main() -> None: - println(ALPHABET[1]) - println(ALPHABET[2:5]) -"#, - ]) + fn test_run_c_import_this_release_flag() -> Result<(), Box> { + let output = incan_command() + .args(["run", "--release", "-c", "import this"]) + // This test should not require network access. We expect the workspace dependencies to already be available + // (the test suite built them) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "static str index/slice regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run --release -c import this failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["b", "cde"], "unexpected static str output:\n{stdout}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("The Zen of Incan") && stdout.contains("Readability counts"), + "stdout missing zen line; got:\n{}", + stdout + ); Ok(()) } #[test] - fn test_collection_literal_spreads_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_variadic_rest_calls_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" +def collect(prefix: str, *items: int, **labels: str) -> int: + mut total: int = 0 + for item in items: + total = total + item + if labels["name"] == "direct": + return total + if labels["name"] == "callable": + return total + return total + +class Collector: + def collect(self, *items: int, **labels: str) -> int: + mut total: int = 0 + for item in items: + total = total + item + if labels["name"] == "method": + return total + return -100 + def main() -> None: - tail: tuple[int, int] = (4, 5) - values = [1, *[2, 3], *tail] - defaults = {"trace": "disabled", "accept": "json"} - merged = {**defaults, "trace": "enabled"} - println(values[0] + values[1] + values[2] + values[3] + values[4]) - println(merged["trace"]) + f = collect + collector = Collector() + println(collect("x", 1, 2, name="direct") + f("x", 4, 5, name="callable") + collector.collect(6, 7, name="method")) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "collection literal spread run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "variadic rest run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) @@ -4299,1134 +2638,1313 @@ def main() -> None: let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["15", "enabled"], - "unexpected collection spread output:\n{stdout}" - ); + assert_eq!(lines, vec!["25"], "unexpected variadic rest output:\n{stdout}"); Ok(()) } #[test] - fn test_enum_methods_and_trait_adoption_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -trait Labelled: - def label(self) -> str: ... - -enum Signal with Labelled: - Start - Stop - - def label(self) -> str: - match self: - Signal.Start => return "start" - Signal.Stop => return "stop" - - def default() -> Self: - return Signal.Start - -def keep_labelled[T with Labelled](value: T) -> T: - return value + fn test_string_and_bytes_iteration_compile_and_run() -> Result<(), Box> { + let output = run_incan_source( + "def main() -> None:\n mut out = \"\"\n for ch in \"Az\":\n out += ch\n for index, ch in enumerate(\"xy\"):\n out += f\"{index}{ch}\"\n mut total = 0\n for byte in b\"Az\":\n total += byte\n for index, byte in enumerate(b\"\\x01\\x02\"):\n total += index + byte\n println(out)\n println(total)\n", + ); -def main() -> None: - signal = keep_labelled(Signal.default()) - println(signal.label()) - println(Signal.Stop.label()) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; assert!( output.status.success(), - "enum methods and trait adoption run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run string/bytes iteration regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!(lines, vec!["Az0x1y", "191"]); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["start", "stop"], "unexpected enum method output:\n{stdout}"); Ok(()) } #[test] - fn test_union_types_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -@derive(Clone) -type LocalPath = newtype str - -def normalize_path_like(value: LocalPath | str) -> LocalPath: - if isinstance(value, str): - return LocalPath(value) - elif isinstance(value, LocalPath): - return value - -def parse_value(flag: bool) -> int | str: - if flag: - return 42 - return "fallback" - -def normalize(value: int | str) -> str: - if isinstance(value, int): - return "number" - else: - return value.upper() + fn test_std_fs_compile_and_run_path_file_and_tree_operations() -> Result<(), Box> { + let base = std::env::temp_dir().join(format!("incan_std_fs_integration_{}", std::process::id())); + let root = base.join("root"); + let copied = base.join("copy"); + let moved = base.join("moved"); + let source = format!( + r#" +from std.fs import IoError, OpenOptions, Path +from std.tempfile import NamedTemporaryFile, SpooledTemporaryFile, TemporaryDirectory +from rust::std::thread import sleep +from rust::std::time import Duration -def describe(value: int | str) -> str: - match value: - int(n) => - return str(n) - str(s) => - return s.upper() +def run() -> Result[None, IoError]: + root = Path("{root}") + copied = Path("{copied}") + moved = Path("{moved}") + if moved.exists(): + moved.remove_tree()? + if copied.exists(): + copied.remove_tree()? + if root.exists(): + root.remove_tree()? + root.mkdir(true, true)? + root.joinpath("a.txt").write_text("alpha", "utf-8", "strict", None)? + root.joinpath("c.md").write_text("charlie", "utf-8", "strict", None)? + root.joinpath("sub").mkdir(true, true)? + root.joinpath("sub").joinpath("b.txt").write_text("bravo", "utf-8", "strict", None)? + println(len(root.glob("*.txt")?)) + println(len(root.rglob("*.txt")?)) + println(len(root.rglob("sub/[ab].txt")?)) + match root.joinpath("a.txt").open("r", -1, Some("definitely-not-an-encoding"), None, None): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match root.joinpath("a.txt").open("rbb+", -1, None, None, None): + Ok(_) => println("bad") + Err(err) => println(err.kind) + default_reader = root.joinpath("a.txt").open()? + println(default_reader.read(-1)?) + default_out = root.joinpath("default-open.txt") + default_writer = default_out.open("w")? + default_writer.write("delta")? + default_writer.flush()? + println(default_out.read_text("utf-8", "strict")?) + latin = root.joinpath("latin.txt") + latin.write_bytes(b"\xff")? + println(len(latin.read_text("windows-1252", "strict")?) > 0) + match latin.read_text("utf-8", "strict"): + Ok(_) => println("bad") + Err(err) => println(err.kind) + println(latin.read_text("utf-8", "replace")? != "") + latin_out = root.joinpath("latin-out.txt") + latin_out.write_text("€", "windows-1252", "strict", None)? + println(latin_out.read_text("windows-1252", "strict")? == "€") + latin_handle_out = root.joinpath("latin-handle-out.txt") + latin_handle = latin_handle_out.open("w", -1, Some("windows-1252"), Some("strict"), None)? + latin_handle.write("€")? + latin_handle.flush()? + println(latin_handle_out.read_text("windows-1252", "strict")? == "€") + text_handle = latin.open("r", -1, Some("windows-1252"), Some("strict"), None)? + println(len(text_handle.read(-1)?) > 0) + options_file = OpenOptions().write(true).create(true).truncate(true).open(root.joinpath("options.txt"))? + options_file.write_bytes(b"opts")? + options_file.flush()? + println(root.joinpath("options.txt").read_text("utf-8", "strict")?) + handle = root.joinpath("a.txt").open("rb", 0, None, None, None)? + chunk = handle.read_exact(2)? + println(len(chunk)) + source_modified = root.joinpath("a.txt").stat()?.modified_unix()? + root.copy(copied, true, true)? + copied_text = copied.joinpath("sub").joinpath("b.txt").read_text("utf-8", "strict")? + println(copied_text) + copied_modified = copied.joinpath("a.txt").stat()?.modified_unix()? + println(copied_modified == source_modified) + sleep(Duration.from_secs(1)) + copied.joinpath("a.txt").touch(true)? + touched_modified = copied.joinpath("a.txt").stat()?.modified_unix()? + println(touched_modified > copied_modified) + copied.move(moved)? + println(moved.joinpath("a.txt").exists()) + stat = moved.joinpath("a.txt").stat()? + println(stat.modified_unix()? > 0) + usage = moved.disk_usage()? + println(usage.total > 0 and usage.free > 0) -def label(value: str | None) -> str: - if value is not None: - return value.upper() - return "missing" + file = NamedTemporaryFile.try_new_with("incan-", ".txt", None)? + path = file.path() + path.write_text("hello", "utf-8", "strict", None)? + println(path.read_text("utf-8", "strict")?) -def describe_optional(value: int | str | None) -> str: - match value: - int(n) => - return str(n) - str(s) => - return s.upper() - None => - return "missing" + directory = TemporaryDirectory.try_new_with("incan-dir-", "", None)? + child = directory.path() / "child.txt" + child.write_text("world", "utf-8", "strict", None)? + println(child.read_text("utf-8", "strict")?) -def describe_wide(value: int | str | bool) -> str: - if isinstance(value, int): - return "number" - else: - match value: - bool(flag) => - if flag: - return "true" - return "false" - str(text) => - return text.upper() + mut memory = SpooledTemporaryFile(max_size=64) + memory.write(b"memory")? + println(memory.rolled_to_disk()) + memory.seek(0, 0)? + println(len(memory.read(-1)?)) -def describe_chain(value: int | str | bool) -> str: - if isinstance(value, int): - return "number" - elif isinstance(value, str): - return value.upper() - else: - if value: - return "true" - return "false" + mut spool = SpooledTemporaryFile(max_size=4) + spool.write(b"rolled")? + println(spool.rolled_to_disk()) + println(spool.path()?.exists()) + spool.seek(0, 0)? + println(len(spool.read(-1)?)) + kept_spool = spool.persist()? + println(kept_spool.exists()) + kept_spool.unlink()? -def describe_wide_chain(value: int | float | str | bool) -> str: - if isinstance(value, bool): - return "bool" - elif isinstance(value, int): - return "int" - elif isinstance(value, float): - return "float" - elif isinstance(value, str): - return value.upper() - return "unknown" + kept_file = file.persist()? + println(kept_file.exists()) + kept_file.unlink()? -def describe_wide_match(value: int | float | str | bool) -> str: - match value: - bool(flag) => - if flag: - return "bool:true" - return "bool:false" - int(n) => - return str(n) - float(f) => - return str(f) - str(s) => - return s.upper() + kept_directory = directory.persist()? + println(kept_directory.exists()) + kept_directory.remove_tree()? -def describe_optional_narrow(value: int | str | None) -> str: - if isinstance(value, int): - return "number" - else: - if value is None: - return "missing" - else: - return value.upper() + moved.remove_tree()? + root.remove_tree()? + return Ok(None) def main() -> None: - println(normalize(parse_value(False))) - println(normalize(parse_value(True))) - println(describe(parse_value(False))) - println(label("present")) - println(label(None)) - println(describe_optional(parse_value(True))) - println(describe_optional(None)) - println(describe_wide("wide")) - println(describe_wide(True)) - println(describe_chain("chain")) - println(describe_chain(False)) - println(describe_wide_chain("wide-chain")) - println(describe_wide_chain(1.25)) - println(describe_wide_match(True)) - println(describe_wide_match(7)) - println(describe_wide_match(2.5)) - println(describe_wide_match("match")) - println(describe_optional_narrow("optional")) - println(describe_optional_narrow(None)) - println(normalize_path_like("from-string").0) - println(normalize_path_like(LocalPath("from-path")).0) + match run(): + Ok(_) => pass + Err(err) => println(err.message()) "#, - ]) + root = root.display(), + copied = copied.display(), + moved = moved.display() + ); + let output = incan_command() + .args(["run", "-c", source.as_str()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "union type run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run std.fs smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); assert_eq!( lines, vec![ - "FALLBACK", - "number", - "FALLBACK", - "PRESENT", - "missing", - "42", - "missing", - "WIDE", + "1", + "2", + "1", + "invalid_input", + "invalid_input", + "alpha", + "delta", "true", - "CHAIN", + "invalid_data", + "true", + "true", + "true", + "true", + "opts", + "2", + "bravo", + "true", + "true", + "true", + "true", + "true", + "hello", + "world", "false", - "WIDE-CHAIN", - "float", - "bool:true", - "7", - "2.5", - "MATCH", - "OPTIONAL", - "missing", - "from-string", - "from-path" + "6", + "true", + "true", + "6", + "true", + "true", + "true" ], - "unexpected union output:\n{stdout}" + "unexpected std.fs output:\n{stdout}" ); Ok(()) } #[test] - fn test_union_model_variants_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -@derive(Clone) -model Leaf: - value: int - -@derive(Clone) -model Pair: - args: list[Expr] - -type Expr = Union[Leaf, Pair] - -def pair() -> Expr: - return Pair(args=[Leaf(value=1), Leaf(value=2)]) - -def clone_expr(expr: Expr) -> Expr: - return expr.clone() + fn test_std_hash_compile_and_run_digest_file_and_error_paths() -> Result<(), Box> { + // Keep std.hash's generated-project dependencies in the root Cargo graph so CI fetches them before this smoke + // runs the generated project under CARGO_NET_OFFLINE. + use blake2::Digest as _; + assert_eq!(blake2::Blake2s256::digest(b"abc").len(), 32); + assert_eq!(blake3::hash(b"abc").as_bytes().len(), 32); + assert_eq!(md5_010::Md5::digest(b"abc").len(), 16); + assert_eq!(sha1::Sha1::digest(b"abc").len(), 20); + assert_eq!(sha2::Sha256::digest(b"abc").len(), 32); + assert_eq!(sha3::Sha3_256::digest(b"abc").len(), 32); + let mut xxh32 = xxhash_rust::xxh32::Xxh32::default(); + xxh32.update(b"abc"); + assert_ne!(xxh32.digest(), 0); + let mut xxh64 = xxhash_rust::xxh64::Xxh64::default(); + xxh64.update(b"abc"); + assert_ne!(xxh64.digest(), 0); + let mut xxh3 = xxhash_rust::xxh3::Xxh3Default::new(); + xxh3.update(b"abc"); + assert_ne!(xxh3.digest(), 0); -def sum_expr(expr: Expr) -> int: - match expr: - Leaf(leaf) => - return leaf.value - Pair(pair) => - return sum_expr(pair.args[0]) + let payload = std::env::temp_dir().join(format!("incan_std_hash_integration_{}.txt", std::process::id())); + std::fs::write(&payload, b"abc")?; -def main() -> None: - println(sum_expr(clone_expr(pair()))) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "union model variant run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + let source = format!( + r#" +from std.hash import ( + blake2b, + blake2s, + blake3, + HashError, + file_digest, + file_hash_u32, + file_hash_u64, + file_hash_u128, + md5, + reader_digest, + reader_hash_u32, + reader_hash_u64, + reader_hash_u128, + sha1, + sha224, + sha256, + sha384, + sha512, + sha3_224, + sha3_256, + sha3_384, + sha3_512, + shake128, + shake256, + xxh32, + xxh64, + xxh3_64, + xxh3_128, +) +from std.fs import Path +from std.io import BytesIO - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["1"], "unexpected union model variant output:\n{stdout}"); - Ok(()) - } +def run() -> Result[None, HashError]: + sha1_digest = sha1.digest(b"abc") + println(len(sha1_digest)) + println(sha1_digest == b"\xa9\x99\x3e\x36\x47\x06\x81\x6a\xba\x3e\x25\x71\x78\x50\xc2\x6c\x9c\xd0\xd8\x9d") + println(len(md5.digest(b"abc"))) + println(md5.digest(b"abc") == b"\x90\x01\x50\x98\x3c\xd2\x4f\xb0\xd6\x96\x3f\x7d\x28\xe1\x7f\x72") + println(len(sha224.digest(b"abc"))) + println(len(sha384.digest(b"abc"))) + println(len(sha512.digest(b"abc"))) + println(len(sha3_224.digest(b"abc"))) + println(len(sha3_256.digest(b"abc"))) + println(len(sha3_384.digest(b"abc"))) + println(len(sha3_512.digest(b"abc"))) + println(len(blake2b.digest(b"abc"))) + println(len(blake2s.digest(b"abc"))) + println(len(blake3.digest(b"abc"))) - #[test] - fn test_imported_union_alias_list_field_compiles_issue622() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("union_list_cross_module_alias_repro"); - fs::create_dir_all(project_root.join("src"))?; - fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"union_list_cross_module_alias_repro\"\nversion = \"0.1.0\"\n", - )?; - fs::write( - project_root.join("src/exprs.incn"), - r#" -@derive(Clone) -pub model Leaf: - pub value: int + mut legacy = sha1.new() + legacy.update(b"a") + legacy.update(b"bc") + println(legacy.finalize_bytes() == sha1_digest) -@derive(Clone) -pub model Pair: - pub args: list[Expr] + digest = sha256.digest(b"abc") + println(len(digest)) -pub type Expr = Union[Leaf, Pair] + mut h = sha256.new() + h.update(b"a") + h.update(b"bc") + println(h.finalize_bytes() == digest) -pub def pair() -> Expr: - return Pair(args=[Leaf(value=1), Leaf(value=2)]) -"#, - )?; - fs::write( - project_root.join("src/lib.incn"), - r#" -from exprs import Expr, Leaf, Pair, pair + mut fast = xxh3_64.new() + fast.update(b"a") + fast.update(b"bc") + println(fast.finalize_u64() == xxh3_64.hash_u64(b"abc")) -def sum_expr(expr: Expr) -> int: - match expr: - Leaf(leaf) => return leaf.value - Pair(pair_expr) => return sum_expr(pair_expr.args[0]) + println(len(shake128.digest(b"abc", 8)?)) + println(len(shake256.digest(b"abc", 8)?)) + match shake128.digest(b"abc", 0): + Ok(_) => println("bad") + Err(err) => println(err.kind) -pub def main_value() -> int: - return sum_expr(pair()) -"#, - )?; + path = Path("{payload}") + missing_path = Path("{missing_payload}") + match path.open("rb"): + Ok(file) => println(file_digest(file, "sha256", 1)? == digest) + Err(err) => return Err(HashError(kind=err.kind, algorithm="open", detail=err.detail)) + println(file_digest(path, "sha1", 1)? == sha1_digest) + println(file_digest(path, "sha256", 1)? == digest) + println(len(file_digest(path, "shake128", 1, 8)?)) + println(len(file_digest(path, "shake256", 2, 8)?)) + println(file_hash_u32(path, "xxh32", 1)? == xxh32.hash_u32(b"abc")) + println(file_hash_u64(path, "xxh3_64", 1)? == xxh3_64.hash_u64(b"abc")) + println(file_hash_u64(path, "xxh64", 2)? == xxh64.hash_u64(b"abc")) + println(file_hash_u128(path, "xxh3_128", 2)? == xxh3_128.hash_u128(b"abc")) + println(reader_digest(BytesIO(b"abc"), "sha256", 1)? == digest) + println(len(reader_digest(BytesIO(b"abc"), "shake256", 2, 8)?)) + println(reader_hash_u32(BytesIO(b"abc"), "xxh32", 2)? == xxh32.hash_u32(b"abc")) + println(reader_hash_u64(BytesIO(b"abc"), "xxh3_64", 2)? == xxh3_64.hash_u64(b"abc")) + println(reader_hash_u64(BytesIO(b"abc"), "xxh64", 2)? == xxh64.hash_u64(b"abc")) + println(reader_hash_u128(BytesIO(b"abc"), "xxh3_128", 2)? == xxh3_128.hash_u128(b"abc")) - let output = Command::new(incan_debug_binary()) - .args(["build", "--lib"]) - .current_dir(&project_root) + match file_hash_u64(path, "sha256", 1): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match file_hash_u64(path, "unknown", 1): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match reader_hash_u64(BytesIO(b"abc"), "sha256", 1): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match reader_hash_u64(BytesIO(b"abc"), "unknown", 1): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match file_digest(path, "shake128", 1): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match file_digest(path, "sha256", 0): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match reader_digest(BytesIO(b"abc"), "sha256", 0): + Ok(_) => println("bad") + Err(err) => println(err.kind) + match file_digest(missing_path, "sha256", 1): + Ok(_) => println("bad") + Err(err) => println(err.kind) + return Ok(None) + +def main() -> None: + match run(): + Ok(_) => pass + Err(err) => println(err.message()) +"#, + payload = payload.display(), + missing_payload = payload.with_extension("missing").display(), + ); + let output = incan_command() + .args(["run", "-c", source.as_str()]) .env("CARGO_NET_OFFLINE", "true") .output()?; + let _ = std::fs::remove_file(&payload); assert!( output.status.success(), - "expected imported union alias list-field project to build for #622.\nstdout:\n{}\nstderr:\n{}", + "incan run std.hash smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!( + lines, + vec![ + "20", + "true", + "16", + "true", + "28", + "48", + "64", + "28", + "32", + "48", + "64", + "64", + "32", + "32", + "true", + "32", + "true", + "true", + "8", + "8", + "invalid_length", + "true", + "true", + "true", + "8", + "8", + "true", + "true", + "true", + "true", + "true", + "8", + "true", + "true", + "true", + "true", + "unsupported_width", + "unknown_algorithm", + "unsupported_width", + "unknown_algorithm", + "invalid_length", + "invalid_chunk_size", + "invalid_chunk_size", + "not_found" + ], + "unexpected std.hash output:\n{stdout}" + ); Ok(()) } #[test] - fn test_issue562_type_alias_dict_and_union_surfaces_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_std_io_compile_and_run_bytesio_core_and_numeric_helpers() -> Result<(), Box> { + // Keep std.io's generated-project dependency in the root Cargo graph so CI fetches it before this smoke runs + // the generated project under CARGO_NET_OFFLINE. + let mut cache_anchor = [0u8; 4]; + ::write_u32(&mut cache_anchor, 258); + assert_eq!(cache_anchor, [2, 1, 0, 0]); + + let output = incan_command() .args([ "run", "-c", r#" -type FieldValue = str | bool | int | float | None -type Fields = Dict[str, FieldValue] +from std.io import BytesIO, Endian, IoError -model Logger: - fields: Fields = {} +def run() -> Result[None, IoError]: + buf = BytesIO(b"abc\0rest") + first = buf.read(2)? + println(len(first)) + println(buf.tell()) + buf.rewind()? + nul: u8 = 0 + letter_t: u8 = 116 + until = buf.read_until(nul)? + println(len(until)) + println(buf.remaining()) + println(buf.skip_until(letter_t)?) + println(buf.remaining()) + match buf.read_exact(1): + Ok(_) => println("bad") + Err(err) => println(err.kind) - def copy_fields(self, extra: Fields) -> Fields: - mut merged: Fields = {} - for key in self.fields.keys(): - merged[key] = self.fields[key] - for key in extra.keys(): - merged[key] = extra[key] - return merged + out = BytesIO() + u32_value: u32 = 258 + i16_value: i16 = -2 + u128_value: u128 = 42 + f64_value: f64 = 1.5 + out.write(u32_value, Endian.Little)? + out.write(i16_value, Endian.Big)? + out.write(u128_value, Endian.Big)? + out.write(f64_value, Endian.Little)? + println(len(out.getvalue())) + out.rewind()? + read_u32: u32 = out.read(Endian.Little)? + read_i16: i16 = out.read(Endian.Big)? + read_u128: u128 = out.read(Endian.Big)? + read_f64: f64 = out.read(Endian.Little)? + println(read_u32) + println(read_i16) + println(read_u128) + println(read_f64 == f64_value) -def to_text(value: FieldValue) -> str: - match value: - str(text) => - return text - bool(flag) => - if flag: - return "true" - return "false" - int(number) => - return str(number) - float(number) => - return str(number) - None => - return "none" + rewrite = BytesIO(b"abcd") + rewrite.seek(1, 0)? + xy: bytes = b"XY" + rewrite.write(xy)? + rewrite.truncate(Some(3))? + println(len(rewrite.getvalue())) + println(rewrite.remaining()) + return Ok(None) def main() -> None: - logger = Logger(fields={"base": "one"}) - merged = logger.copy_fields({"count": 7, "flag": True, "ratio": 2.5, "none": None}) - println(to_text(merged["base"])) - println(to_text(merged["count"])) - println(to_text(merged["flag"])) - println(to_text(merged["ratio"])) - println(to_text(merged["none"])) + match run(): + Ok(_) => pass + Err(err) => println(err.message()) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "issue #562 alias transparency run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run std.io smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); assert_eq!( lines, - vec!["one", "7", "true", "2.5", "none"], - "unexpected issue #562 alias transparency output:\n{stdout}" + vec![ + "2", + "2", + "4", + "4", + "4", + "0", + "unexpected_eof", + "30", + "258", + "-2", + "42", + "true", + "3", + "0" + ], + "unexpected std.io output:\n{stdout}" ); Ok(()) } #[test] - fn test_issue502_independent_union_narrowing_branches_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -@derive(Clone) -type LocalPath = newtype str - -def normalize_path_like(value: LocalPath | str) -> LocalPath: - if isinstance(value, str): - return LocalPath(value) - if isinstance(value, LocalPath): - return value - -def main() -> None: - println(normalize_path_like("from-string").0) - println(normalize_path_like(LocalPath("from-path")).0) -"#, - ]) + fn test_std_encoding_hex_compile_and_run_strict_surface() -> Result<(), Box> { + let output = incan_command() + .args(["run", "tests/fixtures/valid/std_encoding_hex_surface.incn"]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "independent union narrowing branch regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run std.encoding.hex smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); assert_eq!( lines, - vec!["from-string", "from-path"], - "unexpected independent union narrowing output:\n{stdout}" + vec![ + "417a00", + "3", + "417a00", + "417a00", + "FF", + "10", + "00", + "7f", + "invalid_length", + "invalid_character" + ], + "unexpected std.encoding.hex output:\n{stdout}" ); Ok(()) } #[test] - fn test_issue501_option_union_isinstance_narrowing_compile_and_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_std_fs_glob_string_api_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -type LocalPath = newtype str - -def describe(value: Option[LocalPath | str]) -> str: - if value is not None: - if isinstance(value, str): - return value.upper() - elif isinstance(value, LocalPath): - return value.0 - return "missing" +from std.fs.glob import filter_matches, matches def main() -> None: - println(describe("from-string")) - println(describe(LocalPath("from-path"))) - println(describe(None)) + println(matches("routes/users.incn", "routes/*.incn")) + println(matches("routes/users.incn", "routes/[a-z]*.incn")) + println(matches("routes/users.incn", "routes/[!0-9]*.incn")) + println(matches("routes/users.incn", "routes/?.incn")) + hits = filter_matches(["api/users", "docs/readme", "api/orders"], "api/*") + println(len(hits)) + println(hits[0]) + println(hits[1]) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; + assert!( output.status.success(), - "Option[Union] isinstance narrowing regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "std.fs.glob string API failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec!["FROM-STRING", "from-path", "missing"], - "unexpected Option[Union] narrowing output:\n{stdout}" + vec!["true", "true", "true", "false", "2", "api/users", "api/orders"], + "unexpected std.fs.glob output:\n{stdout}" ); Ok(()) } #[test] - fn test_filtered_comprehensions_run_with_borrowed_iterables() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -@derive(Clone) -model StoredNode: - store_id_raw: int - node: str + fn test_imported_default_constructor_fields_compile_and_run() -> Result<(), Box> { + let root = make_temp_dir("incan_imported_defaults"); + fs::create_dir_all(root.join("pkg"))?; + fs::write( + root.join("pkg").join("config.incn"), + r#" +pub model Config: + pub enabled: bool = false + pub retries: int = 3 +"#, + )?; + let main_path = root.join("default_ctor.incn"); + fs::write( + &main_path, + r#" +from pkg.config import Config def main() -> None: - nodes: list[StoredNode] = [ - StoredNode(store_id_raw=1, node="a"), - StoredNode(store_id_raw=2, node="b"), - ] - filtered = [stored.node for stored in nodes if stored.store_id_raw == 1] - scores = [1, 2, 3, 4] - squared_evens = {x: x * x for x in scores if x % 2 == 0} - println(filtered[0]) - println(squared_evens[2]) + cfg = Config() + println(cfg.enabled) + println(cfg.retries) "#, - ]) + )?; + let output = incan_command() + .args(["run", main_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c filtered comprehension regression failed: status={:?} stderr={}", + "imported default constructor regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["a", "4"], - "unexpected filtered comprehension output:\n{stdout}" - ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "false\n3"); Ok(()) } #[test] - fn test_generator_expression_runs_lazily_with_source_ordered_clauses() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" + fn test_imported_value_enum_ordinal_map_compile_and_run() -> Result<(), Box> { + let root = make_temp_dir("incan_imported_ordinal_enum"); + fs::create_dir_all(root.join("pkg"))?; + fs::write( + root.join("pkg").join("status.incn"), + r#" +pub enum Status(str): + Open = "open" + Paid = "paid" + Cancelled = "cancelled" +"#, + )?; + let main_path = root.join("ordinal_enum.incn"); + fs::write( + &main_path, + r#" +from std.collections import OrdinalMap +from pkg.status import Status + def main() -> None: - xs = [1, 2, 3] - ys = [2, 3, 4] - values = (x * y for x in xs if x > 1 for y in ys if y > x).collect() - println(values[0]) - println(values[1]) - println(values[2]) + statuses: list[Status] = [Status.Open, Status.Paid, Status.Cancelled] + match OrdinalMap.from_keys(statuses): + Ok(columns) => match columns.require(Status.Paid): + Ok(value) => println(value) + Err(err) => println(err.message()) + Err(err) => println(err.message()) "#, - ]) + )?; + let output = incan_command() + .args(["run", main_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c generator expression regression failed: status={:?} stderr={}", + "imported value-enum OrdinalMap regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["6", "8", "12"], "unexpected generator output:\n{stdout}"); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "1"); Ok(()) } #[test] - fn test_generator_helper_chain_builds_and_runs() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def triple(x: int) -> int: - return x * 3 - -def big(x: int) -> bool: - return x > 6 + fn test_imported_pascal_case_function_is_not_constructor() -> Result<(), Box> { + let root = make_temp_dir("incan_imported_pascal_case_function"); + fs::create_dir_all(root.join("pkg"))?; + fs::write( + root.join("pkg").join("factory.incn"), + r#" +pub def BytesIO(initial: int = 7) -> int: + return initial +"#, + )?; + let main_path = root.join("factory_call.incn"); + fs::write( + &main_path, + r#" +from pkg.factory import BytesIO def main() -> None: - xs = [1, 2, 3, 4, 5] - values = (x for x in xs).map(triple).filter(big).take(2).collect() - println(values[0]) - println(values[1]) + println(BytesIO()) + println(BytesIO(3)) "#, - ]) + )?; + let output = incan_command() + .args(["run", main_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c generator helper regression failed: status={:?} stderr={}", + "imported PascalCase function regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["9", "12"], "unexpected generator helper output:\n{stdout}"); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "7\n3"); Ok(()) } #[test] - fn test_generator_function_yield_builds_and_runs() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def numbers() -> Generator[int]: - yield 1 - yield 2 + fn test_imported_method_union_arg_compile_and_run() -> Result<(), Box> { + let root = make_temp_dir("incan_imported_method_union_arg"); + fs::create_dir_all(root.join("pkg"))?; + fs::write( + root.join("pkg").join("ops.incn"), + r#" +pub model LocalPath: + pub raw: str + +pub class Opener: + def accept(self, path: Union[LocalPath, str]) -> str: + return "ok" +"#, + )?; + let main_path = root.join("union_arg.incn"); + fs::write( + &main_path, + r#" +from pkg.ops import LocalPath, Opener def main() -> None: - values = numbers().collect() - println(values[0]) - println(values[1]) + println(Opener().accept(LocalPath(raw="a"))) + println(Opener().accept("b")) "#, - ]) + )?; + let output = incan_command() + .args(["run", main_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c generator function regression failed: status={:?} stderr={}", + "imported method union argument regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["1", "2"], "unexpected generator function output:\n{stdout}"); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ok\nok"); Ok(()) } #[test] - fn test_generator_function_body_starts_on_first_consumption() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -def numbers() -> Generator[int]: - println("started") - yield 1 - + fn test_std_fs_preserves_legacy_file_builtins() -> Result<(), Box> { + let path = std::env::temp_dir().join(format!("incan_std_fs_legacy_builtin_{}.txt", std::process::id())); + let source = format!( + r#" def main() -> None: - values = numbers() - println("after construction") - items = values.collect() - println(items[0]) + match write_file("{path}", "legacy"): + Ok(_) => pass + Err(err) => println(err.to_string()) + match read_file("{path}"): + Ok(data) => println(data) + Err(err) => println(err.to_string()) "#, - ]) + path = path.display() + ); + let output = incan_command() + .args(["run", "-c", source.as_str()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c generator laziness regression failed: status={:?} stderr={}", + "legacy file builtins failed after std.fs registration: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["after construction", "started", "1"], - "generator body should not run until first consumption:\n{stdout}" - ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "legacy", "unexpected legacy builtin output:\n{stdout}"); + let _ = std::fs::remove_file(path); Ok(()) } #[test] - fn test_generic_generator_function_yield_builds_and_runs() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_match_rust_result_non_clone_payload_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -def singleton[T](value: T) -> Generator[T]: - yield value +from rust::std::fs import read_dir +from rust::std::path import Path as RustPath def main() -> None: - values = singleton[int](3).collect() - println(values[0]) + mut seen = False + match read_dir(RustPath.new(".")): + Ok(entries) => + for entry_result in entries: + match entry_result: + Ok(entry) => + seen = seen or entry.path().to_string_lossy().into_owned() != "" + Err(err) => println(err.to_string()) + Err(err) => println(err.to_string()) + println(seen) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c generic generator function regression failed: status={:?} stderr={}", + "rust Result non-Clone match regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["3"], "unexpected generic generator output:\n{stdout}"); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!(lines, vec!["true"], "unexpected output:\n{stdout}"); Ok(()) } #[test] - fn test_clone_self_struct_field_reads_do_not_move_out_of_borrowed_self() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_result_inspect_rust_result_non_clone_payload_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -pub class ActiveRegistration: - pub logical_name: str - pub rank: int +from rust::std::fs import read_dir +from rust::std::fs import ReadDir +from rust::std::path import Path as RustPath - def clone(self) -> Self: - return ActiveRegistration(logical_name=self.logical_name, rank=self.rank) +def observe_entries(_entries: ReadDir) -> None: + pass def main() -> None: - reg = ActiveRegistration(logical_name="orders", rank=1) - copied = reg.clone() - println(copied.logical_name) + result = read_dir(RustPath.new(".")).inspect(observe_entries) + match result: + Ok(entries) => + mut seen = False + for entry_result in entries: + match entry_result: + Ok(entry) => + seen = seen or entry.path().to_string_lossy().into_owned() != "" + Err(err) => println(err.to_string()) + println(seen) + Err(err) => println(err.to_string()) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c clone(self)->Self field regression failed: status={:?} stderr={}", + "Result.inspect Rust Result non-Clone regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["orders"], "unexpected clone(self)->Self output:\n{stdout}"); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!( + lines, + vec!["true"], + "unexpected Result.inspect non-Clone Rust Result output:\n{stdout}" + ); Ok(()) } #[test] - fn test_loop_item_field_index_assignment_materializes_owned_value_issue616() - -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_user_authored_result_tap_borrows_callback_payload() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -model Assignment: - output_name: str +from rust::std::fs import read_dir +from rust::std::fs import ReadDir +from rust::std::path import Path as RustPath -def names(assignments: list[Assignment]) -> list[str]: - mut output_names: list[str] = [] - for assignment in assignments: - existing_idx = index_of_name(output_names, assignment.output_name) - if existing_idx >= 0: - output_names[existing_idx] = assignment.output_name - else: - output_names.append(assignment.output_name) - return output_names +def observe_entries(_entries: ReadDir) -> None: + pass -def index_of_name(names: list[str], name: str) -> int: - for idx, current in enumerate(names): - if current == name: - return idx - return -1 +def tap[T, E](result: Result[T, E], f: Callable[T, None]) -> Result[T, E]: + match result: + Ok(value) => + f(value) + return Ok(value) + Err(error) => return Err(error) def main() -> None: - result = names([Assignment(output_name="amount"), Assignment(output_name="amount")]) - println(result[0]) + result = tap(read_dir(RustPath.new(".")), observe_entries) + match result: + Ok(entries) => + mut seen = False + for entry_result in entries: + match entry_result: + Ok(entry) => + seen = seen or entry.path().to_string_lossy().into_owned() != "" + Err(err) => println(err.to_string()) + println(seen) + Err(err) => println(err.to_string()) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "loop item field index-assignment regression failed: status={:?} stderr={}", + "user-authored Result tap borrowed callback regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout.lines().collect::>(); assert_eq!( lines, - vec!["amount"], - "unexpected loop item field index-assignment output:\n{stdout}" + vec!["true"], + "unexpected user-authored Result tap output:\n{stdout}" ); Ok(()) } #[test] - fn test_field_backed_by_value_method_args_do_not_require_user_clone_issue241() - -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_std_result_helpers_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -class Cursor: - def join(self, other: Self, on: bool) -> Self: - return Cursor() +from std.result import map as result_map, map_err as result_map_err +from std.result import and_then as result_and_then, or_else as result_or_else -@derive(Clone) -class Wrapper: - _cursor: Cursor +def double(value: int) -> int: + return value * 2 - def merge(self, other: Self) -> Self: - return Wrapper(_cursor=self._cursor.join(other._cursor, true)) +def prefix(error: str) -> str: + return f"error: {error}" + +def keep_even(value: int) -> Result[int, str]: + if value % 2 == 0: + return Ok(value) + return Err("odd") + +def recover(_error: str) -> Result[int, str]: + return Ok(7) def main() -> None: - left = Wrapper(_cursor=Cursor()) - right = Wrapper(_cursor=Cursor()) - _ = left.merge(right) - println("ok") + ok_value: Result[int, str] = Ok(2) + err_value: Result[int, str] = Err("bad") + even_value: Result[int, str] = Ok(4) + missing_value: Result[int, str] = Err("missing") + match result_map(ok_value, double): + Ok(value) => println(value) + Err(error) => println(error) + match result_map_err(err_value, prefix): + Ok(value) => println(value) + Err(error) => println(error) + match result_and_then(even_value, keep_even): + Ok(value) => println(value) + Err(error) => println(error) + match result_or_else(missing_value, recover): + Ok(value) => println(value) + Err(error) => println(error) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "field-backed by-value method arg regression failed: status={:?} stderr={}", + "std.result helper run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["ok"], "unexpected issue241 output:\n{stdout}"); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!( + lines, + vec!["4", "error: bad", "4", "7"], + "unexpected std.result helper output:\n{stdout}" + ); Ok(()) } #[test] - fn test_issue241_generic_field_backed_method_args_infer_clone_bounds() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_result_methods_dogfood_std_result_helpers_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -class Cursor[T]: - pub value: T - - def join(self, other: Self, on: bool) -> Self: - return self +def double(value: int) -> int: + return value * 2 -@derive(Clone) -class Wrapper[T]: - pub _cursor: Cursor[T] +def prefix(error: str) -> str: + return f"error: {error}" - def merge(self, other: Self) -> Self: - return Wrapper(_cursor=self._cursor.join(other._cursor, true)) +def keep_even(value: int) -> Result[int, str]: + if value % 2 == 0: + return Ok(value) + return Err("odd") + +def recover(_error: str) -> Result[int, str]: + return Ok(7) def main() -> None: - left = Wrapper(_cursor=Cursor(value=1)) - right = Wrapper(_cursor=Cursor(value=2)) - println(left.merge(right)._cursor.value) + ok_value: Result[int, str] = Ok(2) + err_value: Result[int, str] = Err("bad") + missing_value: Result[int, str] = Err("missing") + match ok_value.map(double).and_then(keep_even): + Ok(value) => println(value) + Err(error) => println(error) + match err_value.map_err(prefix): + Ok(value) => println(value) + Err(error) => println(error) + match missing_value.or_else(recover).map(double): + Ok(value) => println(value) + Err(error) => println(error) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "generic issue241 regression failed: status={:?} stderr={}", + "Result method std.result helper run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["1"], "unexpected generic issue241 output:\n{stdout}"); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!( + lines, + vec!["4", "error: bad", "14"], + "unexpected Result method std.result helper output:\n{stdout}" + ); Ok(()) } #[test] - fn test_returning_tuple_with_reused_field_materializes_owned_items() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_result_map_err_accepts_callable_object_trait_adoption() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -class Pred: - pub name: str +from std.traits.callable import Callable1 -@derive(Clone) -class Node: - pub filter_predicate: Pred +model Prefixer with Callable1[str, str]: + prefix: str -def pair(node: Node) -> tuple[Pred, Pred]: - return (node.filter_predicate, node.filter_predicate) + def __call__(self, error: str) -> str: + return f"{self.prefix}: {error}" def main() -> None: - left, right = pair(Node(filter_predicate=Pred(name="x"))) - println(left.name) - println(right.name) + value: Result[int, str] = Err("bad") + match value.map_err(Prefixer(prefix="error")): + Ok(value) => println(value) + Err(error) => println(error) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "tuple field reuse ownership regression failed: status={:?} stderr={}", + "Result.map_err callable-object regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["x", "x"], "unexpected tuple field reuse output:\n{stdout}"); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!( + lines, + vec!["error: bad"], + "unexpected callable-object output:\n{stdout}" + ); Ok(()) } #[test] - fn test_generic_tuple_return_with_reused_field_infers_clone_bound() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_result_method_closure_callbacks_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -class Node[T]: - pub value: T - -def pair[T](node: Node[T]) -> tuple[T, T]: - return (node.value, node.value) - def main() -> None: - left, right = pair(Node(value=1)) - println(left) - println(right) + prefix = "uuid" + value: Result[int, str] = Err("bad") + mapped = value.map_err((err) => f"{prefix}: {err}") + match mapped: + Ok(number) => println(number) + Err(error) => println(error) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "generic tuple field reuse regression failed: status={:?} stderr={}", + "Result method closure callback regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); assert_eq!( lines, - vec!["1", "1"], - "unexpected generic tuple field reuse output:\n{stdout}" + vec!["uuid: bad"], + "unexpected Result method closure callback output:\n{stdout}" ); Ok(()) } #[test] - fn test_incan_call_materializes_owned_value_from_box_as_ref() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from rust::std::boxed import Box + fn test_question_mark_list_comprehension_propagates_result_issue633() -> Result<(), Box> { + let output = run_incan_source( + r#" +def parse_value(value: int) -> Result[int, str]: + if value == 2: + return Err("bad value") + return Ok(value) -@derive(Clone) -class Node: - pub value: int -def take(node: Node) -> int: - return node.value +def parse_all(values: list[int]) -> Result[list[int], str]: + return Ok([parse_value(value)? for value in values]) -def from_box(child: Box[Node]) -> int: - return take(child.as_ref()) def main() -> None: - println(from_box(Box.new(Node(value=4)))) + match parse_all([1, 2, 3]): + Ok(values) => println(values[0]) + Err(err) => println(err) "#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + ); assert!( output.status.success(), - "borrowed box as_ref call regression failed: status={:?} stderr={}", + "question-mark list comprehension regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["4"], "unexpected box as_ref output:\n{stdout}"); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!(lines, vec!["bad value"], "unexpected issue633 output:\n{stdout}"); Ok(()) } #[test] - fn test_generic_incan_call_materializes_owned_value_from_box_as_ref() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from rust::std::boxed import Box + fn test_question_mark_dict_comprehension_propagates_result_issue633() -> Result<(), Box> { + let output = run_incan_source( + r#" +def parse_key(value: int) -> Result[str, str]: + if value == 2: + return Err("bad key") + return Ok(str(value)) -@derive(Clone) -class Node[T]: - pub value: T -def take[T](node: Node[T]) -> T: - return node.value +def parse_map(values: list[int]) -> Result[dict[str, int], str]: + return Ok({parse_key(value)?: value for value in values}) -def from_box[T](child: Box[Node[T]]) -> T: - return take(child.as_ref()) def main() -> None: - println(from_box(Box.new(Node(value=4)))) + match parse_map([1, 2, 3]): + Ok(values) => println(values["1"]) + Err(err) => println(err) "#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + ); assert!( output.status.success(), - "generic borrowed box as_ref call regression failed: status={:?} stderr={}", + "question-mark dict comprehension regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["4"], "unexpected generic box as_ref output:\n{stdout}"); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!(lines, vec!["bad key"], "unexpected issue633 dict output:\n{stdout}"); Ok(()) } #[test] - fn test_match_on_shared_self_option_field_materializes_owned_scrutinee() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_result_map_err_accepts_capturing_inline_closure() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -pub class Node: - pub value: int - -@derive(Clone) -pub class Wrapper: - child: Option[Node] - - def read(self) -> int: - match self.child: - Some(child) => return child.value - None => return 0 - def main() -> None: - println(Wrapper(child=Some(Node(value=4))).read()) + prefix = "error" + value: Result[int, str] = Err("bad") + match value.map_err((error) => f"{prefix}: {error}"): + Ok(value) => println(value) + Err(error) => println(error) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "shared self option-field match regression failed: status={:?} stderr={}", + "Result.map_err inline closure regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["4"], - "unexpected shared self option-field match output:\n{stdout}" - ); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + assert_eq!(lines, vec!["error: bad"], "unexpected inline closure output:\n{stdout}"); Ok(()) } #[test] - fn test_match_on_shared_self_option_box_field_materializes_owned_scrutinee() - -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_static_str_index_and_slice_use_string_helpers() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -from rust::std::boxed import Box - -@derive(Clone) -pub class Node: - pub value: int - -@derive(Clone) -pub class Wrapper: - child: Option[Box[Node]] - - def read(self) -> int: - match self.child: - Some(child) => return child.as_ref().value - None => return 0 +const ALPHABET: str = "abcdef" def main() -> None: - println(Wrapper(child=Some(Box.new(Node(value=4)))).read()) + println(ALPHABET[1]) + println(ALPHABET[2:5]) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "shared self option-box-field match regression failed: status={:?} stderr={}", + "static str index/slice regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec!["4"], - "unexpected shared self option-box-field match output:\n{stdout}" - ); + assert_eq!(lines, vec!["b", "cde"], "unexpected static str output:\n{stdout}"); Ok(()) } #[test] - fn test_generic_match_on_shared_self_option_field_infers_clone_bound() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_collection_literal_spreads_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -@derive(Clone) -pub class Wrapper[T]: - child: Option[T] - - def read_or(self, fallback: T) -> T: - match self.child: - Some(child) => return child - None => return fallback - def main() -> None: - println(Wrapper(child=Some(4)).read_or(0)) + tail: tuple[int, int] = (4, 5) + values = [1, *[2, 3], *tail] + defaults = {"trace": "disabled", "accept": "json"} + merged = {**defaults, "trace": "enabled"} + println(values[0] + values[1] + values[2] + values[3] + values[4]) + println(merged["trace"]) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "generic shared self option-field match regression failed: status={:?} stderr={}", + "collection literal spread run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -5434,91 +3952,193 @@ def main() -> None: let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec!["4"], - "unexpected generic shared self option-field match output:\n{stdout}" + vec!["15", "enabled"], + "unexpected collection spread output:\n{stdout}" ); Ok(()) } #[test] - fn test_trait_supertraits_runtime_with_backend_clone_bounds() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_enum_methods_and_trait_adoption_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -trait Collection[T]: - def first(self) -> T: ... - -trait OrderedCollection[T] with Collection[T]: - def sorted(self) -> Self: ... - -model BoxedValue[T] with OrderedCollection: - value: T +trait Labelled: + def label(self) -> str: ... - def first(self) -> T: - return self.value +enum Signal with Labelled: + Start + Stop - def sorted(self) -> Self: - return self + def label(self) -> str: + match self: + Signal.Start => return "start" + Signal.Stop => return "stop" -def take_first(values: Collection[int]) -> int: - return values.first() + def default() -> Self: + return Signal.Start -def take_sorted(values: OrderedCollection[int]) -> OrderedCollection[int]: - return values.sorted() +def keep_labelled[T with Labelled](value: T) -> T: + return value def main() -> None: - println(take_first(BoxedValue(value=1))) - println(take_sorted(BoxedValue(value=2)).first()) + signal = keep_labelled(Signal.default()) + println(signal.label()) + println(Signal.Stop.label()) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "trait-supertrait ownership regression failed: status={:?} stderr={}", + "enum methods and trait adoption run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["1", "2"], "unexpected trait-supertrait output:\n{stdout}"); + assert_eq!(lines, vec!["start", "stop"], "unexpected enum method output:\n{stdout}"); Ok(()) } #[test] - fn test_result_ok_string_literals_run_without_manual_str_wrapping() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_union_types_compile_and_run() -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -def returns_result() -> Result[str, str]: - return Ok("from_return") +@derive(Clone) +type LocalPath = newtype str -def main() -> None: - direct: Result[str, str] = Ok("from_call") - match direct: - case Ok(msg): - println(msg) - case Err(err): - println(err) +def normalize_path_like(value: LocalPath | str) -> LocalPath: + if isinstance(value, str): + return LocalPath(value) + elif isinstance(value, LocalPath): + return value - match returns_result(): - case Ok(msg): - println(msg) - case Err(err): - println(err) +def parse_value(flag: bool) -> int | str: + if flag: + return 42 + return "fallback" + +def normalize(value: int | str) -> str: + if isinstance(value, int): + return "number" + else: + return value.upper() + +def describe(value: int | str) -> str: + match value: + int(n) => + return str(n) + str(s) => + return s.upper() + +def label(value: str | None) -> str: + if value is not None: + return value.upper() + return "missing" + +def describe_optional(value: int | str | None) -> str: + match value: + int(n) => + return str(n) + str(s) => + return s.upper() + None => + return "missing" + +def describe_wide(value: int | str | bool) -> str: + if isinstance(value, int): + return "number" + else: + match value: + bool(flag) => + if flag: + return "true" + return "false" + str(text) => + return text.upper() + +def describe_chain(value: int | str | bool) -> str: + if isinstance(value, int): + return "number" + elif isinstance(value, str): + return value.upper() + else: + if value: + return "true" + return "false" + +def describe_wide_chain(value: int | float | str | bool) -> str: + if isinstance(value, bool): + return "bool" + elif isinstance(value, int): + return "int" + elif isinstance(value, float): + return "float" + elif isinstance(value, str): + return value.upper() + return "unknown" + +def describe_wide_match(value: int | float | str | bool) -> str: + match value: + bool(flag) => + if flag: + return "bool:true" + return "bool:false" + int(n) => + return str(n) + float(f) => + return str(f) + str(s) => + return s.upper() + +def describe_optional_narrow(value: int | str | None) -> str: + if isinstance(value, int): + return "number" + else: + if value is None: + return "missing" + else: + return value.upper() + +def main() -> None: + println(normalize(parse_value(False))) + println(normalize(parse_value(True))) + println(describe(parse_value(False))) + println(label("present")) + println(label(None)) + println(describe_optional(parse_value(True))) + println(describe_optional(None)) + println(describe_wide("wide")) + println(describe_wide(True)) + println(describe_chain("chain")) + println(describe_chain(False)) + println(describe_wide_chain("wide-chain")) + println(describe_wide_chain(1.25)) + println(describe_wide_match(True)) + println(describe_wide_match(7)) + println(describe_wide_match(2.5)) + println(describe_wide_match("match")) + println(describe_optional_narrow("optional")) + println(describe_optional_narrow(None)) + println(normalize_path_like("from-string").0) + println(normalize_path_like(LocalPath("from-path")).0) "#, ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "incan run -c Result[str, E] string regression failed: status={:?} stderr={}", + "union type run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -5526,1814 +4146,1788 @@ def main() -> None: let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec!["from_call", "from_return"], - "unexpected Result[str, E] output:\n{stdout}" + vec![ + "FALLBACK", + "number", + "FALLBACK", + "PRESENT", + "missing", + "42", + "missing", + "WIDE", + "true", + "CHAIN", + "false", + "WIDE-CHAIN", + "float", + "bool:true", + "7", + "2.5", + "MATCH", + "OPTIONAL", + "missing", + "from-string", + "from-path" + ], + "unexpected union output:\n{stdout}" ); Ok(()) } #[test] - fn test_run_file_release_flag() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_run_release_file"); - let source_path = project_dir.join("main.incn"); - std::fs::write( - &source_path, - r#"def main() -> None: - println("release file path works") -"#, - )?; - - let output = Command::new(incan_debug_binary()) - .args(["run", "--release", source_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run --release failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("release file path works"), - "stdout missing expected output; got:\n{}", - stdout - ); - Ok(()) - } + fn test_union_model_variants_compile_and_run() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +model Leaf: + value: int - #[test] - fn test_build_web_route_uses_proc_macro_passthrough() { - let project_dir = make_temp_dir("incan_web_proc_macro_test"); - let source_path = project_dir.join("main.incn"); - let out_dir = project_dir.join("out"); - let source = r#" -import std.async -from std.web import route +@derive(Clone) +model Pair: + args: list[Expr] -@route("/health") -async def health() -> str: - return "ok" +type Expr = Union[Leaf, Pair] -def main() -> None: - pass -"#; - let Ok(()) = std::fs::write(&source_path, source) else { - panic!("failed to write source file"); - }; +def pair() -> Expr: + return Pair(args=[Leaf(value=1), Leaf(value=2)]) - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "build", - source_path.to_string_lossy().as_ref(), - out_dir.to_string_lossy().as_ref(), +def clone_expr(expr: Expr) -> Expr: + return expr.clone() + +def sum_expr(expr: Expr) -> int: + match expr: + Leaf(leaf) => + return leaf.value + Pair(pair) => + return sum_expr(pair.args[0]) + +def main() -> None: + println(sum_expr(clone_expr(pair()))) +"#, ]) .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan build"); - }; - + .output()?; assert!( output.status.success(), - "incan build web route failed: status={:?} stderr={}", + "union model variant run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let generated_main = out_dir.join("src/main.rs"); - let Ok(main_rs) = std::fs::read_to_string(&generated_main) else { - panic!("failed to read generated Rust source"); - }; - assert!( - main_rs.contains("#[incan_web_macros::route("), - "expected generated web route to use proc macro passthrough:\n{}", - main_rs - ); - assert!( - !main_rs.contains("__incan_router!"), - "legacy __incan_router! macro should not be emitted:\n{}", - main_rs - ); - assert!( - !main_rs.contains("set_router"), - "legacy set_router() call should not be emitted:\n{}", - main_rs - ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["1"], "unexpected union model variant output:\n{stdout}"); + Ok(()) } #[test] - fn test_run_async_channel_facade() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_async_channel_facade_test"); - let source_path = project_dir.join("async_channel.incn"); - let source = r#" -import std.async -from std.async.channel import channel, unbounded_channel, oneshot - -async def main() -> None: - tx, rx = channel(4) - cloned = tx.clone() - - match await cloned.send(1): - Ok(_) => println("sent") - Err(err) => println(err.message()) - - match await rx.recv(): - Some(value) => println(value) - None => println("closed") - - match await tx.reserve(): - Ok(permit) => - match permit.send(4): - Ok(_) => println("reserved") - Err(err) => println(err.message()) - Err(err) => println(err.message()) - - match await rx.recv(): - Some(value) => println(value) - None => println("closed") - - tx2, rx2 = unbounded_channel() - match await tx2.send(2): - Ok(_) => println("sent") - Err(err) => println(err.message()) - - match rx2.try_recv(): - Some(value) => println(value) - None => println("empty") + fn test_imported_union_alias_list_field_compiles_issue622() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path().join("union_list_cross_module_alias_repro"); + fs::create_dir_all(project_root.join("src"))?; + fs::write( + project_root.join("incan.toml"), + "[project]\nname = \"union_list_cross_module_alias_repro\"\nversion = \"0.1.0\"\n", + )?; + fs::write( + project_root.join("src/exprs.incn"), + r#" +@derive(Clone) +pub model Leaf: + pub value: int - match await tx2.reserve(): - Ok(permit) => - match permit.send(5): - Ok(_) => println("unbounded reserved") - Err(err) => println(err.message()) - Err(err) => println(err.message()) +@derive(Clone) +pub model Pair: + pub args: list[Expr] - match rx2.try_recv(): - Some(value) => println(value) - None => println("empty") +pub type Expr = Union[Leaf, Pair] - println(f"close:{rx2.close()}") - println(tx2.is_closed()) +pub def pair() -> Expr: + return Pair(args=[Leaf(value=1), Leaf(value=2)]) +"#, + )?; + fs::write( + project_root.join("src/lib.incn"), + r#" +from exprs import Expr, Leaf, Pair, pair - otx, orx = oneshot() - match otx.send(3): - Ok(_) => println("delivered") - Err(value) => println(value) +def sum_expr(expr: Expr) -> int: + match expr: + Leaf(leaf) => return leaf.value + Pair(pair_expr) => return sum_expr(pair_expr.args[0]) - match await orx.recv(): - Ok(value) => println(value) - Err(err) => println(err.message()) -"#; - std::fs::write(&source_path, source)?; +pub def main_value() -> int: + return sum_expr(pair()) +"#, + )?; - let output = Command::new(incan_debug_binary()) - .args(["run", source_path.to_string_lossy().as_ref()]) + let output = incan_command() + .args(["build", "--lib"]) + .current_dir(&project_root) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run async channel facade failed: status={:?} stderr={}", - output.status, + "expected imported union alias list-field project to build for #622.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("sent"), "expected send output; got:\n{}", stdout); - assert!( - stdout.contains("1"), - "expected bounded receive output; got:\n{}", - stdout - ); - assert!( - stdout.contains("2"), - "expected unbounded receive output; got:\n{}", - stdout - ); - assert!( - stdout.contains("reserved"), - "expected bounded reserve output; got:\n{}", - stdout - ); - assert!( - stdout.contains("4"), - "expected bounded permit receive output; got:\n{}", - stdout - ); - assert!( - stdout.contains("unbounded reserved"), - "expected unbounded reserve output; got:\n{}", - stdout - ); - assert!( - stdout.contains("5"), - "expected unbounded permit receive output; got:\n{}", - stdout - ); - assert!( - stdout.contains("close:true"), - "expected receiver close output; got:\n{}", - stdout - ); - assert!( - stdout.contains("true"), - "expected closed-state output; got:\n{}", - stdout - ); - assert!( - stdout.contains("delivered"), - "expected oneshot send output; got:\n{}", - stdout - ); - assert!( - stdout.contains("3"), - "expected oneshot receive output; got:\n{}", - stdout - ); Ok(()) } - /// Regression (GitHub #289): `await expr?` must emit `.await?` (not `?.await`) in generated Rust. #[test] - fn test_build_async_await_try_ordering_emits_await_before_try() { - let project_dir = make_temp_dir("incan_async_await_try_ordering"); - let source_path = project_dir.join("async_await_try_ordering.incn"); - let out_dir = project_dir.join("out"); - let source = r#" -import std.async + fn test_issue562_type_alias_dict_and_union_surfaces_compile_and_run() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +type FieldValue = str | bool | int | float | None +type Fields = Dict[str, FieldValue] -async def register_sources() -> Result[None, str]: - return Ok(None) +model Logger: + fields: Fields = {} -async def main() -> Result[None, str]: - await register_sources()? - return Ok(None) -"#; - let Ok(()) = std::fs::write(&source_path, source) else { - panic!("failed to write source file"); - }; + def copy_fields(self, extra: Fields) -> Fields: + mut merged: Fields = {} + for key in self.fields.keys(): + merged[key] = self.fields[key] + for key in extra.keys(): + merged[key] = extra[key] + return merged + +def to_text(value: FieldValue) -> str: + match value: + str(text) => + return text + bool(flag) => + if flag: + return "true" + return "false" + int(number) => + return str(number) + float(number) => + return str(number) + None => + return "none" - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "build", - source_path.to_string_lossy().as_ref(), - out_dir.to_string_lossy().as_ref(), +def main() -> None: + logger = Logger(fields={"base": "one"}) + merged = logger.copy_fields({"count": 7, "flag": True, "ratio": 2.5, "none": None}) + println(to_text(merged["base"])) + println(to_text(merged["count"])) + println(to_text(merged["flag"])) + println(to_text(merged["ratio"])) + println(to_text(merged["none"])) +"#, ]) .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan build"); - }; - + .output()?; assert!( output.status.success(), - "incan build await/try ordering regression failed: status={:?} stderr={}", + "issue #562 alias transparency run-path regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let generated_main = out_dir.join("src/main.rs"); - let Ok(main_rs) = std::fs::read_to_string(&generated_main) else { - panic!("failed to read generated Rust source"); - }; - let normalized: String = main_rs.chars().filter(|c| !c.is_whitespace()).collect(); - assert!( - normalized.contains("register_sources().await?;"), - "expected awaited-then-try ordering in generated Rust, got:\n{}", - main_rs - ); - assert!( - !normalized.contains("register_sources()?.await;"), - "generated Rust must not apply `?` before `.await`, got:\n{}", - main_rs + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["one", "7", "true", "2.5", "none"], + "unexpected issue #562 alias transparency output:\n{stdout}" ); + Ok(()) } #[test] - fn test_build_and_run_keyword_named_modules_escape_consistently() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_keyword_module_paths"); - let src_dir = project_dir.join("src"); - std::fs::create_dir_all(src_dir.join("api"))?; - std::fs::write( - project_dir.join("incan.toml"), - "[project]\nname = \"keyword_module_paths\"\nversion = \"0.1.0\"\n", - )?; + fn test_issue502_independent_union_narrowing_branches_compile_and_run() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +type LocalPath = newtype str - let main_path = src_dir.join("main.incn"); - // Use a Rust keyword that remains a legal Incan module spelling. `type` is a separate Incan keyword, so - // parser work to allow `from type import ...` would be a different issue than Rust-side module escaping. - std::fs::write( - &main_path, - r#"from extern import root_value -from api.extern import nested_value +def normalize_path_like(value: LocalPath | str) -> LocalPath: + if isinstance(value, str): + return LocalPath(value) + if isinstance(value, LocalPath): + return value def main() -> None: - println(root_value()) - println(nested_value()) -"#, - )?; - std::fs::write( - src_dir.join("extern.incn"), - r#"pub def root_value() -> str: - return "root-keyword" -"#, - )?; - std::fs::write( - src_dir.join("api").join("extern.incn"), - r#"pub def nested_value() -> str: - return "nested-keyword" + println(normalize_path_like("from-string").0) + println(normalize_path_like(LocalPath("from-path")).0) "#, - )?; - - let out_dir = project_dir.join("out"); - let build_output = Command::new(incan_debug_binary()) - .args([ - "build", - main_path.to_string_lossy().as_ref(), - out_dir.to_string_lossy().as_ref(), ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( - build_output.status.success(), - "incan build keyword-module project failed: status={:?} stderr={}", - build_output.status, - String::from_utf8_lossy(&build_output.stderr) + output.status.success(), + "independent union narrowing branch regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); - let main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; - let api_mod_rs = std::fs::read_to_string(out_dir.join("src/api/mod.rs"))?; - let normalized_main: String = main_rs.chars().filter(|c| !c.is_whitespace()).collect(); - let normalized_api_mod: String = api_mod_rs.chars().filter(|c| !c.is_whitespace()).collect(); - - assert!( - normalized_main.contains("#[path=\"extern.rs\"]modr#extern;"), - "expected top-level keyword module path attr in generated main.rs, got:\n{main_rs}" - ); - assert!( - normalized_main.contains("crate::r#extern::root_value"), - "expected generated use path to escape top-level keyword module, got:\n{main_rs}" - ); - assert!( - normalized_main.contains("crate::api::r#extern::nested_value"), - "expected generated use path to escape nested keyword module, got:\n{main_rs}" - ); - assert!( - normalized_api_mod.contains("#[path=\"extern.rs\"]pubmodr#extern;"), - "expected nested keyword module path attr in api/mod.rs, got:\n{api_mod_rs}" + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["from-string", "from-path"], + "unexpected independent union narrowing output:\n{stdout}" ); + Ok(()) + } - let run_output = Command::new(incan_debug_binary()) - .args(["run", main_path.to_string_lossy().as_ref()]) + #[test] + fn test_issue501_option_union_isinstance_narrowing_compile_and_run() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +type LocalPath = newtype str + +def describe(value: Option[LocalPath | str]) -> str: + if value is not None: + if isinstance(value, str): + return value.upper() + elif isinstance(value, LocalPath): + return value.0 + return "missing" + +def main() -> None: + println(describe("from-string")) + println(describe(LocalPath("from-path"))) + println(describe(None)) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( - run_output.status.success(), - "incan run keyword-module project failed: status={:?} stderr={}", - run_output.status, - String::from_utf8_lossy(&run_output.stderr) + output.status.success(), + "Option[Union] isinstance narrowing regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&run_output.stdout); - assert!( - stdout.contains("root-keyword"), - "expected top-level keyword module output, got:\n{stdout}" - ); - assert!( - stdout.contains("nested-keyword"), - "expected nested keyword module output, got:\n{stdout}" + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["FROM-STRING", "from-path", "missing"], + "unexpected Option[Union] narrowing output:\n{stdout}" ); - Ok(()) } #[test] - fn test_run_async_task_and_time_facade() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_async_task_time_facade_test"); - let source_path = project_dir.join("async_task_time.incn"); - let source = r#" -import std.async -from std.async.task import spawn, spawn_blocking -from std.async.time import sleep, timeout, timeout_ms, timeout_join, timeout_join_ms, TimeoutJoinOutcome - -async def quick_value() -> int: - await sleep(0.01) - return 7 - -async def slow_value() -> int: - await sleep(0.05) - return 99 - -def blocking_value() -> int: - return 42 - -async def main() -> None: - match await spawn(quick_value()): - Ok(value) => println(f"spawn_ok:{value}") - Err(err) => println(f"spawn_err:{err.message()}") - - match await spawn_blocking(blocking_value): - Ok(value) => println(f"spawn_blocking_ok:{value}") - Err(err) => println(f"spawn_blocking_err:{err.message()}") - - match await timeout(0.25, quick_value()): - Ok(value) => println(f"timeout_ok:{value}") - Err(err) => println(f"timeout_err:{err.message()}") - - match await timeout(0.001, slow_value()): - Ok(value) => println(f"timeout_unexpected_ok:{value}") - Err(err) => println(f"timeout_expired:{err.message()}") - - match await timeout_ms(250, quick_value()): - Ok(value) => println(f"timeout_ms_ok:{value}") - Err(err) => println(f"timeout_ms_err:{err.message()}") - - match await timeout_ms(1, slow_value()): - Ok(value) => println(f"timeout_ms_unexpected_ok:{value}") - Err(err) => println(f"timeout_ms_expired:{err.message()}") - - durable = spawn(slow_value()) - match await timeout_join(0.001, durable): - TimeoutJoinOutcome.Completed(value) => println(f"timeout_join_unexpected_ok:{value}") - TimeoutJoinOutcome.JoinFailed(err) => println(f"timeout_join_err:{err.message()}") - TimeoutJoinOutcome.TimedOut(handle) => - println("task still running after timeout") - match await handle: - Ok(value) => println(f"timeout_join_later:{value}") - Err(err) => println(f"timeout_join_later_err:{err.message()}") - - durable_ms = spawn(slow_value()) - match await timeout_join_ms(1, durable_ms): - TimeoutJoinOutcome.Completed(value) => println(f"timeout_join_ms_unexpected_ok:{value}") - TimeoutJoinOutcome.JoinFailed(err) => println(f"timeout_join_ms_err:{err.message()}") - TimeoutJoinOutcome.TimedOut(handle) => - match await handle: - Ok(value) => println(f"timeout_join_ms_later:{value}") - Err(err) => println(f"timeout_join_ms_later_err:{err.message()}") -"#; - std::fs::write(&source_path, source)?; + fn test_filtered_comprehensions_run_with_borrowed_iterables() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +model StoredNode: + store_id_raw: int + node: str - let output = Command::new(incan_debug_binary()) - .args(["run", source_path.to_string_lossy().as_ref()]) +def main() -> None: + nodes: list[StoredNode] = [ + StoredNode(store_id_raw=1, node="a"), + StoredNode(store_id_raw=2, node="b"), + ] + filtered = [stored.node for stored in nodes if stored.store_id_raw == 1] + scores = [1, 2, 3, 4] + squared_evens = {x: x * x for x in scores if x % 2 == 0} + println(filtered[0]) + println(squared_evens[2]) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run async task/time facade failed: status={:?} stderr={}", + "incan run -c filtered comprehension regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("spawn_ok:7"), - "expected spawn success output; got:\n{}", - stdout - ); - assert!( - stdout.contains("spawn_blocking_ok:42"), - "expected spawn_blocking success output; got:\n{}", - stdout - ); - assert!( - stdout.contains("timeout_ok:7"), - "expected timeout success output; got:\n{}", - stdout - ); - assert!( - stdout.contains("timeout_expired:operation timed out"), - "expected timeout expiry output; got:\n{}", - stdout - ); - assert!( - stdout.contains("timeout_ms_ok:7"), - "expected timeout_ms success output; got:\n{}", - stdout - ); - assert!( - stdout.contains("timeout_ms_expired:operation timed out"), - "expected timeout_ms expiry output; got:\n{}", - stdout - ); - assert!( - stdout.contains("task still running after timeout"), - "expected durable timeout message; got:\n{}", - stdout - ); - assert!( - stdout.contains("timeout_join_later:99"), - "expected timeout_join preserved handle output; got:\n{}", - stdout - ); - assert!( - stdout.contains("timeout_join_ms_later:99"), - "expected timeout_join_ms preserved handle output; got:\n{}", - stdout - ); - assert!( - !stdout.contains("timeout_unexpected_ok") - && !stdout.contains("timeout_ms_unexpected_ok") - && !stdout.contains("timeout_join_unexpected_ok") - && !stdout.contains("timeout_join_ms_unexpected_ok") - && !stdout.contains("spawn_err:") - && !stdout.contains("spawn_blocking_err:") - && !stdout.contains("timeout_err:") - && !stdout.contains("timeout_ms_err:"), - "unexpected error/success fallback branch output; got:\n{}", - stdout + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["a", "4"], + "unexpected filtered comprehension output:\n{stdout}" ); Ok(()) } #[test] - fn test_run_async_barrier_cancellation_withdraws_waiter() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_async_barrier_cancel_test"); - let source_path = project_dir.join("async_barrier_cancel.incn"); - let source = r#" -import std.async -from std.async.sync import Barrier, Mutex -from std.async.task import spawn, yield_now -from std.async.time import timeout_join_ms, TimeoutJoinOutcome - -async def mark_ready(ready: Mutex[int]) -> None: - guard = await ready.lock() - guard.set(1) + fn test_generator_expression_runs_lazily_with_source_ordered_clauses() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def main() -> None: + xs = [1, 2, 3] + ys = [2, 3, 4] + values = (x * y for x in xs if x > 1 for y in ys if y > x).collect() + println(values[0]) + println(values[1]) + println(values[2]) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "incan run -c generator expression regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); -async def is_ready(ready: Mutex[int]) -> bool: - guard = await ready.lock() - return guard.get() == 1 + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["6", "8", "12"], "unexpected generator output:\n{stdout}"); + Ok(()) + } -async def wait_until_ready(ready: Mutex[int]) -> None: - while True: - if await is_ready(ready): - return - await yield_now() + #[test] + fn test_generator_helper_chain_builds_and_runs() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def triple(x: int) -> int: + return x * 3 -async def wait_barrier(barrier: Barrier, ready: Mutex[int]) -> int: - await mark_ready(ready) - return await barrier.wait() +def big(x: int) -> bool: + return x > 6 -async def main() -> None: - barrier = Barrier.new(2) +def main() -> None: + xs = [1, 2, 3, 4, 5] + values = (x for x in xs).map(triple).filter(big).take(2).collect() + println(values[0]) + println(values[1]) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "incan run -c generator helper regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); - cancelled_ready = Mutex.new(0) - cancelled = spawn(wait_barrier(barrier, cancelled_ready)) - await wait_until_ready(cancelled_ready) - cancelled.abort() - match await cancelled: - Ok(slot) => println(f"unexpected_cancelled_slot:{slot}") - Err(err) => println(f"cancelled:{err.message()}") + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["9", "12"], "unexpected generator helper output:\n{stdout}"); + Ok(()) + } - replacement_ready = Mutex.new(0) - replacement = spawn(wait_barrier(barrier, replacement_ready)) - await wait_until_ready(replacement_ready) - match await timeout_join_ms(5, replacement): - TimeoutJoinOutcome.Completed(slot) => println(f"unexpected_replacement_completed:{slot}") - TimeoutJoinOutcome.JoinFailed(err) => println(f"unexpected_replacement_failed:{err.message()}") - TimeoutJoinOutcome.TimedOut(handle) => - println("replacement_waiting") - current = await barrier.wait() - match await handle: - Ok(slot) => println(f"replacement_slot:{slot}") - Err(err) => println(f"unexpected_replacement_join_failed:{err.message()}") - println(f"current_slot:{current}") -"#; - std::fs::write(&source_path, source)?; + #[test] + fn test_generator_function_yield_builds_and_runs() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def numbers() -> Generator[int]: + yield 1 + yield 2 - let output = Command::new(incan_debug_binary()) - .args(["run", source_path.to_string_lossy().as_ref()]) +def main() -> None: + values = numbers().collect() + println(values[0]) + println(values[1]) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run async barrier cancellation failed: status={:?} stderr={}", + "incan run -c generator function regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("cancelled:task") && stdout.contains("was cancelled"), - "expected cancelled join output; got:\n{}", - stdout - ); - assert!( - stdout.contains("replacement_waiting"), - "expected replacement to keep waiting until another active participant arrived; got:\n{}", - stdout - ); - assert!( - stdout.contains("replacement_slot:") && stdout.contains("current_slot:"), - "expected both active participants to complete after the second arrival; got:\n{}", - stdout - ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["1", "2"], "unexpected generator function output:\n{stdout}"); + Ok(()) + } + + #[test] + fn test_generator_function_body_starts_on_first_consumption() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def numbers() -> Generator[int]: + println("started") + yield 1 + +def main() -> None: + values = numbers() + println("after construction") + items = values.collect() + println(items[0]) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( - !stdout.contains("unexpected_"), - "unexpected fallback branch output; got:\n{}", - stdout + output.status.success(), + "incan run -c generator laziness regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["after construction", "started", "1"], + "generator body should not run until first consumption:\n{stdout}" + ); Ok(()) } #[test] - fn test_run_repro_model_traits() { - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/repro_model_traits.incn"]) - // This should not require network access (workspace deps should already be available). - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + fn test_generic_generator_function_yield_builds_and_runs() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def singleton[T](value: T) -> Generator[T]: + yield value +def main() -> None: + values = singleton[int](3).collect() + println(values[0]) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "incan run repro_model_traits failed: status={:?} stderr={}", + "incan run -c generic generator function regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("[Ada] hello"), - "expected repro output; got:\n{}", - stdout - ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["3"], "unexpected generic generator output:\n{stdout}"); + Ok(()) } - /// RFC 021: Runtime verification that __fields__() returns correct FieldInfo values #[test] - fn test_run_field_info_reflection() { - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/field_info_reflection.incn"]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + fn test_clone_self_struct_field_reads_do_not_move_out_of_borrowed_self() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +pub class ActiveRegistration: + pub logical_name: str + pub rank: int + + def clone(self) -> Self: + return ActiveRegistration(logical_name=self.logical_name, rank=self.rank) +def main() -> None: + reg = ActiveRegistration(logical_name="orders", rank=1) + copied = reg.clone() + println(copied.logical_name) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "incan run field_info_reflection failed: status={:?} stderr={}", + "incan run -c clone(self)->Self field regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["orders"], "unexpected clone(self)->Self output:\n{stdout}"); + Ok(()) + } - // Verify __class_name__ - assert!( - stdout.contains("Account"), - "expected __class_name__ to return 'Account'; got:\n{}", - stdout - ); + #[test] + fn test_loop_item_field_index_assignment_materializes_owned_value_issue616() + -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +model Assignment: + output_name: str - // Verify field info for type_ (has alias) - assert!( - stdout.contains("field:type_|wire:type|type:str|default:false"), - "expected type_ field info with alias='type'; got:\n{}", - stdout - ); +def names(assignments: list[Assignment]) -> list[str]: + mut output_names: list[str] = [] + for assignment in assignments: + existing_idx = index_of_name(output_names, assignment.output_name) + if existing_idx >= 0: + output_names[existing_idx] = assignment.output_name + else: + output_names.append(assignment.output_name) + return output_names - // Verify field info for balance (has default) - assert!( - stdout.contains("field:balance|wire:balance|type:int|default:true"), - "expected balance field info with default=true; got:\n{}", - stdout - ); +def index_of_name(names: list[str], name: str) -> int: + for idx, current in enumerate(names): + if current == name: + return idx + return -1 - // Verify field info for name (no alias, no default) +def main() -> None: + result = names([Assignment(output_name="amount"), Assignment(output_name="amount")]) + println(result[0]) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( - stdout.contains("field:name|wire:name|type:str|default:false"), - "expected name field info; got:\n{}", - stdout + output.status.success(), + "loop item field index-assignment regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - // Empty models should produce no FieldInfo entries - assert!( - stdout.contains("empty_fields:0"), - "expected empty model to return 0 fields; got:\n{}", - stdout + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["amount"], + "unexpected loop item field index-assignment output:\n{stdout}" ); + Ok(()) + } - // Nested generics should use Incan type formatting - assert!( - stdout.contains("settings_field:complex|type:list[dict[str, int]]"), - "expected nested generic type name; got:\n{}", - stdout - ); + #[test] + fn test_field_backed_by_value_method_args_do_not_require_user_clone_issue241() + -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +class Cursor: + def join(self, other: Self, on: bool) -> Self: + return Cursor() - // User-defined field types should use their Incan type name - assert!( - stdout.contains("user_field:address|type:Address"), - "expected user-defined field type name; got:\n{}", - stdout - ); +@derive(Clone) +class Wrapper: + _cursor: Cursor - // Inherited class fields should appear in __fields__() - assert!( - stdout.contains("child_field:base_id|type:int"), - "expected inherited base field in __fields__; got:\n{}", - stdout - ); - assert!( - stdout.contains("child_field:name|type:str"), - "expected child field in __fields__; got:\n{}", - stdout - ); - } + def merge(self, other: Self) -> Self: + return Wrapper(_cursor=self._cursor.join(other._cursor, true)) - /// RFC 023: Runtime parity check for source-defined stdlib surfaces migrated off helper stubs. - #[test] - fn test_run_rfc023_stdlib_behavior_parity() { - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/rfc023_stdlib_behavior_parity.incn"]) +def main() -> None: + left = Wrapper(_cursor=Cursor()) + right = Wrapper(_cursor=Cursor()) + _ = left.merge(right) + println("ok") +"#, + ]) .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; - + .output()?; assert!( output.status.success(), - "incan run rfc023_stdlib_behavior_parity failed: status={:?} stderr={}", + "field-backed by-value method arg regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("{\"value\":1,\"player\":\"Ada\"}"), - "expected explicit Serialize adoption to preserve JSON output; got:\n{}", - stdout - ); - assert!( - stdout.contains("Score"), - "expected reflection class name output; got:\n{}", - stdout - ); - assert!( - stdout.contains("true\ntrue"), - "expected clone/equality and ordering behavior from derive-backed traits; got:\n{}", - stdout - ); - assert!( - stdout.contains("{\"value\":0,\"player\":\"\"}"), - "expected Default derive to preserve zero-value JSON output; got:\n{}", - stdout - ); - assert!( - stdout.contains("field:value|wire:value|type:int|default:true"), - "expected reflection metadata for value field; got:\n{}", - stdout - ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["ok"], "unexpected issue241 output:\n{stdout}"); + Ok(()) + } + + #[test] + fn test_issue241_generic_field_backed_method_args_infer_clone_bounds() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +class Cursor[T]: + pub value: T + + def join(self, other: Self, on: bool) -> Self: + return self + +@derive(Clone) +class Wrapper[T]: + pub _cursor: Cursor[T] + + def merge(self, other: Self) -> Self: + return Wrapper(_cursor=self._cursor.join(other._cursor, true)) + +def main() -> None: + left = Wrapper(_cursor=Cursor(value=1)) + right = Wrapper(_cursor=Cursor(value=2)) + println(left.merge(right)._cursor.value) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( - stdout.contains("field:player|wire:player|type:str|default:true"), - "expected reflection metadata for player field; got:\n{}", - stdout + output.status.success(), + "generic issue241 regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["1"], "unexpected generic issue241 output:\n{stdout}"); + Ok(()) } #[test] - fn test_run_rfc030_std_collections_behavior() { - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/rfc030_std_collections_behavior.incn"]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + fn test_returning_tuple_with_reused_field_materializes_owned_items() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +class Pred: + pub name: str + +@derive(Clone) +class Node: + pub filter_predicate: Pred + +def pair(node: Node) -> tuple[Pred, Pred]: + return (node.filter_predicate, node.filter_predicate) +def main() -> None: + left, right = pair(Node(filter_predicate=Pred(name="x"))) + println(left.name) + println(right.name) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "incan run rfc030_std_collections_behavior failed: status={:?} stderr={}", + "tuple field reuse ownership regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["x", "x"], "unexpected tuple field reuse output:\n{stdout}"); + Ok(()) } #[test] - fn test_run_rfc064_std_encoding_behavior() { - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/rfc064_std_encoding_behavior.incn"]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + fn test_generic_tuple_return_with_reused_field_infers_clone_bound() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +class Node[T]: + pub value: T + +def pair[T](node: Node[T]) -> tuple[T, T]: + return (node.value, node.value) +def main() -> None: + left, right = pair(Node(value=1)) + println(left) + println(right) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "incan run rfc064_std_encoding_behavior failed: status={:?} stderr={}", + "generic tuple field reuse regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("strict-padding-error") - && stdout.contains("bech32-checksum-error") - && stdout.contains("rfc064-encoding-ok"), - "expected strict error markers and success marker; got:\n{}", - stdout + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["1", "1"], + "unexpected generic tuple field reuse output:\n{stdout}" ); + Ok(()) } #[test] - fn test_run_std_uuid_surface() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/std_uuid_surface.incn"]) + fn test_incan_call_materializes_owned_value_from_box_as_ref() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +from rust::std::boxed import Box + +@derive(Clone) +class Node: + pub value: int + +def take(node: Node) -> int: + return node.value + +def from_box(child: Box[Node]) -> int: + return take(child.as_ref()) + +def main() -> None: + println(from_box(Box.new(Node(value=4)))) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run std_uuid_surface failed: status={:?} stderr={}", + "borrowed box as_ref call regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "std.uuid ok"); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["4"], "unexpected box as_ref output:\n{stdout}"); Ok(()) } #[test] - fn test_run_std_ordinal_map_surface() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/std_ordinal_map_surface.incn"]) + fn test_generic_incan_call_materializes_owned_value_from_box_as_ref() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +from rust::std::boxed import Box + +@derive(Clone) +class Node[T]: + pub value: T + +def take[T](node: Node[T]) -> T: + return node.value + +def from_box[T](child: Box[Node[T]]) -> T: + return take(child.as_ref()) + +def main() -> None: + println(from_box(Box.new(Node(value=4)))) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run std_ordinal_map_surface failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "generic borrowed box as_ref call regression failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "std.ordinal_map ok"); - let generated_main = fs::read_to_string("target/incan/std_ordinal_map_surface/src/main.rs")?; - assert!( - generated_main.contains("__incan_ordinal_require_str("), - "OrdinalMap[str] literal lookup should lower through the borrowed string fast path:\n{generated_main}" - ); - let generated_collections = - fs::read_to_string("target/incan/std_ordinal_map_surface/src/__incan_std/collections.rs")?; - assert!( - generated_collections.contains("incan_stdlib::__incan_ordinal_map_string_fast_impls!();"), - "generated std.collections should splice in the stdlib-owned OrdinalMap string support:\n{generated_collections}" - ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["4"], "unexpected generic box as_ref output:\n{stdout}"); Ok(()) } #[test] - fn test_run_std_regex_rfc059_surface() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/std_regex_surface.incn"]) - .output()?; + fn test_match_on_shared_self_option_field_materializes_owned_scrutinee() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +pub class Node: + pub value: int + +@derive(Clone) +pub class Wrapper: + child: Option[Node] + + def read(self) -> int: + match self.child: + Some(child) => return child.value + None => return 0 +def main() -> None: + println(Wrapper(child=Some(Node(value=4))).read()) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "incan run std_regex_surface failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "shared self option-field match regression failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec![ - "true", - "xx@0:2", - "ALPHA-12", - "beta", - "beta", - "0:4", - "", - "", - "beta|", - "one,two", - "a:1,b:2", - "a|b|c", - "a|b,c", - "a|b,c", - "a|b|c", - "Lovelace, Ada", - "Lovelace/Ada", - "Lovelace, Ada", - "$2, $1", - "x x three", - "$1 two", - ], - "unexpected std.regex output:\n{stdout}" - ); - let generated_core = fs::read_to_string("target/incan/std_regex_surface/src/__incan_std/regex/_core.rs")?; - for unexpected in [ - "RegexBuilder::new(&(pattern).to_string())", - "raw.find(&(text).to_string())", - "raw.find_iter(&(text).to_string())", - "raw.captures(&(text).to_string())", - "raw.captures_iter(&(text).to_string())", - ] { - assert!( - !generated_core.contains(unexpected), - "std.regex should let the compiler borrow Incan strings for Rust regex APIs instead of cloning them:\n{generated_core}" - ); - } - for expected in [ - "RegexBuilder::new(&pattern)", - "raw.find(&text)", - "raw.find_iter(&text)", - "raw.captures(&text)", - "raw.captures_iter(&text)", - ] { - assert!( - generated_core.contains(expected), - "std.regex should preserve compiler-managed Rust borrow boundaries; missing `{expected}`:\n{generated_core}" - ); - } + vec!["4"], + "unexpected shared self option-field match output:\n{stdout}" + ); Ok(()) } #[test] - fn test_run_std_regex_unsupported_safe_engine_pattern_reports_error() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) + fn test_match_on_shared_self_option_box_field_materializes_owned_scrutinee() + -> Result<(), Box> { + let output = incan_command() .args([ "run", "-c", r#" -from std.regex import Regex +from rust::std::boxed import Box + +@derive(Clone) +pub class Node: + pub value: int + +@derive(Clone) +pub class Wrapper: + child: Option[Box[Node]] + + def read(self) -> int: + match self.child: + Some(child) => return child.as_ref().value + None => return 0 def main() -> None: - match Regex("(?<=prefix)\\w+"): - Ok(_) => println("unexpected-ok") - Err(err) => - println("unsupported") - println(err.kind()) - println(err.message()) + println(Wrapper(child=Some(Box.new(Node(value=4)))).read()) "#, ]) + .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "std.regex unsupported-pattern program should report RegexError without failing the process: status={:?}\nstdout:\n{}\nstderr:\n{}", + "shared self option-box-field match regression failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - assert!( - stdout.contains("unsupported") && !stdout.contains("unexpected-ok"), - "expected safe-engine rejection branch, got:\n{stdout}" - ); - assert!( - stdout.contains("compile_error"), - "expected stable RegexError kind, got:\n{stdout}" - ); - assert!( - stdout.to_ascii_lowercase().contains("look"), - "expected diagnostic to identify the unsupported lookaround boundary, got:\n{stdout}" + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["4"], + "unexpected shared self option-box-field match output:\n{stdout}" ); Ok(()) } #[test] - fn test_run_u128_modulo_floor_div() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/u128_modulo_floor_div.incn"]) + fn test_generic_match_on_shared_self_option_field_infers_clone_bound() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +@derive(Clone) +pub class Wrapper[T]: + child: Option[T] + + def read_or(self, fallback: T) -> T: + match self.child: + Some(child) => return child + None => return fallback + +def main() -> None: + println(Wrapper(child=Some(4)).read_or(0)) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run u128_modulo_floor_div failed: status={:?} stderr={}", + "generic shared self option-field match regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "u128 modulo ok"); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["4"], + "unexpected generic shared self option-field match output:\n{stdout}" + ); Ok(()) } #[test] - fn test_run_rfc030_field_overlay_reflection() { - let Ok(output) = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/rfc030_field_overlay_reflection.incn"]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + fn test_trait_supertraits_runtime_with_backend_clone_bounds() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +trait Collection[T]: + def first(self) -> T: ... - assert!( - output.status.success(), - "incan run rfc030_field_overlay_reflection failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - } +trait OrderedCollection[T] with Collection[T]: + def sorted(self) -> Self: ... - #[test] - fn test_check_cyclic_explicit_call_site_generics_cross_module_succeeds() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_cycle_explicit_call_site_check"); - let main_path = super::write_cycle_explicit_call_site_generics_project(&project_dir)?; +model BoxedValue[T] with OrderedCollection: + value: T - let output = Command::new(incan_debug_binary()) - .arg("--check") - .arg(main_path) + def first(self) -> T: + return self.value + + def sorted(self) -> Self: + return self + +def take_first(values: Collection[int]) -> int: + return values.first() + +def take_sorted(values: OrderedCollection[int]) -> OrderedCollection[int]: + return values.sorted() + +def main() -> None: + println(take_first(BoxedValue(value=1))) + println(take_sorted(BoxedValue(value=2)).first()) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan --check cyclic explicit call-site generics failed: status={:?} stderr={}", + "trait-supertrait ownership regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["1", "2"], "unexpected trait-supertrait output:\n{stdout}"); Ok(()) } #[test] - fn test_run_cyclic_explicit_call_site_generics_cross_module_succeeds() -> Result<(), Box> { - let project_dir = make_temp_dir("incan_cycle_explicit_call_site_run"); - let main_path = super::write_cycle_explicit_call_site_generics_project(&project_dir)?; + fn test_result_ok_string_literals_run_without_manual_str_wrapping() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def returns_result() -> Result[str, str]: + return Ok("from_return") - let output = Command::new(incan_debug_binary()) - .arg("run") - .arg(main_path) +def main() -> None: + direct: Result[str, str] = Ok("from_call") + match direct: + case Ok(msg): + println(msg) + case Err(err): + println(err) + + match returns_result(): + case Ok(msg): + println(msg) + case Err(err): + println(err) +"#, + ]) .env("CARGO_NET_OFFLINE", "true") .output()?; - assert!( output.status.success(), - "incan run cyclic explicit call-site generics failed: status={:?} stderr={}", + "incan run -c Result[str, E] string regression failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains('1'), - "expected runtime output to contain 1, got:\n{}", - stdout + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec!["from_call", "from_return"], + "unexpected Result[str, E] output:\n{stdout}" ); Ok(()) } #[test] - fn test_benchmark_quicksort_codegen_compiles() { - let path = Path::new("benchmarks/sorting/quicksort/quicksort.incn"); - if !path.exists() { - return; - } - - let Ok(source) = fs::read_to_string(path) else { - panic!("failed to read {}", path.display()); - }; - let Ok(tokens) = lexer::lex(&source) else { - panic!("lexing failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(()) = typechecker::check(&ast) else { - panic!("typecheck failed"); - }; - - let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { - panic!("codegen failed"); - }; - - // Regression: Vec::swap indices must be cast to usize. - let mut ok = true; - let mut search_from = 0usize; - while let Some(pos) = rust_code[search_from..].find(".swap(") { - let abs = search_from + pos; - let window_end = (abs + 120).min(rust_code.len()); - let window = &rust_code[abs..window_end]; - if !window.contains("as usize") { - ok = false; - break; - } - search_from = abs + 5; - } - assert!( - ok, - "expected quicksort to cast swap indices to usize; generated:\n{}", - rust_code - ); - - // Note: This test uses standalone rustc compilation, which can't access incan_stdlib/incan_derive. - // Skip the compilation check if generated Rust references external Incan crates. - if rust_code.contains("incan_stdlib::") || rust_code.contains("incan_derive::") { - // Skip rustc compilation test for code that requires Incan support crates. - return; - } - - let Ok(()) = rustc_compile_ok(&rust_code) else { - panic!("generated quicksort Rust failed to compile"); - }; - } - - #[test] - fn test_const_declarations_compile_and_run() { - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -const PI: float = 3.14159 -const APP_NAME: str = "Incan" -const MAGIC: int = 42 -const ENABLED: bool = true -const RAW_DATA: bytes = b"\x00\x01\x02\x03" -const FROZEN_TEXT: FrozenStr = "frozen" -const NUMBERS: FrozenList[int] = [1, 2, 3, 4, 5] -const GREETING: str = "Hello World" - -def main() -> None: - print(PI) - print(APP_NAME) - print(MAGIC) - print(ENABLED) - print(RAW_DATA.len()) - print(FROZEN_TEXT.len()) - print(NUMBERS.len()) - print(GREETING) + fn test_run_file_release_flag() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_run_release_file"); + let source_path = project_dir.join("main.incn"); + std::fs::write( + &source_path, + r#"def main() -> None: + println("release file path works") "#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + )?; + let output = incan_command() + .args(["run", "--release", source_path.to_string_lossy().as_ref()]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "const declarations test failed: status={:?} stderr={}", + "incan run --release failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("3.14159"), "PI const not emitted correctly"); - assert!(stdout.contains("Incan"), "APP_NAME const not emitted correctly"); - assert!(stdout.contains("42"), "MAGIC const not emitted correctly"); - assert!(stdout.contains("true"), "ENABLED const not emitted correctly"); - assert!(stdout.contains("4"), "RAW_DATA length incorrect"); - assert!(stdout.contains("6"), "FROZEN_TEXT length incorrect"); - assert!(stdout.contains("5"), "NUMBERS length incorrect"); - assert!(stdout.contains("Hello World"), "GREETING concat not working"); + assert!( + stdout.contains("release file path works"), + "stdout missing expected output; got:\n{}", + stdout + ); + Ok(()) } #[test] - fn test_const_str_materializes_to_owned_str_at_runtime_sites() { - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -const PREFIX: str = "target/" - -def echo(value: str) -> str: - return value - -def direct() -> str: - return PREFIX + fn test_check_web_route_uses_proc_macro_passthrough() { + let project_dir = make_temp_dir("incan_web_proc_macro_test"); + let source_path = project_dir.join("main.incn"); + let source = r#" +import std.async +from std.web import route -def join(name: str) -> str: - return PREFIX + name +@route("/health") +async def health() -> str: + return "ok" def main() -> None: - local = PREFIX - println(direct()) - println(echo(PREFIX)) - println(echo(local)) - println(join("orders.csv")) -"#, - ]) + pass +"#; + let Ok(()) = std::fs::write(&source_path, source) else { + panic!("failed to write source file"); + }; + + let Ok(output) = incan_command() + .args(["--check", source_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output() else { - panic!("failed to run incan"); + panic!("failed to run incan check"); }; assert!( output.status.success(), - "const str materialization test failed: status={:?} stderr={}", + "incan check web route failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); - - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!(lines, vec!["target/", "target/", "target/", "target/orders.csv"]); } #[test] - fn test_rfc041_rusttype_interop_typechecks_end_to_end() { + fn test_run_async_channel_facade() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_async_channel_facade_test"); + let source_path = project_dir.join("async_channel.incn"); let source = r#" -from rust::std::string import String as RustString +import std.async +from std.async.channel import channel, unbounded_channel, oneshot -type Name = rusttype RustString: - def parse(raw: str) -> Result[Name, str]: - ... +async def main() -> None: + tx, rx = channel(4) + cloned = tx.clone() - def as_str(self) -> str: - ... + match await cloned.send(1): + Ok(_) => println("sent") + Err(err) => println(err.message()) - interop: - from str try Name.parse - into str via Name.as_str + match await rx.recv(): + Some(value) => println(value) + None => println("closed") -def main() -> None: - pass -"#; - let Ok(()) = super::compile_source(source) else { - panic!("expected RFC 041 rusttype/interop source to typecheck"); - }; - } + match await tx.reserve(): + Ok(permit) => + match permit.send(4): + Ok(_) => println("reserved") + Err(err) => println(err.message()) + Err(err) => println(err.message()) - #[test] - fn test_rfc041_rusttype_with_methods_typechecks() { - let source = r#" -from rust::mail import Sender as RustSender + match await rx.recv(): + Some(value) => println(value) + None => println("closed") -type Sender = rusttype RustSender: - send_now = try_send + tx2, rx2 = unbounded_channel() + match await tx2.send(2): + Ok(_) => println("sent") + Err(err) => println(err.message()) - def try_send(self, value: int) -> Result[None, str]: - ... + match rx2.try_recv(): + Some(value) => println(value) + None => println("empty") -def push(sender: Sender, value: int) -> Result[None, str]: - return sender.send_now(value) + match await tx2.reserve(): + Ok(permit) => + match permit.send(5): + Ok(_) => println("unbounded reserved") + Err(err) => println(err.message()) + Err(err) => println(err.message()) -def main() -> None: - pass -"#; - let Ok(()) = super::compile_source(source) else { - panic!("expected RFC 041 rusttype method surface to typecheck"); - }; - } + match rx2.try_recv(): + Some(value) => println(value) + None => println("empty") - #[test] - fn test_rfc041_rust_coercion_codegen_smoke() { - let source = r#" -from rust::std::time import Duration + println(f"close:{rx2.close()}") + println(tx2.is_closed()) -def main() -> None: - _ = Duration.from_secs_f32(1.5) + otx, orx = oneshot() + match otx.send(3): + Ok(_) => println("delivered") + Err(value) => println(value) + + match await orx.recv(): + Ok(value) => println(value) + Err(err) => println(err.message()) "#; - let Ok(tokens) = lexer::lex(source) else { - panic!("lexing failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(()) = typechecker::check(&ast) else { - panic!("typecheck failed"); - }; - let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { - panic!("codegen failed"); - }; + std::fs::write(&source_path, source)?; + + let output = incan_command() + .args(["run", source_path.to_string_lossy().as_ref()]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( - rust_code.contains("Duration::from_secs_f32"), - "expected RFC 041 coercion fixture to lower to Duration::from_secs_f32 call, got:\n{rust_code}" + output.status.success(), + "incan run async channel facade failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - } - #[test] - fn test_rfc041_structural_coercion_codegen_smoke() { - let source = r#" -def main() -> None: - maybe: Option[int] = Some(1) - names: List[str] = ["a", "b"] - scores: Dict[str, float] = {"latency": 1.5} -"#; - let Ok(tokens) = lexer::lex(source) else { - panic!("lexing failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(()) = typechecker::check(&ast) else { - panic!("typecheck failed"); - }; - let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { - panic!("codegen failed"); - }; + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("sent"), "expected send output; got:\n{}", stdout); assert!( - rust_code.contains("let _maybe: Option = Some(1);"), - "expected Option[int] smoke value to lower to a Rust Option expression; got:\n{rust_code}" + stdout.contains("1"), + "expected bounded receive output; got:\n{}", + stdout ); assert!( - rust_code.contains("let _names: Vec = vec![\"a\".to_string(), \"b\".to_string()];"), - "expected List[str] smoke value to lower to an owned Rust string vec; got:\n{rust_code}" + stdout.contains("2"), + "expected unbounded receive output; got:\n{}", + stdout + ); + assert!( + stdout.contains("reserved"), + "expected bounded reserve output; got:\n{}", + stdout + ); + assert!( + stdout.contains("4"), + "expected bounded permit receive output; got:\n{}", + stdout + ); + assert!( + stdout.contains("unbounded reserved"), + "expected unbounded reserve output; got:\n{}", + stdout + ); + assert!( + stdout.contains("5"), + "expected unbounded permit receive output; got:\n{}", + stdout + ); + assert!( + stdout.contains("close:true"), + "expected receiver close output; got:\n{}", + stdout + ); + assert!( + stdout.contains("true"), + "expected closed-state output; got:\n{}", + stdout + ); + assert!( + stdout.contains("delivered"), + "expected oneshot send output; got:\n{}", + stdout ); assert!( - rust_code.contains("collect::>()"), - "expected Dict[str, float] smoke value to lower to a Rust HashMap collect; got:\n{rust_code}" + stdout.contains("3"), + "expected oneshot receive output; got:\n{}", + stdout ); + Ok(()) } + /// Regression (GitHub #289): `await expr?` must emit `.await?` (not `?.await`) in generated Rust. #[test] - fn test_rfc009_numeric_resize_and_decimal_codegen_smoke() { + fn test_build_async_await_try_ordering_emits_await_before_try() { + let project_dir = make_temp_dir("incan_async_await_try_ordering"); + let source_path = project_dir.join("async_await_try_ordering.incn"); + let out_dir = project_dir.join("out"); let source = r#" -def main() -> None: - small: i8 = 120 - wide: int = small.resize() - maybe: Option[i8] = wide.try_resize() - wrapped: i8 = wide.wrapping_resize() - capped: i8 = wide.saturating_resize() - price: decimal[5, 2] = 19.99d +import std.async + +async def register_sources() -> Result[None, str]: + return Ok(None) + +async def main() -> Result[None, str]: + await register_sources()? + return Ok(None) "#; - let Ok(tokens) = lexer::lex(source) else { - panic!("lexing failed"); - }; - let Ok(ast) = parser::parse(&tokens) else { - panic!("parse failed"); - }; - let Ok(()) = typechecker::check(&ast) else { - panic!("typecheck failed"); + let Ok(()) = std::fs::write(&source_path, source) else { + panic!("failed to write source file"); }; - let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { - panic!("codegen failed"); + + let Ok(output) = incan_command() + .args([ + "build", + source_path.to_string_lossy().as_ref(), + out_dir.to_string_lossy().as_ref(), + ]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan build"); }; + assert!( - rust_code.contains("let wide: i64 = (small) as i64;"), - "expected lossless resize to emit a Rust cast, got:\n{rust_code}" - ); - assert!( - rust_code.contains("incan_stdlib::num::try_resize::<_, i8>(wide)"), - "expected try_resize to call stdlib checked resize helper, got:\n{rust_code}" + output.status.success(), + "incan build await/try ordering regression failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + + let generated_main = out_dir.join("src/main.rs"); + let Ok(main_rs) = std::fs::read_to_string(&generated_main) else { + panic!("failed to read generated Rust source"); + }; + let normalized: String = main_rs.chars().filter(|c| !c.is_whitespace()).collect(); assert!( - rust_code.contains("incan_stdlib::num::saturating_resize::<_, i8>(wide)"), - "expected saturating_resize to call stdlib saturating helper, got:\n{rust_code}" + normalized.contains("register_sources().await?;"), + "expected awaited-then-try ordering in generated Rust, got:\n{}", + main_rs ); assert!( - rust_code.contains("let _price: incan_stdlib::num::Decimal128") - && rust_code.contains("Decimal128::from_literal") - && rust_code.contains("\"19.99d\""), - "expected decimal annotation/literal to lower to Decimal128, got:\n{rust_code}" + !normalized.contains("register_sources()?.await;"), + "generated Rust must not apply `?` before `.await`, got:\n{}", + main_rs ); } #[test] - fn test_mixed_numeric_codegen_runs() { - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" + fn test_build_and_run_keyword_named_modules_escape_consistently() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_keyword_module_paths"); + let src_dir = project_dir.join("src"); + std::fs::create_dir_all(src_dir.join("api"))?; + std::fs::write( + project_dir.join("incan.toml"), + "[project]\nname = \"keyword_module_paths\"\nversion = \"0.1.0\"\n", + )?; + + let main_path = src_dir.join("main.incn"); + // Use a Rust keyword that remains a legal Incan module spelling. `type` is a separate Incan keyword, so + // parser work to allow `from type import ...` would be a different issue than Rust-side module escaping. + std::fs::write( + &main_path, + r#"from extern import root_value +from api.extern import nested_value + def main() -> None: - size: int = 2 - x: float = 3.0 - result = 2.0 * x / size - println(result) + println(root_value()) + println(nested_value()) +"#, + )?; + std::fs::write( + src_dir.join("extern.incn"), + r#"pub def root_value() -> str: + return "root-keyword" "#, + )?; + std::fs::write( + src_dir.join("api").join("extern.incn"), + r#"pub def nested_value() -> str: + return "nested-keyword" +"#, + )?; + + let out_dir = project_dir.join("out"); + let build_output = incan_command() + .args([ + "build", + main_path.to_string_lossy().as_ref(), + out_dir.to_string_lossy().as_ref(), ]) .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; + .output()?; + assert!( + build_output.status.success(), + "incan build keyword-module project failed: status={:?} stderr={}", + build_output.status, + String::from_utf8_lossy(&build_output.stderr) + ); + + let main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; + let api_mod_rs = std::fs::read_to_string(out_dir.join("src/api/mod.rs"))?; + let normalized_main: String = main_rs.chars().filter(|c| !c.is_whitespace()).collect(); + let normalized_api_mod: String = api_mod_rs.chars().filter(|c| !c.is_whitespace()).collect(); assert!( - output.status.success(), - "mixed numeric run failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) + normalized_main.contains("#[path=\"extern.rs\"]modr#extern;"), + "expected top-level keyword module path attr in generated main.rs, got:\n{main_rs}" + ); + assert!( + normalized_main.contains("crate::r#extern::root_value"), + "expected generated use path to escape top-level keyword module, got:\n{main_rs}" + ); + assert!( + normalized_main.contains("crate::api::r#extern::nested_value"), + "expected generated use path to escape nested keyword module, got:\n{main_rs}" + ); + assert!( + normalized_api_mod.contains("#[path=\"extern.rs\"]pubmodr#extern;"), + "expected nested keyword module path attr in api/mod.rs, got:\n{api_mod_rs}" ); - let stdout = String::from_utf8_lossy(&output.stdout); + let run_output = incan_command() + .args(["run", main_path.to_string_lossy().as_ref()]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( - stdout.contains('3'), - "mixed numeric output missing expected result; stdout={}", - stdout + run_output.status.success(), + "incan run keyword-module project failed: status={:?} stderr={}", + run_output.status, + String::from_utf8_lossy(&run_output.stderr) + ); + + let stdout = String::from_utf8_lossy(&run_output.stdout); + assert!( + stdout.contains("root-keyword"), + "expected top-level keyword module output, got:\n{stdout}" ); + assert!( + stdout.contains("nested-keyword"), + "expected nested keyword module output, got:\n{stdout}" + ); + + Ok(()) } #[test] - fn test_std_async_race_helper_first_completion_runs() { - let output = run_incan_source( - r#" -from std.async.race import arm, race -from std.async.time import sleep + fn test_run_async_task_and_time_facade() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_async_task_time_facade_test"); + let source_path = project_dir.join("async_task_time.incn"); + let source = r#" +import std.async +from std.async.task import spawn, spawn_blocking +from std.async.time import sleep, timeout, timeout_ms, timeout_join, timeout_join_ms, TimeoutJoinOutcome -def label(value: int) -> str: - return f"win:{value}" +async def quick_value() -> int: + await sleep(0.01) + return 7 -async def fast() -> int: - return 1 +async def slow_value() -> int: + await sleep(0.05) + return 99 -async def slow() -> int: - await sleep(0.01) - return 2 +def blocking_value() -> int: + return 42 + +async def main() -> None: + match await spawn(quick_value()): + Ok(value) => println(f"spawn_ok:{value}") + Err(err) => println(f"spawn_err:{err.message()}") + + match await spawn_blocking(blocking_value): + Ok(value) => println(f"spawn_blocking_ok:{value}") + Err(err) => println(f"spawn_blocking_err:{err.message()}") + + match await timeout(0.25, quick_value()): + Ok(value) => println(f"timeout_ok:{value}") + Err(err) => println(f"timeout_err:{err.message()}") + + match await timeout(0.001, slow_value()): + Ok(value) => println(f"timeout_unexpected_ok:{value}") + Err(err) => println(f"timeout_expired:{err.message()}") + + match await timeout_ms(250, quick_value()): + Ok(value) => println(f"timeout_ms_ok:{value}") + Err(err) => println(f"timeout_ms_err:{err.message()}") + + match await timeout_ms(1, slow_value()): + Ok(value) => println(f"timeout_ms_unexpected_ok:{value}") + Err(err) => println(f"timeout_ms_expired:{err.message()}") + + durable = spawn(slow_value()) + match await timeout_join(0.001, durable): + TimeoutJoinOutcome.Completed(value) => println(f"timeout_join_unexpected_ok:{value}") + TimeoutJoinOutcome.JoinFailed(err) => println(f"timeout_join_err:{err.message()}") + TimeoutJoinOutcome.TimedOut(handle) => + println("task still running after timeout") + match await handle: + Ok(value) => println(f"timeout_join_later:{value}") + Err(err) => println(f"timeout_join_later_err:{err.message()}") + + durable_ms = spawn(slow_value()) + match await timeout_join_ms(1, durable_ms): + TimeoutJoinOutcome.Completed(value) => println(f"timeout_join_ms_unexpected_ok:{value}") + TimeoutJoinOutcome.JoinFailed(err) => println(f"timeout_join_ms_err:{err.message()}") + TimeoutJoinOutcome.TimedOut(handle) => + match await handle: + Ok(value) => println(f"timeout_join_ms_later:{value}") + Err(err) => println(f"timeout_join_ms_later_err:{err.message()}") +"#; + std::fs::write(&source_path, source)?; + + let output = incan_command() + .args(["run", source_path.to_string_lossy().as_ref()]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; -async def main() -> None: - println(await race(arm(slow(), label), arm(fast(), label))) -"#, - ); assert!( output.status.success(), - "std.async.race first-completion run failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + "incan run async task/time facade failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - assert_eq!( - stdout.lines().last().map(str::trim), - Some("win:1"), - "unexpected stdout:\n{stdout}" - ); - } - - #[test] - fn test_std_async_race_helper_ready_tie_uses_source_order() { - let output = run_incan_source( - r#" -from std.async.race import arm, race - -def label(value: int) -> str: - return f"win:{value}" - -async def first() -> int: - return 1 -async def second() -> int: - return 2 - -async def main() -> None: - println(await race(arm(first(), label), arm(second(), label))) -"#, + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("spawn_ok:7"), + "expected spawn success output; got:\n{}", + stdout ); assert!( - output.status.success(), - "std.async.race ready-tie run failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + stdout.contains("spawn_blocking_ok:42"), + "expected spawn_blocking success output; got:\n{}", + stdout ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - assert_eq!( - stdout.lines().last().map(str::trim), - Some("win:1"), - "unexpected stdout:\n{stdout}" + assert!( + stdout.contains("timeout_ok:7"), + "expected timeout success output; got:\n{}", + stdout ); - } - - #[test] - fn test_race_for_expression_first_completion_runs_through_shared_runtime() { - let output = run_incan_source( - r#" -import std.async -from std.async.time import sleep - -async def fast() -> int: - return 1 - -async def slow() -> int: - await sleep(0.01) - return 2 - -async def main() -> None: - prefix = "win" - result = race for value: - await slow() => f"{prefix}:{value}" - await fast() => f"{prefix}:{value}" - println(result) -"#, + assert!( + stdout.contains("timeout_expired:operation timed out"), + "expected timeout expiry output; got:\n{}", + stdout ); assert!( - output.status.success(), - "race for first-completion run failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + stdout.contains("timeout_ms_ok:7"), + "expected timeout_ms success output; got:\n{}", + stdout ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - assert_eq!( - stdout.lines().last().map(str::trim), - Some("win:1"), - "unexpected stdout:\n{stdout}" + assert!( + stdout.contains("timeout_ms_expired:operation timed out"), + "expected timeout_ms expiry output; got:\n{}", + stdout ); - } - - #[test] - fn test_race_for_expression_ready_tie_uses_stdlib_source_order() { - let output = run_incan_source( - r#" -import std.async - -async def first() -> int: - return 1 - -async def second() -> int: - return 2 - -async def main() -> None: - result = race for value: - await first() => value - await second() => value - println(result) -"#, + assert!( + stdout.contains("task still running after timeout"), + "expected durable timeout message; got:\n{}", + stdout ); assert!( - output.status.success(), - "race for ready-tie run failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + stdout.contains("timeout_join_later:99"), + "expected timeout_join preserved handle output; got:\n{}", + stdout ); - let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); - assert_eq!( - stdout.lines().last().map(str::trim), - Some("1"), - "unexpected stdout:\n{stdout}" + assert!( + stdout.contains("timeout_join_ms_later:99"), + "expected timeout_join_ms preserved handle output; got:\n{}", + stdout + ); + assert!( + !stdout.contains("timeout_unexpected_ok") + && !stdout.contains("timeout_ms_unexpected_ok") + && !stdout.contains("timeout_join_unexpected_ok") + && !stdout.contains("timeout_join_ms_unexpected_ok") + && !stdout.contains("spawn_err:") + && !stdout.contains("spawn_blocking_err:") + && !stdout.contains("timeout_err:") + && !stdout.contains("timeout_ms_err:"), + "unexpected error/success fallback branch output; got:\n{}", + stdout ); + Ok(()) } #[test] - fn test_std_math_module_constants_and_functions_run() { - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -import std.math + fn test_run_async_barrier_cancellation_withdraws_waiter() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_async_barrier_cancel_test"); + let source_path = project_dir.join("async_barrier_cancel.incn"); + let source = r#" +import std.async +from std.async.sync import Barrier, Mutex +from std.async.task import spawn, yield_now +from std.async.time import timeout_join_ms, TimeoutJoinOutcome -def main() -> None: - println(math.PI) - println(math.round(1.6)) - println(math.log2(8.0)) - println(math.atan2(1.0, 1.0)) - println(math.hypot(3.0, 4.0)) - println(math.gcd(54, 24)) - println(math.lcm(6, 8)) -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output() - else { - panic!("failed to run incan"); - }; +async def mark_ready(ready: Mutex[int]) -> None: + guard = await ready.lock() + guard.set(1) - assert!( - output.status.success(), - "std.math module run failed: status={:?} stderr={}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); +async def is_ready(ready: Mutex[int]) -> bool: + guard = await ready.lock() + return guard.get() == 1 - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines.len(), - 7, - "expected 7 output lines (PI/round/log2/atan2/hypot/gcd/lcm); got: {stdout}" - ); +async def wait_until_ready(ready: Mutex[int]) -> None: + while True: + if await is_ready(ready): + return + await yield_now() - let Ok(pi) = lines[0].parse::() else { - panic!("PI output was not a float: `{}`", lines[0]); - }; - let Ok(round) = lines[1].parse::() else { - panic!("round output was not a float: `{}`", lines[1]); - }; - let Ok(log2) = lines[2].parse::() else { - panic!("log2 output was not a float: `{}`", lines[2]); - }; - let Ok(atan2) = lines[3].parse::() else { - panic!("atan2 output was not a float: `{}`", lines[3]); - }; - let Ok(hypot) = lines[4].parse::() else { - panic!("hypot output was not a float: `{}`", lines[4]); - }; - let Ok(gcd) = lines[5].parse::() else { - panic!("gcd output was not an int: `{}`", lines[5]); - }; - let Ok(lcm) = lines[6].parse::() else { - panic!("lcm output was not an int: `{}`", lines[6]); - }; +async def wait_barrier(barrier: Barrier, ready: Mutex[int]) -> int: + await mark_ready(ready) + return await barrier.wait() - assert!((pi - std::f64::consts::PI).abs() < 1e-12, "unexpected PI value: {pi}"); - assert!((round - 2.0).abs() < 1e-12, "unexpected round value: {round}"); - assert!((log2 - 3.0).abs() < 1e-12, "unexpected log2 value: {log2}"); - assert!( - (atan2 - std::f64::consts::FRAC_PI_4).abs() < 1e-12, - "unexpected atan2 value: {atan2}" - ); - assert!((hypot - 5.0).abs() < 1e-12, "unexpected hypot value: {hypot}"); - assert_eq!(gcd, 6, "unexpected gcd value: {gcd}"); - assert_eq!(lcm, 24, "unexpected lcm value: {lcm}"); - } +async def main() -> None: + barrier = Barrier.new(2) - #[test] - fn test_std_math_numeric_like_helpers_run() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -import std.math + cancelled_ready = Mutex.new(0) + cancelled = spawn(wait_barrier(barrier, cancelled_ready)) + await wait_until_ready(cancelled_ready) + cancelled.abort() + match await cancelled: + Ok(slot) => println(f"unexpected_cancelled_slot:{slot}") + Err(err) => println(f"cancelled:{err.message()}") -def main() -> None: - assert math.is_int_like("0") - assert math.is_int_like("-123") - assert not math.is_int_like("1e3") - assert not math.is_int_like("01") + replacement_ready = Mutex.new(0) + replacement = spawn(wait_barrier(barrier, replacement_ready)) + await wait_until_ready(replacement_ready) + match await timeout_join_ms(5, replacement): + TimeoutJoinOutcome.Completed(slot) => println(f"unexpected_replacement_completed:{slot}") + TimeoutJoinOutcome.JoinFailed(err) => println(f"unexpected_replacement_failed:{err.message()}") + TimeoutJoinOutcome.TimedOut(handle) => + println("replacement_waiting") + current = await barrier.wait() + match await handle: + Ok(slot) => println(f"replacement_slot:{slot}") + Err(err) => println(f"unexpected_replacement_join_failed:{err.message()}") + println(f"current_slot:{current}") +"#; + std::fs::write(&source_path, source)?; - assert math.is_float_like("0.0") - assert math.is_float_like("-0.5") - assert math.is_float_like("1e3") - assert math.is_float_like("1.25E+10") - assert not math.is_float_like("1") - assert not math.is_float_like("+1") - assert not math.is_float_like("1e+") -"#, - ]) + let output = incan_command() + .args(["run", source_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( output.status.success(), - "std.math numeric-like helper run failed: status={:?}\nstdout={}\nstderr={}", + "incan run async barrier cancellation failed: status={:?} stderr={}", output.status, - String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("cancelled:task") && stdout.contains("was cancelled"), + "expected cancelled join output; got:\n{}", + stdout + ); + assert!( + stdout.contains("replacement_waiting"), + "expected replacement to keep waiting until another active participant arrived; got:\n{}", + stdout + ); + assert!( + stdout.contains("replacement_slot:") && stdout.contains("current_slot:"), + "expected both active participants to complete after the second arrival; got:\n{}", + stdout + ); + assert!( + !stdout.contains("unexpected_"), + "unexpected fallback branch output; got:\n{}", + stdout + ); + Ok(()) } #[test] - fn test_std_datetime_surface_runs_with_std_time_runtime_boundary() -> Result<(), Box> { - let runtime_source = std::fs::read_to_string("crates/incan_stdlib/stdlib/datetime/runtime.incn")?; - let mut civil_sources = Vec::new(); - civil_sources.push(std::fs::read_to_string( - "crates/incan_stdlib/stdlib/datetime/civil.incn", - )?); - for entry in std::fs::read_dir("crates/incan_stdlib/stdlib/datetime/civil")? { - let entry = entry?; - if entry.path().extension().is_some_and(|extension| extension == "incn") { - civil_sources.push(std::fs::read_to_string(entry.path())?); - } - } - let civil_source = civil_sources.join("\n"); + fn test_run_repro_model_traits() { + let Ok(output) = incan_command() + .args(["run", "tests/fixtures/repro_model_traits.incn"]) + // This should not require network access (workspace deps should already be available). + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; + assert!( - runtime_source.contains("from rust::std::time import") && !runtime_source.contains("@rust"), - "std.datetime runtime must use the Rust std::time boundary without raw @rust bodies" + output.status.success(), + "incan run repro_model_traits failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - !civil_source.contains("from rust::") && !civil_source.contains("@rust"), - "std.datetime civil calendar code must remain source-defined Incan" + stdout.contains("[Ada] hello"), + "expected repro output; got:\n{}", + stdout ); + } - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/std_datetime_surface.incn"]) + /// RFC 021: Runtime verification that __fields__() returns correct FieldInfo values + #[test] + fn test_run_field_info_reflection() { + let Ok(output) = incan_command() + .args(["run", "tests/fixtures/field_info_reflection.incn"]) .env("CARGO_NET_OFFLINE", "true") - .output()?; + .output() + else { + panic!("failed to run incan"); + }; assert!( output.status.success(), - "std.datetime surface run failed: status={:?} stderr={}", + "incan run field_info_reflection failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec![ - "500", - "2", - "9", - "true", - "true", - "true", - "2026-04-21", - "2026-07-14", - "true", - "2026-04-15T00:34:56.123456789", - "Tue Apr 14 2026", - "12:34:56.123456789", - "07:08:09.123456789", - "2026-04-14", - "2026-04-14T07:08:09.123456789", - "2026-04-14", - "53", - "bad-week", - "2026-04-15T12:34:56", - "true", - "1800", - "+01:00", - "Z", - "2026-04-14T12:34:56.123456789+01:00", - "2026-04-14T12:34:56.123456789+0100", - "2026-04-14 12:34:56.123456789+01:00", - "2026-04-14T12:34:56.123456789+01:00", - "2026-04-14T12:34:56Z", - "bad-offset", - "long-nanos", - "bad-date-digits", - "bad-time-digits", - "named-timezone", - ], - "unexpected std.datetime output: {stdout}" + + // Verify __class_name__ + assert!( + stdout.contains("Account"), + "expected __class_name__ to return 'Account'; got:\n{}", + stdout ); - Ok(()) - } - #[test] - fn test_std_compression_surface_runs_generated_project() -> Result<(), Box> { - // Keep std.compression's generated-project dependencies in the root Cargo graph so CI fetches them before this - // smoke runs the generated project under CARGO_NET_OFFLINE. - use std::io::{Cursor, Read as _}; + // Verify field info for type_ (has alias) + assert!( + stdout.contains("field:type_|wire:type|type:str|default:false"), + "expected type_ field info with alias='type'; got:\n{}", + stdout + ); - let sample = b"abc"; - let mut gzip = flate2::read::GzEncoder::new(Cursor::new(sample), flate2::Compression::new(6)); - let mut gzip_out = Vec::new(); - gzip.read_to_end(&mut gzip_out)?; - assert!(!gzip_out.is_empty()); + // Verify field info for balance (has default) + assert!( + stdout.contains("field:balance|wire:balance|type:int|default:true"), + "expected balance field info with default=true; got:\n{}", + stdout + ); - let zstd_out = zstd::stream::encode_all(Cursor::new(sample), 0)?; - assert!(!zstd_out.is_empty()); + // Verify field info for name (no alias, no default) + assert!( + stdout.contains("field:name|wire:name|type:str|default:false"), + "expected name field info; got:\n{}", + stdout + ); - let mut bz2 = bzip2::read::BzEncoder::new(Cursor::new(sample), bzip2::Compression::new(6)); - let mut bz2_out = Vec::new(); - bz2.read_to_end(&mut bz2_out)?; - assert!(!bz2_out.is_empty()); + // Empty models should produce no FieldInfo entries + assert!( + stdout.contains("empty_fields:0"), + "expected empty model to return 0 fields; got:\n{}", + stdout + ); - let mut lzma = xz2::read::XzEncoder::new(Cursor::new(sample), 6); - let mut lzma_out = Vec::new(); - lzma.read_to_end(&mut lzma_out)?; - assert!(!lzma_out.is_empty()); + // Nested generics should use Incan type formatting + assert!( + stdout.contains("settings_field:complex|type:list[dict[str, int]]"), + "expected nested generic type name; got:\n{}", + stdout + ); - let mut snappy = snap::raw::Encoder::new(); - assert!(!snappy.compress_vec(sample)?.is_empty()); + // User-defined field types should use their Incan type name + assert!( + stdout.contains("user_field:address|type:Address"), + "expected user-defined field type name; got:\n{}", + stdout + ); - let output = Command::new(incan_debug_binary()) - .args(["run", "tests/fixtures/valid/std_compression_surface.incn"]) + // Inherited class fields should appear in __fields__() + assert!( + stdout.contains("child_field:base_id|type:int"), + "expected inherited base field in __fields__; got:\n{}", + stdout + ); + assert!( + stdout.contains("child_field:name|type:str"), + "expected child field in __fields__; got:\n{}", + stdout + ); + } + + /// RFC 023: Runtime parity check for source-defined stdlib surfaces migrated off helper stubs. + #[test] + fn test_run_rfc023_stdlib_behavior_parity() { + let Ok(output) = incan_command() + .args(["run", "tests/fixtures/rfc023_stdlib_behavior_parity.incn"]) .env("CARGO_NET_OFFLINE", "true") - .output()?; + .output() + else { + panic!("failed to run incan"); + }; assert!( output.status.success(), - "std.compression surface run failed: status={:?} stderr={}", + "incan run rfc023_stdlib_behavior_parity failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); - assert_eq!( - lines, - vec![ - "gzip round trip ok", - "zlib round trip ok", - "deflate round trip ok", - "zstd round trip ok", - "bz2 round trip ok", - "lzma round trip ok", - "snappy round trip ok", - "snappy.raw round trip ok", - "autodetection ok", - "stream round trips ok", - "file stream round trip ok", - "option and chunk errors ok", - ], - "unexpected std.compression output: {stdout}" + assert!( + stdout.contains("{\"value\":1,\"player\":\"Ada\"}"), + "expected explicit Serialize adoption to preserve JSON output; got:\n{}", + stdout + ); + assert!( + stdout.contains("Score"), + "expected reflection class name output; got:\n{}", + stdout + ); + assert!( + stdout.contains("true\ntrue"), + "expected clone/equality and ordering behavior from derive-backed traits; got:\n{}", + stdout + ); + assert!( + stdout.contains("{\"value\":0,\"player\":\"\"}"), + "expected Default derive to preserve zero-value JSON output; got:\n{}", + stdout + ); + assert!( + stdout.contains("field:value|wire:value|type:int|default:true"), + "expected reflection metadata for value field; got:\n{}", + stdout + ); + assert!( + stdout.contains("field:player|wire:player|type:str|default:true"), + "expected reflection metadata for player field; got:\n{}", + stdout ); - Ok(()) } #[test] - fn test_rust_associated_call_in_elif_branch_uses_path_syntax() { - let Ok(output) = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#" -from rust::std::path import Path - -def f(kind: str, output_uri: str) -> bool: - if kind == "a": - return Path.new(output_uri).exists() - elif kind == "b": - return Path.new(output_uri).exists() - else: - return false - -def main() -> None: - println(f("a", "missing-a")) - println(f("b", "missing-b")) -"#, - ]) + fn test_run_rfc030_std_collections_behavior() { + let Ok(output) = incan_command() + .args(["run", "tests/fixtures/rfc030_std_collections_behavior.incn"]) .env("CARGO_NET_OFFLINE", "true") .output() else { @@ -7342,2243 +5936,2378 @@ def main() -> None: assert!( output.status.success(), - "rust associated call in elif branch failed: status={:?} stderr={}", + "incan run rfc030_std_collections_behavior failed: status={:?} stderr={}", output.status, String::from_utf8_lossy(&output.stderr) ); } -} - -/// End-to-end integration tests for `incan test`. -/// -/// These tests exercise the full pipeline: write an Incan test file → run `incan test` via the CLI → verify -/// stdout/stderr/exit code. They catch integration bugs like broken per-file `cargo test` harness wiring or parametrize -/// expansion that unit tests cannot detect. -mod test_runner_e2e { - use super::incan_debug_binary; - use std::path::Path; - use std::process::Command; - use std::sync::atomic::{AtomicU64, Ordering}; - - static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); - - struct TestProject { - dir: tempfile::TempDir, - } - - impl std::ops::Deref for TestProject { - type Target = Path; - - fn deref(&self) -> &Self::Target { - self.dir.path() - } - } - - /// Create a temp directory with a single test file and keep it alive for the test duration. - fn write_test_project(filename: &str, source: &str) -> TestProject { - let seq = TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed); - let prefix = format!("incan_e2e_test_{}_{}_", std::process::id(), seq); - let Ok(dir) = tempfile::Builder::new().prefix(&prefix).tempdir() else { - panic!("failed to create temp dir"); - }; - let Ok(()) = std::fs::write(dir.path().join(filename), source) else { - panic!("failed to write test file"); - }; - TestProject { dir } - } - /// Run `incan test` for the given path argument (file or directory). - fn run_incan_test_path(path: &Path) -> std::process::Output { - Command::new(incan_debug_binary()) - .args(["test", path.to_string_lossy().as_ref()]) + #[test] + fn test_run_rfc064_std_encoding_behavior() { + let Ok(output) = incan_command() + .args(["run", "tests/fixtures/rfc064_std_encoding_behavior.incn"]) .env("CARGO_NET_OFFLINE", "true") .output() - .unwrap_or_else(|e| panic!("failed to run `incan test`: {}", e)) - } - - /// Run `incan test` on a directory and return the combined output. - fn run_incan_test(dir: &Path) -> std::process::Output { - run_incan_test_path(dir) - } + else { + panic!("failed to run incan"); + }; - /// Run `incan test` with extra flags. - fn run_incan_test_with_args(dir: &Path, extra: &[&str]) -> std::process::Output { - let mut cmd = Command::new(incan_debug_binary()); - cmd.arg("test"); - for arg in extra { - cmd.arg(arg); - } - cmd.arg(dir.to_string_lossy().as_ref()); - cmd.env("CARGO_NET_OFFLINE", "true"); - cmd.output() - .unwrap_or_else(|e| panic!("failed to run `incan test`: {}", e)) + assert!( + output.status.success(), + "incan run rfc064_std_encoding_behavior failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("strict-padding-error") + && stdout.contains("bech32-checksum-error") + && stdout.contains("rfc064-encoding-ok"), + "expected strict error markers and success marker; got:\n{}", + stdout + ); } - /// Run `incan test` with `cwd` and a relative path argument. - fn run_incan_test_relative(cwd: &Path, relative_path: &str) -> std::process::Output { - Command::new(incan_debug_binary()) - .arg("test") - .arg(relative_path) + #[test] + fn test_run_std_uuid_surface() -> Result<(), Box> { + let output = incan_command() + .args(["run", "tests/fixtures/valid/std_uuid_surface.incn"]) .env("CARGO_NET_OFFLINE", "true") - .current_dir(cwd) - .output() - .unwrap_or_else(|e| panic!("failed to run `incan test {relative_path}`: {}", e)) - } + .output()?; - /// Run `incan build ` for an inline-test production source. - fn run_incan_build(entry: &Path, out_dir: &Path) -> std::process::Output { - let output = Command::new(incan_debug_binary()) - .args([ - "build", - entry.to_string_lossy().as_ref(), - out_dir.to_string_lossy().as_ref(), - ]) - .env("CARGO_NET_OFFLINE", "true") - .output(); - let Ok(output) = output else { - panic!("failed to run `incan build`"); - }; - output + assert!( + output.status.success(), + "incan run std_uuid_surface failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "std.uuid ok"); + Ok(()) } - // ---- Passing test ---- - #[test] - fn e2e_passing_test_succeeds() { - let dir = write_test_project( - "test_math.incn", - r#" -from std.testing import assert_eq + fn test_run_std_ordinal_map_surface() -> Result<(), Box> { + let output = incan_command() + .args(["run", "tests/fixtures/valid/std_ordinal_map_surface.incn"]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; -def test_addition() -> None: - assert_eq(1 + 1, 2) -"#, + assert!( + output.status.success(), + "incan run std_ordinal_map_surface failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "std.ordinal_map ok"); - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - + let generated_main = fs::read_to_string("target/incan/std_ordinal_map_surface/src/main.rs")?; assert!( - output.status.success(), - "expected passing test to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + generated_main.contains("__incan_ordinal_require_str("), + "OrdinalMap[str] literal lookup should lower through the borrowed string fast path:\n{generated_main}" ); + let generated_collections = + fs::read_to_string("target/incan/std_ordinal_map_surface/src/__incan_std/collections.rs")?; assert!( - stdout.contains("PASSED") || stdout.contains("passed"), - "expected PASSED in output.\nstdout:\n{}", - stdout, + generated_collections.contains("incan_stdlib::__incan_ordinal_map_string_fast_impls!();"), + "generated std.collections should splice in the stdlib-owned OrdinalMap string support:\n{generated_collections}" ); + Ok(()) } #[test] - fn e2e_two_tests_in_one_file_share_single_cargo_batch() { - let dir = write_test_project( - "test_pair.incn", - r#" -from std.testing import assert_eq + fn test_run_std_regex_rfc059_surface() -> Result<(), Box> { + let output = incan_command() + .args(["run", "tests/fixtures/valid/std_regex_surface.incn"]) + .output()?; -def test_one() -> None: - assert_eq(1, 1) + assert!( + output.status.success(), + "incan run std_regex_surface failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec![ + "true", + "xx@0:2", + "ALPHA-12", + "beta", + "beta", + "0:4", + "", + "", + "beta|", + "one,two", + "a:1,b:2", + "a|b|c", + "a|b,c", + "a|b,c", + "a|b|c", + "Lovelace, Ada", + "Lovelace/Ada", + "Lovelace, Ada", + "$2, $1", + "x x three", + "$1 two", + ], + "unexpected std.regex output:\n{stdout}" + ); + let generated_core = fs::read_to_string("target/incan/std_regex_surface/src/__incan_std/regex/_core.rs")?; + for unexpected in [ + "RegexBuilder::new(&(pattern).to_string())", + "raw.find(&(text).to_string())", + "raw.find_iter(&(text).to_string())", + "raw.captures(&(text).to_string())", + "raw.captures_iter(&(text).to_string())", + ] { + assert!( + !generated_core.contains(unexpected), + "std.regex should let the compiler borrow Incan strings for Rust regex APIs instead of cloning them:\n{generated_core}" + ); + } + for expected in [ + "RegexBuilder::new(&pattern)", + "raw.find(&text)", + "raw.find_iter(&text)", + "raw.captures(&text)", + "raw.captures_iter(&text)", + ] { + assert!( + generated_core.contains(expected), + "std.regex should preserve compiler-managed Rust borrow boundaries; missing `{expected}`:\n{generated_core}" + ); + } + Ok(()) + } + + #[test] + fn test_run_std_regex_unsupported_safe_engine_pattern_reports_error() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +from std.regex import Regex -def test_two() -> None: - assert_eq(2, 2) +def main() -> None: + match Regex("(?<=prefix)\\w+"): + Ok(_) => println("unexpected-ok") + Err(err) => + println("unsupported") + println(err.kind()) + println(err.message()) "#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + ]) + .output()?; assert!( output.status.success(), - "expected both tests to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + "std.regex unsupported-pattern program should report RegexError without failing the process: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); assert!( - stdout.contains("test_pair.incn::test_one") && stdout.contains("test_pair.incn::test_two"), - "expected each test name in reporter output.\nstdout:\n{}", - stdout, + stdout.contains("unsupported") && !stdout.contains("unexpected-ok"), + "expected safe-engine rejection branch, got:\n{stdout}" ); assert!( - stdout.match_indices("PASSED").count() >= 2, - "expected two passing results (per-test PASSED lines).\nstdout:\n{}", - stdout, + stdout.contains("compile_error"), + "expected stable RegexError kind, got:\n{stdout}" + ); + assert!( + stdout.to_ascii_lowercase().contains("look"), + "expected diagnostic to identify the unsupported lookaround boundary, got:\n{stdout}" ); + Ok(()) } #[test] - fn e2e_generated_harness_preheat_is_fingerprinted() { - let dir = write_test_project( - "test_preheat.incn", - r#" -from std.testing import assert_eq - -def test_preheat() -> None: - assert_eq(1, 1) -"#, - ); + fn test_run_u128_modulo_floor_div() -> Result<(), Box> { + let output = incan_command() + .args(["run", "tests/fixtures/valid/u128_modulo_floor_div.incn"]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; - let first = run_incan_test_with_args(&dir, &["-v"]); - let first_stdout = String::from_utf8_lossy(&first.stdout); - let first_stderr = String::from_utf8_lossy(&first.stderr); - assert!( - first.status.success(), - "expected first preheat run to succeed.\nstdout:\n{}\nstderr:\n{}", - first_stdout, - first_stderr, - ); assert!( - first_stdout.contains("preheat phase: ran"), - "expected first run to preheat stale harness.\nstdout:\n{}", - first_stdout, + output.status.success(), + "incan run u128_modulo_floor_div failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "u128 modulo ok"); + Ok(()) + } + + #[test] + fn test_run_rfc030_field_overlay_reflection() { + let Ok(output) = incan_command() + .args(["run", "tests/fixtures/rfc030_field_overlay_reflection.incn"]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; - let second = run_incan_test_with_args(&dir, &["-v"]); - let second_stdout = String::from_utf8_lossy(&second.stdout); - let second_stderr = String::from_utf8_lossy(&second.stderr); - assert!( - second.status.success(), - "expected second preheat run to succeed.\nstdout:\n{}\nstderr:\n{}", - second_stdout, - second_stderr, - ); assert!( - second_stdout.contains("preheat phase: up-to-date"), - "expected second run to reuse preheated harness.\nstdout:\n{}", - second_stdout, + output.status.success(), + "incan run rfc030_field_overlay_reflection failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); } #[test] - fn e2e_cross_file_batch_falls_back_when_top_level_names_collide() -> Result<(), Box> { - let dir = write_test_project( - "test_a.incn", - r#" -from std.testing import assert_eq + fn test_check_cyclic_explicit_call_site_generics_cross_module_succeeds() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_cycle_explicit_call_site_check"); + let main_path = super::write_cycle_explicit_call_site_generics_project(&project_dir)?; -model Order: - id: int + let output = incan_command() + .arg("--check") + .arg(main_path) + .env("CARGO_NET_OFFLINE", "true") + .output()?; -def test_a() -> None: - order = Order(id=1) - assert_eq(order.id, 1) -"#, + assert!( + output.status.success(), + "incan --check cyclic explicit call-site generics failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - std::fs::write( - dir.join("test_b.incn"), - r#" -from std.testing import assert_eq - -model Order: - id: int + Ok(()) + } -def test_b() -> None: - order = Order(id=2) - assert_eq(order.id, 2) -"#, - )?; + #[test] + fn test_run_cyclic_explicit_call_site_generics_cross_module_succeeds() -> Result<(), Box> { + let project_dir = make_temp_dir("incan_cycle_explicit_call_site_run"); + let main_path = super::write_cycle_explicit_call_site_generics_project(&project_dir)?; - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let output = incan_command() + .arg("run") + .arg(main_path) + .env("CARGO_NET_OFFLINE", "true") + .output()?; assert!( output.status.success(), - "expected same-named top-level declarations in different files to run in isolated harnesses.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + "incan run cyclic explicit call-site generics failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("test_a.incn::test_a") && stdout.contains("test_b.incn::test_b"), - "expected both tests in reporter output.\nstdout:\n{}", - stdout, + stdout.contains('1'), + "expected runtime output to contain 1, got:\n{}", + stdout ); Ok(()) } #[test] - fn e2e_imported_default_expression_expands_with_required_scope_issue395() -> Result<(), Box> - { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "default_expr_import_test_repro" -version = "0.1.0" -"#, + fn test_benchmark_quicksort_codegen_compiles() { + let path = Path::new("benchmarks/sorting/quicksort/quicksort.incn"); + if !path.exists() { + return; + } + + let Ok(source) = fs::read_to_string(path) else { + panic!("failed to read {}", path.display()); + }; + let Ok(tokens) = lexer::lex(&source) else { + panic!("lexing failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(()) = typechecker::check(&ast) else { + panic!("typecheck failed"); + }; + + let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { + panic!("codegen failed"); + }; + + // Regression: Vec::swap indices must be cast to usize. + let mut ok = true; + let mut search_from = 0usize; + while let Some(pos) = rust_code[search_from..].find(".swap(") { + let abs = search_from + pos; + let window_end = (abs + 120).min(rust_code.len()); + let window = &rust_code[abs..window_end]; + if !window.contains("as usize") { + ok = false; + break; + } + search_from = abs + 5; + } + assert!( + ok, + "expected quicksort to cast swap indices to usize; generated:\n{}", + rust_code ); - let src_dir = dir.join("src"); - let tests_dir = dir.join("tests"); - std::fs::create_dir_all(&src_dir)?; - std::fs::create_dir_all(&tests_dir)?; - std::fs::write( - src_dir.join("defaults.incn"), - r#" -pub def fallback() -> int: - return 2 -"#, - )?; - std::fs::write( - src_dir.join("helper.incn"), - r#" -from defaults import fallback -pub def combine(left: int, middle: int = fallback(), right: int = 3) -> int: - return left + middle + right -"#, - )?; - std::fs::write( - tests_dir.join("test_default_expr_import.incn"), - r#" -from std.testing import assert_eq -from helper import combine + // Note: This test uses standalone rustc compilation, which can't access incan_stdlib/incan_derive. + // Skip the compilation check if generated Rust references external Incan crates. + if rust_code.contains("incan_stdlib::") || rust_code.contains("incan_derive::") { + // Skip rustc compilation test for code that requires Incan support crates. + return; + } + + let Ok(()) = rustc_compile_ok(&rust_code) else { + panic!("generated quicksort Rust failed to compile"); + }; + } + + #[test] + fn test_const_declarations_compile_and_run() { + let Ok(output) = incan_command() + .args([ + "run", + "-c", + r#" +const PI: float = 3.14159 +const APP_NAME: str = "Incan" +const MAGIC: int = 42 +const ENABLED: bool = true +const RAW_DATA: bytes = b"\x00\x01\x02\x03" +const FROZEN_TEXT: FrozenStr = "frozen" +const NUMBERS: FrozenList[int] = [1, 2, 3, 4, 5] +const GREETING: str = "Hello World" -def test_imported_default_expression_expands_with_required_imports() -> None: - assert_eq(combine(left=1, right=4), 7, "default expression helper should be available after expansion") +def main() -> None: + print(PI) + print(APP_NAME) + print(MAGIC) + print(ENABLED) + print(RAW_DATA.len()) + print(FROZEN_TEXT.len()) + print(NUMBERS.len()) + print(GREETING) "#, - )?; - - let output = run_incan_test_relative(&dir, "tests"); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + ]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; assert!( output.status.success(), - "expected imported default expression test to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!( - stdout.contains( - "test_default_expr_import.incn::test_imported_default_expression_expands_with_required_imports" - ), - "expected issue 395 test name in reporter output.\nstdout:\n{}", - stdout, + "const declarations test failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - Ok(()) + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("3.14159"), "PI const not emitted correctly"); + assert!(stdout.contains("Incan"), "APP_NAME const not emitted correctly"); + assert!(stdout.contains("42"), "MAGIC const not emitted correctly"); + assert!(stdout.contains("true"), "ENABLED const not emitted correctly"); + assert!(stdout.contains("4"), "RAW_DATA length incorrect"); + assert!(stdout.contains("6"), "FROZEN_TEXT length incorrect"); + assert!(stdout.contains("5"), "NUMBERS length incorrect"); + assert!(stdout.contains("Hello World"), "GREETING concat not working"); } #[test] - fn e2e_explicit_test_decorator_discovers_non_prefixed_function() { - let dir = write_test_project( - "test_decorator.incn", - r#" -from std.testing import assert_eq, test + fn test_const_str_materializes_to_owned_str_at_runtime_sites() { + let Ok(output) = incan_command() + .args([ + "run", + "-c", + r#" +const PREFIX: str = "target/" -@test -def verifies_total() -> None: - assert_eq(40 + 2, 42) -"#, - ); +def echo(value: str) -> str: + return value - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); +def direct() -> str: + return PREFIX + +def join(name: str) -> str: + return PREFIX + name + +def main() -> None: + local = PREFIX + println(direct()) + println(echo(PREFIX)) + println(echo(local)) + println(join("orders.csv")) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; assert!( output.status.success(), - "expected @test-decorated function to run.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!( - stdout.contains("test_decorator.incn::verifies_total"), - "expected decorated test id in output.\nstdout:\n{}", - stdout, + "const str materialization test failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["target/", "target/", "target/", "target/orders.csv"]); } #[test] - fn e2e_list_and_keyword_filter_use_stable_test_ids() { - let dir = write_test_project( - "test_list_filter.incn", - r#" -from std.testing import assert_eq + fn test_rfc041_rusttype_interop_typechecks_end_to_end() { + let source = r#" +from rust::std::string import String as RustString -def test_alpha() -> None: - assert_eq(1, 1) +type Name = rusttype RustString: + def parse(raw: str) -> Result[Name, str]: + ... -def test_beta() -> None: - assert_eq(2, 2) -"#, - ); + def as_str(self) -> str: + ... - let output = run_incan_test_with_args(&dir, &["--list", "-k", "test_beta"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + interop: + from str try Name.parse + into str via Name.as_str - assert!( - output.status.success(), - "expected --list -k run to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!( - stdout.lines().any(|line| line == "test_list_filter.incn::test_beta"), - "expected exact listed beta id rooted at the explicit test directory.\nstdout:\n{}", - stdout, - ); - assert!( - !stdout.contains(dir.to_string_lossy().as_ref()), - "expected --list output to avoid machine-local absolute paths.\nstdout:\n{}", - stdout, - ); - assert!( - !stdout.contains("test_list_filter.incn::test_alpha"), - "expected keyword filter to hide alpha.\nstdout:\n{}", - stdout, - ); +def main() -> None: + pass +"#; + let Ok(()) = super::compile_source(source) else { + panic!("expected RFC 041 rusttype/interop source to typecheck"); + }; } #[test] - fn e2e_json_format_emits_result_records() -> Result<(), Box> { - let dir = write_test_project( - "test_json_report.incn", - r#" -from std.testing import assert_eq + fn test_rfc041_rusttype_with_methods_typechecks() { + let source = r#" +from rust::mail import Sender as RustSender -def test_json_one() -> None: - assert_eq(1, 1) -"#, - ); +type Sender = rusttype RustSender: + send_now = try_send - let output = run_incan_test_with_args(&dir, &["--format", "json", "--shuffle", "--seed", "7"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + def try_send(self, value: int) -> Result[None, str]: + ... - assert!( - output.status.success(), - "expected JSON-format run to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); +def push(sender: Sender, value: int) -> Result[None, str]: + return sender.send_now(value) - let mut saw_result = false; - let mut saw_summary = false; - for line in stdout.lines().filter(|line| !line.trim().is_empty()) { - let value: serde_json::Value = serde_json::from_str(line)?; - if value.get("test_id").is_some() { - saw_result = true; - assert_eq!( - value.get("schema_version").and_then(|v| v.as_str()), - Some("incan.test.v1") - ); - assert_eq!( - value.get("test_id").and_then(|v| v.as_str()), - Some("test_json_report.incn::test_json_one") - ); - assert_eq!(value.get("status").and_then(|v| v.as_str()), Some("passed")); - } - if value.get("summary").is_some() { - saw_summary = true; - assert_eq!( - value - .get("summary") - .and_then(|summary| summary.get("shuffle_seed")) - .and_then(|v| v.as_u64()), - Some(7) - ); - } - } +def main() -> None: + pass +"#; + let Ok(()) = super::compile_source(source) else { + panic!("expected RFC 041 rusttype method surface to typecheck"); + }; + } + + #[test] + fn test_rfc041_rust_coercion_codegen_smoke() { + let source = r#" +from rust::std::time import Duration +def main() -> None: + _ = Duration.from_secs_f32(1.5) +"#; + let Ok(tokens) = lexer::lex(source) else { + panic!("lexing failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(()) = typechecker::check(&ast) else { + panic!("typecheck failed"); + }; + let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { + panic!("codegen failed"); + }; assert!( - saw_result, - "expected at least one JSON result record.\nstdout:\n{}", - stdout + rust_code.contains("Duration::from_secs_f32"), + "expected RFC 041 coercion fixture to lower to Duration::from_secs_f32 call, got:\n{rust_code}" ); - assert!(saw_summary, "expected a JSON summary record.\nstdout:\n{}", stdout); - Ok(()) } #[test] - fn e2e_junit_report_writes_testcase_xml() { - let dir = write_test_project( - "test_junit_report.incn", - r#" -from std.testing import assert_eq - -def test_junit_one() -> None: - assert_eq(1, 1) -"#, + fn test_rfc041_structural_coercion_codegen_smoke() { + let source = r#" +def main() -> None: + maybe: Option[int] = Some(1) + names: List[str] = ["a", "b"] + scores: Dict[str, float] = {"latency": 1.5} +"#; + let Ok(tokens) = lexer::lex(source) else { + panic!("lexing failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(()) = typechecker::check(&ast) else { + panic!("typecheck failed"); + }; + let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { + panic!("codegen failed"); + }; + assert!( + rust_code.contains("let _maybe: Option = Some(1);"), + "expected Option[int] smoke value to lower to a Rust Option expression; got:\n{rust_code}" ); - let report = dir.join("reports").join("junit.xml"); - let report_arg = report.to_string_lossy().to_string(); - let output = run_incan_test_with_args(&dir, &["--junit", report_arg.as_str()]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected JUnit report run to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + rust_code.contains("let _names: Vec = vec![\"a\".to_string(), \"b\".to_string()];"), + "expected List[str] smoke value to lower to an owned Rust string vec; got:\n{rust_code}" ); - let Ok(xml) = std::fs::read_to_string(&report) else { - panic!("failed to read {}", report.display()); - }; assert!( - xml.contains(">()"), + "expected Dict[str, float] smoke value to lower to a Rust HashMap collect; got:\n{rust_code}" ); } #[test] - fn e2e_run_xfail_treats_xfail_as_ordinary_test() { - let dir = write_test_project( - "test_run_xfail.incn", - r#" -from std.testing import assert_eq, xfail - -@xfail("currently passes") -def test_xpass() -> None: - assert_eq(1, 1) -"#, + fn test_rfc009_numeric_resize_and_decimal_codegen_smoke() { + let source = r#" +def main() -> None: + small: i8 = 120 + wide: int = small.resize() + maybe: Option[i8] = wide.try_resize() + wrapped: i8 = wide.wrapping_resize() + capped: i8 = wide.saturating_resize() + price: decimal[5, 2] = 19.99d +"#; + let Ok(tokens) = lexer::lex(source) else { + panic!("lexing failed"); + }; + let Ok(ast) = parser::parse(&tokens) else { + panic!("parse failed"); + }; + let Ok(()) = typechecker::check(&ast) else { + panic!("typecheck failed"); + }; + let Ok(rust_code) = IrCodegen::new().try_generate(&ast) else { + panic!("codegen failed"); + }; + assert!( + rust_code.contains("let wide: i64 = (small) as i64;"), + "expected lossless resize to emit a Rust cast, got:\n{rust_code}" ); - - let default = run_incan_test(&dir); - let default_stdout = String::from_utf8_lossy(&default.stdout); - let default_stderr = String::from_utf8_lossy(&default.stderr); assert!( - !default.status.success(), - "expected default xpass to fail.\nstdout:\n{}\nstderr:\n{}", - default_stdout, - default_stderr, + rust_code.contains("incan_stdlib::num::try_resize::<_, i8>(wide)"), + "expected try_resize to call stdlib checked resize helper, got:\n{rust_code}" ); - - let run_xfail = run_incan_test_with_args(&dir, &["--run-xfail"]); - let stdout = String::from_utf8_lossy(&run_xfail.stdout); - let stderr = String::from_utf8_lossy(&run_xfail.stderr); assert!( - run_xfail.status.success(), - "expected --run-xfail to treat xfail marker as ordinary.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + rust_code.contains("incan_stdlib::num::saturating_resize::<_, i8>(wide)"), + "expected saturating_resize to call stdlib saturating helper, got:\n{rust_code}" ); assert!( - stdout.contains("test_run_xfail.incn::test_xpass") && stdout.contains("PASSED"), - "expected ordinary passing output.\nstdout:\n{}", - stdout, + rust_code.contains("let _price: incan_stdlib::num::Decimal128") + && rust_code.contains("Decimal128::from_literal") + && rust_code.contains("\"19.99d\""), + "expected decimal annotation/literal to lower to Decimal128, got:\n{rust_code}" ); } #[test] - fn e2e_conftest_fixture_is_visible_to_nested_tests() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "conftest_fixture" -version = "0.1.0" -"#, - ); - let tests_dir = dir.join("tests").join("unit"); - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); - } - if let Err(err) = std::fs::write( - dir.join("tests").join("conftest.incn"), - r#" -from std.testing import fixture - -@fixture -def answer() -> int: - return 42 -"#, - ) { - panic!("failed to write conftest: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_answer.incn"), - r#" -from std.testing import assert_eq - -def test_answer(answer: int) -> None: - assert_eq(answer, 42) + fn test_mixed_numeric_codegen_runs() { + let Ok(output) = incan_command() + .args([ + "run", + "-c", + r#" +def main() -> None: + size: int = 2 + x: float = 3.0 + result = 2.0 * x / size + println(result) "#, - ) { - panic!("failed to write nested test: {}", err); - } + ]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; - let output = run_incan_test_relative(&dir, "tests"); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "expected conftest fixture injection to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + "mixed numeric run failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); + + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("test_answer.incn::test_answer"), - "expected nested stable id in output.\nstdout:\n{}", - stdout, + stdout.contains('3'), + "mixed numeric output missing expected result; stdout={}", + stdout ); } #[test] - fn e2e_nested_test_root_uses_same_conftest_boundary_for_collection_and_execution() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "nested_conftest_boundary" -version = "0.1.0" -"#, - ); - let tests_dir = dir.join("tests"); - let unit_dir = tests_dir.join("unit"); - if let Err(err) = std::fs::create_dir_all(&unit_dir) { - panic!("failed to create nested tests dir: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("conftest.incn"), + fn test_std_async_race_and_race_for_surfaces_share_one_run() { + let output = run_incan_source( r#" -from std.testing import fixture +import std.async +from std.async.race import arm, race +from std.async.time import sleep -@fixture -def answer() -> int: +def label(value: int) -> str: + return f"win:{value}" + +async def fast() -> int: return 1 -"#, - ) { - panic!("failed to write parent conftest: {}", err); - } - if let Err(err) = std::fs::write( - unit_dir.join("conftest.incn"), - r#" -from std.testing import fixture -@fixture -def answer() -> int: +async def slow() -> int: + await sleep(0.01) return 2 -"#, - ) { - panic!("failed to write nested conftest: {}", err); - } - if let Err(err) = std::fs::write( - unit_dir.join("test_value.incn"), - r#" -from std.testing import assert_eq - -def test_answer(answer: int) -> None: - assert_eq(answer, 2) -"#, - ) { - panic!("failed to write nested conftest test: {}", err); - } - let output = run_incan_test(&unit_dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected nested root run to use only root-bounded conftest sources.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - } +async def first() -> int: + return 1 - #[test] - fn e2e_nested_conftest_fixture_overrides_parent_fixture() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "nested_conftest_precedence" -version = "0.1.0" -"#, - ); - let tests_dir = dir.join("tests"); - let unit_dir = tests_dir.join("unit"); - if let Err(err) = std::fs::create_dir_all(&unit_dir) { - panic!("failed to create nested tests dir: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("conftest.incn"), - r#" -from std.testing import fixture +async def second() -> int: + return 2 -@fixture -def shared() -> str: - return "parent" -"#, - ) { - panic!("failed to write parent conftest: {}", err); - } - if let Err(err) = std::fs::write( - unit_dir.join("conftest.incn"), - r#" -from std.testing import fixture +async def run_race_for_first() -> str: + prefix = "win" + return race for value: + await slow() => f"{prefix}:{value}" + await fast() => f"{prefix}:{value}" -@fixture -def shared() -> str: - return "child" -"#, - ) { - panic!("failed to write nested conftest: {}", err); - } - if let Err(err) = std::fs::write( - unit_dir.join("test_precedence.incn"), - r#" -from std.testing import assert_eq +async def run_race_for_tie() -> int: + return race for value: + await first() => value + await second() => value -def test_uses_nearest_fixture(shared: str) -> None: - assert_eq(shared, "child") +async def main() -> None: + println(await race(arm(slow(), label), arm(fast(), label))) + println(await race(arm(first(), label), arm(second(), label))) + println(await run_race_for_first()) + println(await run_race_for_tie()) "#, - ) { - panic!("failed to write nested conftest test: {}", err); - } - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + ); assert!( output.status.success(), - "expected nearest conftest fixture to override parent fixture without duplicate generated functions.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr + "std.async race surface batch failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + assert_eq!( + stdout.lines().map(str::trim).collect::>(), + vec!["win:1", "win:1", "win:1", "1"], + "unexpected stdout:\n{stdout}" ); - assert!(stdout.contains("test_uses_nearest_fixture")); } - #[test] - fn e2e_builtin_tmp_path_fixture_is_injected() { - let dir = write_test_project( - "test_tmp_path.incn", - r#" -from std.testing import assert_eq -from rust::std::path import PathBuf + #[test] + fn test_std_math_surface_runs() { + let Ok(output) = incan_command() + .args([ + "run", + "-c", + r#" +import std.math + +def main() -> None: + println(math.PI) + println(math.round(1.6)) + println(math.log2(8.0)) + println(math.atan2(1.0, 1.0)) + println(math.hypot(3.0, 4.0)) + println(math.gcd(54, 24)) + println(math.lcm(6, 8)) + + assert math.is_int_like("0") + assert math.is_int_like("-123") + assert not math.is_int_like("1e3") + assert not math.is_int_like("01") -def test_tmp_path_fixture(tmp_path: PathBuf) -> None: - assert_eq(tmp_path.exists(), true) + assert math.is_float_like("0.0") + assert math.is_float_like("-0.5") + assert math.is_float_like("1e3") + assert math.is_float_like("1.25E+10") + assert not math.is_float_like("1") + assert not math.is_float_like("+1") + assert not math.is_float_like("1e+") "#, - ); + ]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "expected built-in tmp_path fixture to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + "std.math module run failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - } - - #[test] - fn e2e_std_testing_assert_helper_is_normalized_before_codegen() { - let dir = write_test_project( - "test_assert_helper.incn", - r#" -import std.testing as testing -def test_assert_helper() -> None: - testing.assert(True) -"#, + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines.len(), + 7, + "expected 7 output lines (PI/round/log2/atan2/hypot/gcd/lcm); got: {stdout}" ); - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let Ok(pi) = lines[0].parse::() else { + panic!("PI output was not a float: `{}`", lines[0]); + }; + let Ok(round) = lines[1].parse::() else { + panic!("round output was not a float: `{}`", lines[1]); + }; + let Ok(log2) = lines[2].parse::() else { + panic!("log2 output was not a float: `{}`", lines[2]); + }; + let Ok(atan2) = lines[3].parse::() else { + panic!("atan2 output was not a float: `{}`", lines[3]); + }; + let Ok(hypot) = lines[4].parse::() else { + panic!("hypot output was not a float: `{}`", lines[4]); + }; + let Ok(gcd) = lines[5].parse::() else { + panic!("gcd output was not an int: `{}`", lines[5]); + }; + let Ok(lcm) = lines[6].parse::() else { + panic!("lcm output was not an int: `{}`", lines[6]); + }; + + assert!((pi - std::f64::consts::PI).abs() < 1e-12, "unexpected PI value: {pi}"); + assert!((round - 2.0).abs() < 1e-12, "unexpected round value: {round}"); + assert!((log2 - 3.0).abs() < 1e-12, "unexpected log2 value: {log2}"); assert!( - output.status.success(), - "expected one-argument std.testing.assert call to run without generated Rust string rewriting.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr + (atan2 - std::f64::consts::FRAC_PI_4).abs() < 1e-12, + "unexpected atan2 value: {atan2}" ); - assert!(stdout.contains("test_assert_helper")); + assert!((hypot - 5.0).abs() < 1e-12, "unexpected hypot value: {hypot}"); + assert_eq!(gcd, 6, "unexpected gcd value: {gcd}"); + assert_eq!(lcm, 24, "unexpected lcm value: {lcm}"); } #[test] - fn e2e_marker_expr_and_strict_markers_use_conftest_registry() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "strict_markers" -version = "0.1.0" -"#, - ); - let tests_dir = dir.join("tests"); - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("conftest.incn"), - r#" -const TEST_MARKERS: List[str] = ["smoke"] -const TEST_MARKS: List[str] = ["smoke"] -"#, - ) { - panic!("failed to write conftest: {}", err); + fn test_std_datetime_surface_runs_with_std_time_runtime_boundary() -> Result<(), Box> { + let runtime_source = std::fs::read_to_string("crates/incan_stdlib/stdlib/datetime/runtime.incn")?; + let mut civil_sources = Vec::new(); + civil_sources.push(std::fs::read_to_string( + "crates/incan_stdlib/stdlib/datetime/civil.incn", + )?); + for entry in std::fs::read_dir("crates/incan_stdlib/stdlib/datetime/civil")? { + let entry = entry?; + if entry.path().extension().is_some_and(|extension| extension == "incn") { + civil_sources.push(std::fs::read_to_string(entry.path())?); + } } - if let Err(err) = std::fs::write( - tests_dir.join("test_markers.incn"), - r#" -from std.testing import assert_eq - -def test_inherited_smoke() -> None: - assert_eq(1, 1) + let civil_source = civil_sources.join("\n"); + assert!( + runtime_source.contains("from rust::std::time import") && !runtime_source.contains("@rust"), + "std.datetime runtime must use the Rust std::time boundary without raw @rust bodies" + ); + assert!( + !civil_source.contains("from rust::") && !civil_source.contains("@rust"), + "std.datetime civil calendar code must remain source-defined Incan" + ); -def test_other() -> None: - assert_eq(1, 1) -"#, - ) { - panic!("failed to write marker test: {}", err); - } + let output = incan_command() + .args(["run", "tests/fixtures/valid/std_datetime_surface.incn"]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; - let listed = run_incan_test_with_args(&tests_dir, &["--list", "-m", "smoke", "--strict-markers"]); - let stdout = String::from_utf8_lossy(&listed.stdout); - let stderr = String::from_utf8_lossy(&listed.stderr); assert!( - listed.status.success(), - "expected strict registered marker list to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + output.status.success(), + "std.datetime surface run failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - assert!(stdout.contains("test_markers.incn::test_inherited_smoke")); - let strict_error = run_incan_test_with_args(&tests_dir, &["--list", "-m", "missing", "--strict-markers"]); - let strict_stdout = String::from_utf8_lossy(&strict_error.stdout); - let strict_stderr = String::from_utf8_lossy(&strict_error.stderr); - assert!( - !strict_error.status.success(), - "expected unknown strict marker to fail.\nstdout:\n{}\nstderr:\n{}", - strict_stdout, - strict_stderr, + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec![ + "500", + "2", + "9", + "true", + "true", + "true", + "2026-04-21", + "2026-07-14", + "true", + "2026-04-15T00:34:56.123456789", + "Tue Apr 14 2026", + "12:34:56.123456789", + "07:08:09.123456789", + "2026-04-14", + "2026-04-14T07:08:09.123456789", + "2026-04-14", + "53", + "bad-week", + "2026-04-15T12:34:56", + "true", + "1800", + "+01:00", + "Z", + "2026-04-14T12:34:56.123456789+01:00", + "2026-04-14T12:34:56.123456789+0100", + "2026-04-14 12:34:56.123456789+01:00", + "2026-04-14T12:34:56.123456789+01:00", + "2026-04-14T12:34:56Z", + "bad-offset", + "long-nanos", + "bad-date-digits", + "bad-time-digits", + "named-timezone", + ], + "unexpected std.datetime output: {stdout}" ); - assert!(strict_stderr.contains("unknown marker `missing`")); + Ok(()) } #[test] - fn e2e_marker_expr_boolean_grammar_filters_tests() -> Result<(), Box> { - let dir = write_test_project( - "test_marker_expr.incn", - r#" -from std.testing import assert_eq, mark, slow + fn test_std_compression_surface_runs_generated_project() -> Result<(), Box> { + // Keep std.compression's generated-project dependencies in the root Cargo graph so CI fetches them before this + // smoke runs the generated project under CARGO_NET_OFFLINE. + use std::io::{Cursor, Read as _}; -const TEST_MARKERS: List[str] = ["api", "db"] + let sample = b"abc"; + let mut gzip = flate2::read::GzEncoder::new(Cursor::new(sample), flate2::Compression::new(6)); + let mut gzip_out = Vec::new(); + gzip.read_to_end(&mut gzip_out)?; + assert!(!gzip_out.is_empty()); -@mark("api") -def test_api() -> None: - assert_eq(1, 1) + let zstd_out = zstd::stream::encode_all(Cursor::new(sample), 0)?; + assert!(!zstd_out.is_empty()); -@mark("api") -@slow -def test_api_slow() -> None: - assert_eq(1, 1) + let mut bz2 = bzip2::read::BzEncoder::new(Cursor::new(sample), bzip2::Compression::new(6)); + let mut bz2_out = Vec::new(); + bz2.read_to_end(&mut bz2_out)?; + assert!(!bz2_out.is_empty()); -@mark("db") -def test_db() -> None: - assert_eq(1, 1) -"#, - ); + let mut lzma = xz2::read::XzEncoder::new(Cursor::new(sample), 6); + let mut lzma_out = Vec::new(); + lzma.read_to_end(&mut lzma_out)?; + assert!(!lzma_out.is_empty()); + + let mut snappy = snap::raw::Encoder::new(); + assert!(!snappy.compress_vec(sample)?.is_empty()); + + let output = incan_command() + .args(["run", "tests/fixtures/valid/std_compression_surface.incn"]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; - let output = run_incan_test_with_args( - &dir, - &["--list", "-m", "api and not slow", "--strict-markers", "--slow"], - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "expected boolean marker expression to collect.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + "std.compression surface run failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - assert!(stdout.contains("test_marker_expr.incn::test_api")); - assert!(!stdout.contains("test_marker_expr.incn::test_api_slow")); - assert!(!stdout.contains("test_marker_expr.incn::test_db")); - let invalid = run_incan_test_with_args(&dir, &["--list", "-m", "api and ("]); - let invalid_stderr = String::from_utf8_lossy(&invalid.stderr); - assert!( - !invalid.status.success(), - "expected invalid marker expression to fail.\nstderr:\n{}", - invalid_stderr, + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines, + vec![ + "gzip round trip ok", + "zlib round trip ok", + "deflate round trip ok", + "zstd round trip ok", + "bz2 round trip ok", + "lzma round trip ok", + "snappy round trip ok", + "snappy.raw round trip ok", + "autodetection ok", + "stream round trips ok", + "file stream round trip ok", + "option and chunk errors ok", + ], + "unexpected std.compression output: {stdout}" ); - assert!(invalid_stderr.contains("expected marker name or parenthesized expression")); Ok(()) } #[test] - fn e2e_slow_marker_is_excluded_by_default_and_included_with_flag() { - let dir = write_test_project( - "test_slow_filter.incn", - r#" -from std.testing import assert_eq, slow + fn test_rust_associated_call_in_elif_branch_uses_path_syntax() { + let Ok(output) = incan_command() + .args([ + "run", + "-c", + r#" +from rust::std::path import Path -def test_fast() -> None: - assert_eq(1, 1) +def f(kind: str, output_uri: str) -> bool: + if kind == "a": + return Path.new(output_uri).exists() + elif kind == "b": + return Path.new(output_uri).exists() + else: + return false -@slow -def test_slow_case() -> None: - assert_eq(1, 1) +def main() -> None: + println(f("a", "missing-a")) + println(f("b", "missing-b")) "#, - ); - - let default_list = run_incan_test_with_args(&dir, &["--list"]); - let default_stdout = String::from_utf8_lossy(&default_list.stdout); - assert!( - default_list.status.success(), - "expected default list to succeed.\nstdout:\n{}", - default_stdout, - ); - assert!(default_stdout.contains("test_slow_filter.incn::test_fast")); - assert!(!default_stdout.contains("test_slow_filter.incn::test_slow_case")); + ]) + .env("CARGO_NET_OFFLINE", "true") + .output() + else { + panic!("failed to run incan"); + }; - let slow_list = run_incan_test_with_args(&dir, &["--list", "--slow"]); - let slow_stdout = String::from_utf8_lossy(&slow_list.stdout); assert!( - slow_list.status.success(), - "expected --slow list to succeed.\nstdout:\n{}", - slow_stdout, + output.status.success(), + "rust associated call in elif branch failed: status={:?} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) ); - assert!(slow_stdout.contains("test_slow_filter.incn::test_fast")); - assert!(slow_stdout.contains("test_slow_filter.incn::test_slow_case")); } +} - #[test] - fn e2e_parametrize_case_ids_and_marks_affect_collection() { - let dir = write_test_project( - "test_case_ids.incn", - r#" -from std.testing import assert_eq, param_case, parametrize, xfail - -@parametrize("x, expected", [ - param_case((1, 3), marks=[xfail("known")], id="one-three"), - (2, 4), -], ids=["ignored", "two-four"]) -def test_double(x: int, expected: int) -> None: - assert_eq(x * 2, expected) -"#, - ); +/// End-to-end integration tests for `incan test`. +/// +/// These tests exercise the full pipeline: write an Incan test file → run `incan test` via the CLI → verify +/// stdout/stderr/exit code. They catch integration bugs like broken per-file `cargo test` harness wiring or parametrize +/// expansion that unit tests cannot detect. +mod test_runner_e2e { + use super::incan_command; + use std::path::Path; + use std::sync::atomic::{AtomicU64, Ordering}; - let listed = run_incan_test_with_args(&dir, &["--list"]); - let stdout = String::from_utf8_lossy(&listed.stdout); - let stderr = String::from_utf8_lossy(&listed.stderr); - assert!( - listed.status.success(), - "expected parametrized list to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stdout.contains("test_case_ids.incn::test_double[one-three]")); - assert!(stdout.contains("test_case_ids.incn::test_double[two-four]")); + static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); - let run = run_incan_test(&dir); - let run_stdout = String::from_utf8_lossy(&run.stdout); - let run_stderr = String::from_utf8_lossy(&run.stderr); - assert!( - run.status.success(), - "expected xfailed case and passing case to make the run succeed.\nstdout:\n{}\nstderr:\n{}", - run_stdout, - run_stderr, - ); - assert!(run_stdout.contains("xfailed") || run_stdout.contains("XFAIL")); + struct TestProject { + dir: tempfile::TempDir, } - #[test] - fn e2e_stacked_parametrize_lists_cartesian_product_ids() { - let dir = write_test_project( - "test_parametrize_product.incn", - r#" -from std.testing import assert_eq, parametrize + impl std::ops::Deref for TestProject { + type Target = Path; -@parametrize("x", [1, 2], ids=["one", "two"]) -@parametrize("y", [10, 20], ids=["ten", "twenty"]) -def test_pair(x: int, y: int) -> None: - assert_eq(x < y, true) -"#, - ); + fn deref(&self) -> &Self::Target { + self.dir.path() + } + } - let listed = run_incan_test_with_args(&dir, &["--list"]); - let stdout = String::from_utf8_lossy(&listed.stdout); - let stderr = String::from_utf8_lossy(&listed.stderr); - assert!( - listed.status.success(), - "expected stacked parametrized list to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stdout.contains("test_parametrize_product.incn::test_pair[one-ten]")); - assert!(stdout.contains("test_parametrize_product.incn::test_pair[one-twenty]")); - assert!(stdout.contains("test_parametrize_product.incn::test_pair[two-ten]")); - assert!(stdout.contains("test_parametrize_product.incn::test_pair[two-twenty]")); + /// Create a temp directory with a single test file and keep it alive for the test duration. + fn write_test_project(filename: &str, source: &str) -> TestProject { + let seq = TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed); + let prefix = format!("incan_e2e_test_{}_{}_", std::process::id(), seq); + let Ok(dir) = tempfile::Builder::new().prefix(&prefix).tempdir() else { + panic!("failed to create temp dir"); + }; + let Ok(()) = std::fs::write(dir.path().join(filename), source) else { + panic!("failed to write test file"); + }; + TestProject { dir } } - #[test] - fn e2e_parametrize_arity_mismatch_is_collection_error() { - let dir = write_test_project( - "test_parametrize_arity.incn", - r#" -from std.testing import parametrize + /// Run `incan test` for the given path argument (file or directory). + fn run_incan_test_path(path: &Path) -> std::process::Output { + incan_command() + .args(["test", path.to_string_lossy().as_ref()]) + .env("CARGO_NET_OFFLINE", "true") + .env("INCAN_TEST_SHARED_TARGET_DIR", shared_test_runner_target_dir()) + .output() + .unwrap_or_else(|e| panic!("failed to run `incan test`: {}", e)) + } -@parametrize("x, y", [1]) -def test_bad_case(x: int, y: int) -> None: - pass -"#, - ); + fn shared_test_runner_target_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("incan_e2e_shared_target") + } - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !output.status.success(), - "expected arity mismatch to fail during collection.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stderr.contains("parametrize case `1`")); - assert!(stderr.contains("expected 2 value(s)")); + /// Run `incan test` on a directory and return the combined output. + fn run_incan_test(dir: &Path) -> std::process::Output { + run_incan_test_path(dir) } - #[test] - fn e2e_timeout_marks_slow_test_failed() { - let dir = write_test_project( - "test_timeout.incn", - r#" -from rust::std::thread import sleep -from rust::std::time import Duration + /// Run `incan test` with extra flags. + fn run_incan_test_with_args(dir: &Path, extra: &[&str]) -> std::process::Output { + let mut cmd = incan_command(); + cmd.arg("test"); + for arg in extra { + cmd.arg(arg); + } + cmd.arg(dir.to_string_lossy().as_ref()); + cmd.env("CARGO_NET_OFFLINE", "true"); + cmd.env("INCAN_TEST_SHARED_TARGET_DIR", shared_test_runner_target_dir()); + cmd.output() + .unwrap_or_else(|e| panic!("failed to run `incan test`: {}", e)) + } -def test_slow() -> None: - sleep(Duration.from_millis(100)) -"#, - ); + /// Run `incan test` with `cwd` and a relative path argument. + fn run_incan_test_relative(cwd: &Path, relative_path: &str) -> std::process::Output { + incan_command() + .arg("test") + .arg(relative_path) + .env("CARGO_NET_OFFLINE", "true") + .env("INCAN_TEST_SHARED_TARGET_DIR", shared_test_runner_target_dir()) + .current_dir(cwd) + .output() + .unwrap_or_else(|e| panic!("failed to run `incan test {relative_path}`: {}", e)) + } - let output = run_incan_test_with_args(&dir, &["--timeout", "1ms"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !output.status.success(), - "expected timeout run to fail.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stdout.contains("timed out after")); + /// Run `incan build ` for an inline-test production source. + fn run_incan_build(entry: &Path, out_dir: &Path) -> std::process::Output { + let output = incan_command() + .args([ + "build", + entry.to_string_lossy().as_ref(), + out_dir.to_string_lossy().as_ref(), + ]) + .env("CARGO_NET_OFFLINE", "true") + .output(); + let Ok(output) = output else { + panic!("failed to run `incan build`"); + }; + output } + // ---- Passing test ---- + #[test] - fn e2e_conditional_markers_evaluate_collection_probes() { - let platform = std::env::consts::OS; + fn e2e_basic_reporting_decorator_filter_and_capture_share_one_project() { let dir = write_test_project( - "test_conditional_markers.incn", - &format!( - r#" -from std.testing import assert_eq, feature, platform, skipif, xfailif + "test_runner_surface.incn", + r#" +from std.testing import assert_eq, test -@skipif(platform() == "{platform}", reason="host platform") -def test_skip_on_platform_probe() -> None: - assert_eq(1, 0) +def test_addition() -> None: + assert_eq(1 + 1, 2) -@xfailif(feature("known_bug"), reason="feature-gated known issue") -def test_feature_xfail() -> None: - assert_eq(1, 0) -"# - ), - ); +def test_one() -> None: + assert_eq(1, 1) - let without_feature = run_incan_test_with_args(&dir, &["-k", "test_feature_xfail"]); - let without_stdout = String::from_utf8_lossy(&without_feature.stdout); - let without_stderr = String::from_utf8_lossy(&without_feature.stderr); - assert!( - !without_feature.status.success(), - "expected feature-gated xfail to run as an ordinary failing test without --feature.\nstdout:\n{}\nstderr:\n{}", - without_stdout, - without_stderr, - ); +def test_two() -> None: + assert_eq(2, 2) - let output = run_incan_test_with_args(&dir, &["--feature", "known_bug"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected skipif/xfailif probes to make the run successful.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stdout.contains("SKIPPED") || stdout.contains("skipped")); - assert!(stdout.contains("XFAIL") || stdout.contains("xfailed")); - } +@test +def verifies_total() -> None: + assert_eq(40 + 2, 42) - #[test] - fn e2e_conditional_marker_rejects_runtime_expression() { - let dir = write_test_project( - "test_bad_conditional_marker.incn", - r#" -from std.testing import skipif +def test_alpha() -> None: + assert_eq(1, 1) -def helper() -> bool: - return true +def test_beta() -> None: + assert_eq(2, 2) -@skipif(helper(), reason="dynamic") -def test_dynamic_condition() -> None: - pass +def test_prints() -> None: + print("VISIBLE_CAPTURE") "#, ); let output = run_incan_test(&dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + assert!( - !output.status.success(), - "expected unsupported conditional marker expression to fail collection.\nstdout:\n{}\nstderr:\n{}", + output.status.success(), + "expected both tests to succeed.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - stderr.contains("platform()") && stderr.contains("feature"), - "expected collection-time expression diagnostic.\nstderr:\n{}", - stderr, + stdout.contains("PASSED") || stdout.contains("passed"), + "expected PASSED in output.\nstdout:\n{}", + stdout, ); - } - - #[test] - fn e2e_jobs_run_independent_files_concurrently() -> Result<(), Box> { - let dir = write_test_project( - "test_sleep_a.incn", - r#" -from rust::std::thread import sleep -from rust::std::time import Duration - -def test_sleep_a() -> None: - sleep(Duration.from_millis(1200)) -"#, + assert!( + stdout.contains("test_runner_surface.incn::test_one") + && stdout.contains("test_runner_surface.incn::test_two") + && stdout.contains("test_runner_surface.incn::verifies_total"), + "expected basic and decorated test names in reporter output.\nstdout:\n{}", + stdout, ); - let second = dir.join("test_sleep_b.incn"); - std::fs::write( - &second, - r#" -from rust::std::thread import sleep -from rust::std::time import Duration - -def test_sleep_b() -> None: - sleep(Duration.from_millis(1200)) -"#, - )?; - - let sequential_start = std::time::Instant::now(); - let sequential = run_incan_test_with_args(&dir, &["--jobs", "1"]); - let sequential_elapsed = sequential_start.elapsed(); - let sequential_stdout = String::from_utf8_lossy(&sequential.stdout); - let sequential_stderr = String::from_utf8_lossy(&sequential.stderr); assert!( - sequential.status.success(), - "expected sequential warm-up run to pass.\nstdout:\n{}\nstderr:\n{}", - sequential_stdout, - sequential_stderr, + stdout.match_indices("PASSED").count() >= 6, + "expected passing result lines for all basic surface tests.\nstdout:\n{}", + stdout, ); - let parallel_start = std::time::Instant::now(); - let parallel = run_incan_test_with_args(&dir, &["--jobs", "2"]); - let parallel_elapsed = parallel_start.elapsed(); - let parallel_stdout = String::from_utf8_lossy(¶llel.stdout); - let parallel_stderr = String::from_utf8_lossy(¶llel.stderr); + let listed = run_incan_test_with_args(&dir, &["--list", "-k", "test_beta"]); + let listed_stdout = String::from_utf8_lossy(&listed.stdout); + let listed_stderr = String::from_utf8_lossy(&listed.stderr); assert!( - parallel.status.success(), - "expected parallel run to pass.\nstdout:\n{}\nstderr:\n{}", - parallel_stdout, - parallel_stderr, + listed.status.success(), + "expected --list -k run to succeed.\nstdout:\n{}\nstderr:\n{}", + listed_stdout, + listed_stderr, ); assert!( - parallel_elapsed + std::time::Duration::from_millis(500) < sequential_elapsed, - "expected --jobs 2 to run independent file batches concurrently; sequential={:?}, parallel={:?}\nparallel stdout:\n{}", - sequential_elapsed, - parallel_elapsed, - parallel_stdout, + listed_stdout + .lines() + .any(|line| line == "test_runner_surface.incn::test_beta"), + "expected exact listed beta id rooted at the explicit test directory.\nstdout:\n{}", + listed_stdout, ); - Ok(()) + assert!( + !listed_stdout.contains(dir.to_string_lossy().as_ref()), + "expected --list output to avoid machine-local absolute paths.\nstdout:\n{}", + listed_stdout, + ); + assert!( + !listed_stdout.contains("test_runner_surface.incn::test_alpha"), + "expected keyword filter to hide alpha.\nstdout:\n{}", + listed_stdout, + ); + + let captured = run_incan_test_with_args(&dir, &["--nocapture", "-k", "test_prints"]); + let captured_stdout = String::from_utf8_lossy(&captured.stdout); + let captured_stderr = String::from_utf8_lossy(&captured.stderr); + assert!( + captured.status.success(), + "expected nocapture run to succeed.\nstdout:\n{}\nstderr:\n{}", + captured_stdout, + captured_stderr, + ); + assert!(captured_stdout.contains("VISIBLE_CAPTURE")); } #[test] - fn e2e_jobs_fail_fast_stops_launching_pending_units() -> Result<(), Box> { + fn e2e_generated_harness_preheat_is_fingerprinted() { let dir = write_test_project( - "test_a_fail.incn", + "test_preheat.incn", r#" -def test_a_fail() -> None: - assert 1 == 2 +from std.testing import assert_eq -def test_c_pending() -> None: - pass +def test_preheat() -> None: + assert_eq(1, 1) "#, ); - std::fs::write( - dir.join("test_b_slow.incn"), - r#" -from rust::std::thread import sleep -from rust::std::time import Duration -def test_b_slow() -> None: - sleep(Duration.from_millis(3000)) -"#, - )?; - let warmup = run_incan_test_with_args(&dir, &["--jobs", "1", "-k", "test_b_slow"]); - let warmup_stdout = String::from_utf8_lossy(&warmup.stdout); - let warmup_stderr = String::from_utf8_lossy(&warmup.stderr); + let first = run_incan_test_with_args(&dir, &["-v"]); + let first_stdout = String::from_utf8_lossy(&first.stdout); + let first_stderr = String::from_utf8_lossy(&first.stderr); assert!( - warmup.status.success(), - "expected slow test warm-up to pass.\nstdout:\n{}\nstderr:\n{}", - warmup_stdout, - warmup_stderr, + first.status.success(), + "expected first preheat run to succeed.\nstdout:\n{}\nstderr:\n{}", + first_stdout, + first_stderr, ); - - let output = run_incan_test_with_args(&dir, &["--jobs", "2", "-x"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - !output.status.success(), - "expected fail-fast run to fail.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + first_stdout.contains("preheat phase: ran"), + "expected first run to preheat stale harness.\nstdout:\n{}", + first_stdout, ); + + let second = run_incan_test_with_args(&dir, &["-v"]); + let second_stdout = String::from_utf8_lossy(&second.stdout); + let second_stderr = String::from_utf8_lossy(&second.stderr); assert!( - stdout.contains("test_a_fail"), - "expected failing test to be reported.\nstdout:\n{}", - stdout, + second.status.success(), + "expected second preheat run to succeed.\nstdout:\n{}\nstderr:\n{}", + second_stdout, + second_stderr, ); assert!( - !stdout.contains("test_c_pending"), - "expected fail-fast scheduler not to launch pending units after the first completed failure.\nstdout:\n{}", - stdout, + second_stdout.contains("preheat phase: up-to-date"), + "expected second run to reuse preheated harness.\nstdout:\n{}", + second_stdout, ); - Ok(()) } #[test] - fn e2e_resource_marker_prevents_overlapping_workers() -> Result<(), Box> { + fn e2e_cross_file_batch_falls_back_when_top_level_names_collide() -> Result<(), Box> { let dir = write_test_project( - "test_resource_a.incn", + "test_a.incn", r#" -from rust::std::thread import sleep -from rust::std::time import Duration -from std.testing import resource +from std.testing import assert_eq -@resource("db") -def test_resource_a() -> None: - sleep(Duration.from_millis(1000)) +model Order: + id: int + +def test_a() -> None: + order = Order(id=1) + assert_eq(order.id, 1) "#, ); std::fs::write( - dir.join("test_resource_b.incn"), + dir.join("test_b.incn"), r#" -from rust::std::thread import sleep -from rust::std::time import Duration -from std.testing import resource +from std.testing import assert_eq -@resource("db") -def test_resource_b() -> None: - sleep(Duration.from_millis(1000)) +model Order: + id: int + +def test_b() -> None: + order = Order(id=2) + assert_eq(order.id, 2) "#, )?; - let warmup = run_incan_test_with_args(&dir, &["--jobs", "1"]); - let warmup_stdout = String::from_utf8_lossy(&warmup.stdout); - let warmup_stderr = String::from_utf8_lossy(&warmup.stderr); - assert!( - warmup.status.success(), - "expected resource warm-up to pass.\nstdout:\n{}\nstderr:\n{}", - warmup_stdout, - warmup_stderr, - ); - - let start = std::time::Instant::now(); - let output = run_incan_test_with_args(&dir, &["--jobs", "2"]); - let elapsed = start.elapsed(); + let output = run_incan_test(&dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + assert!( output.status.success(), - "expected resource-constrained run to pass.\nstdout:\n{}\nstderr:\n{}", + "expected same-named top-level declarations in different files to run in isolated harnesses.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - elapsed >= std::time::Duration::from_millis(1800), - "expected shared @resource workers not to overlap; elapsed={:?}\nstdout:\n{}", - elapsed, + stdout.contains("test_a.incn::test_a") && stdout.contains("test_b.incn::test_b"), + "expected both tests in reporter output.\nstdout:\n{}", stdout, ); Ok(()) } #[test] - fn e2e_serial_marker_runs_alone() -> Result<(), Box> { + fn e2e_imported_default_expression_expands_with_required_scope_issue395() -> Result<(), Box> + { let dir = write_test_project( - "test_serial.incn", - r#" -from rust::std::thread import sleep -from rust::std::time import Duration -from std.testing import serial - -@serial -def test_serial() -> None: - sleep(Duration.from_millis(1000)) + "incan.toml", + r#"[project] +name = "default_expr_import_test_repro" +version = "0.1.0" "#, ); + let src_dir = dir.join("src"); + let tests_dir = dir.join("tests"); + std::fs::create_dir_all(&src_dir)?; + std::fs::create_dir_all(&tests_dir)?; std::fs::write( - dir.join("test_regular.incn"), + src_dir.join("defaults.incn"), r#" -from rust::std::thread import sleep -from rust::std::time import Duration +pub def fallback() -> int: + return 2 +"#, + )?; + std::fs::write( + src_dir.join("helper.incn"), + r#" +from defaults import fallback -def test_regular() -> None: - sleep(Duration.from_millis(1000)) +pub def combine(left: int, middle: int = fallback(), right: int = 3) -> int: + return left + middle + right "#, )?; + std::fs::write( + tests_dir.join("test_default_expr_import.incn"), + r#" +from std.testing import assert_eq +from helper import combine - let warmup = run_incan_test_with_args(&dir, &["--jobs", "1"]); - let warmup_stdout = String::from_utf8_lossy(&warmup.stdout); - let warmup_stderr = String::from_utf8_lossy(&warmup.stderr); - assert!( - warmup.status.success(), - "expected serial warm-up to pass.\nstdout:\n{}\nstderr:\n{}", - warmup_stdout, - warmup_stderr, - ); +def test_imported_default_expression_expands_with_required_imports() -> None: + assert_eq(combine(left=1, right=4), 7, "default expression helper should be available after expansion") +"#, + )?; - let start = std::time::Instant::now(); - let output = run_incan_test_with_args(&dir, &["--jobs", "2"]); - let elapsed = start.elapsed(); + let output = run_incan_test_relative(&dir, "tests"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + assert!( output.status.success(), - "expected serial-constrained run to pass.\nstdout:\n{}\nstderr:\n{}", + "expected imported default expression test to succeed.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - elapsed >= std::time::Duration::from_millis(1800), - "expected @serial worker to run alone; elapsed={:?}\nstdout:\n{}", - elapsed, + stdout.contains( + "test_default_expr_import.incn::test_imported_default_expression_expands_with_required_imports" + ), + "expected issue 395 test name in reporter output.\nstdout:\n{}", stdout, ); Ok(()) } #[test] - fn e2e_nocapture_prints_passing_test_output() { + fn e2e_report_formats_share_one_project() -> Result<(), Box> { let dir = write_test_project( - "test_capture.incn", + "test_report_formats.incn", r#" -def test_prints() -> None: - print("VISIBLE_CAPTURE") +from std.testing import assert_eq + +def test_report_one() -> None: + assert_eq(1, 1) "#, ); - let output = run_incan_test_with_args(&dir, &["--nocapture"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let json_output = run_incan_test_with_args(&dir, &["--format", "json", "--shuffle", "--seed", "7"]); + let json_stdout = String::from_utf8_lossy(&json_output.stdout); + let json_stderr = String::from_utf8_lossy(&json_output.stderr); assert!( - output.status.success(), - "expected nocapture run to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + json_output.status.success(), + "expected JSON-format run to succeed.\nstdout:\n{}\nstderr:\n{}", + json_stdout, + json_stderr, + ); + + let mut saw_result = false; + let mut saw_summary = false; + for line in json_stdout.lines().filter(|line| !line.trim().is_empty()) { + let value: serde_json::Value = serde_json::from_str(line)?; + if value.get("test_id").is_some() { + saw_result = true; + assert_eq!( + value.get("schema_version").and_then(|v| v.as_str()), + Some("incan.test.v1") + ); + assert_eq!( + value.get("test_id").and_then(|v| v.as_str()), + Some("test_report_formats.incn::test_report_one") + ); + assert_eq!(value.get("status").and_then(|v| v.as_str()), Some("passed")); + } + if value.get("summary").is_some() { + saw_summary = true; + assert_eq!( + value + .get("summary") + .and_then(|summary| summary.get("shuffle_seed")) + .and_then(|v| v.as_u64()), + Some(7) + ); + } + } + assert!( + saw_result, + "expected at least one JSON result record.\nstdout:\n{}", + json_stdout + ); + assert!(saw_summary, "expected a JSON summary record.\nstdout:\n{}", json_stdout); + + let report = dir.join("reports").join("junit.xml"); + let report_arg = report.to_string_lossy().to_string(); + let junit_output = run_incan_test_with_args(&dir, &["--junit", report_arg.as_str()]); + let junit_stdout = String::from_utf8_lossy(&junit_output.stdout); + let junit_stderr = String::from_utf8_lossy(&junit_output.stderr); + assert!( + junit_output.status.success(), + "expected JUnit report run to succeed.\nstdout:\n{}\nstderr:\n{}", + junit_stdout, + junit_stderr, + ); + let xml = std::fs::read_to_string(&report)?; + assert!( + xml.contains(" None: +@xfail("currently passes") +def test_xpass() -> None: assert_eq(1, 1) - -def test_alpha_two() -> None: - assert_eq(2, 2) -"#, - ) { - panic!("failed to write test_alpha.incn: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_beta.incn"), - r#" -from std.testing import assert_eq - -def test_beta_only() -> None: - assert_eq(3, 3) "#, - ) { - panic!("failed to write test_beta.incn: {}", err); - } - - let first = run_incan_test_relative(&dir, "tests/test_alpha.incn"); - let first_stdout = String::from_utf8_lossy(&first.stdout); - let first_stderr = String::from_utf8_lossy(&first.stderr); - assert!( - first.status.success(), - "expected first single-file run to succeed.\nstdout:\n{}\nstderr:\n{}", - first_stdout, - first_stderr, ); - let second = run_incan_test_relative(&dir, "tests/test_beta.incn"); - let second_stdout = String::from_utf8_lossy(&second.stdout); - let second_stderr = String::from_utf8_lossy(&second.stderr); - let second_combined = format!("{second_stdout}\n{second_stderr}"); - assert!( - second.status.success(), - "expected second single-file run to succeed.\nstdout:\n{}\nstderr:\n{}", - second_stdout, - second_stderr, - ); + let default = run_incan_test(&dir); + let default_stdout = String::from_utf8_lossy(&default.stdout); + let default_stderr = String::from_utf8_lossy(&default.stderr); assert!( - second_combined.contains("test_beta.incn::test_beta_only"), - "expected the requested beta test to run.\noutput:\n{}", - second_combined, + !default.status.success(), + "expected default xpass to fail.\nstdout:\n{}\nstderr:\n{}", + default_stdout, + default_stderr, ); + + let run_xfail = run_incan_test_with_args(&dir, &["--run-xfail"]); + let stdout = String::from_utf8_lossy(&run_xfail.stdout); + let stderr = String::from_utf8_lossy(&run_xfail.stderr); assert!( - !second_combined.contains("test_alpha.incn::test_alpha_one") - && !second_combined.contains("test_alpha.incn::test_alpha_two"), - "expected no alpha tests in second single-file run.\noutput:\n{}", - second_combined, + run_xfail.status.success(), + "expected --run-xfail to treat xfail marker as ordinary.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, ); assert!( - !second_combined.contains("Test runner did not report outcome"), - "expected no missing-outcome diagnostic in second run.\noutput:\n{}", - second_combined, + stdout.contains("test_run_xfail.incn::test_xpass") && stdout.contains("PASSED"), + "expected ordinary passing output.\nstdout:\n{}", + stdout, ); } #[test] - fn e2e_sequential_single_file_runs_do_not_cross_wire_absolute_paths() { - let dir = write_test_project( + fn e2e_conftest_nearest_fixture_override_project() { + let override_dir = write_test_project( "incan.toml", r#"[project] -name = "session_isolation_absolute" +name = "nested_conftest_precedence" version = "0.1.0" "#, ); - let tests_dir = dir.join("tests"); - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); + let override_tests_dir = override_dir.join("tests"); + let override_unit_dir = override_tests_dir.join("unit"); + if let Err(err) = std::fs::create_dir_all(&override_unit_dir) { + panic!("failed to create nested tests dir: {}", err); } - let alpha_path = tests_dir.join("test_alpha_abs.incn"); - let beta_path = tests_dir.join("test_beta_abs.incn"); if let Err(err) = std::fs::write( - &alpha_path, + override_tests_dir.join("conftest.incn"), r#" -from std.testing import assert_eq +from std.testing import fixture -def test_alpha_abs_one() -> None: - assert_eq(10, 10) +@fixture +def shared() -> str: + return "parent" "#, ) { - panic!("failed to write test_alpha_abs.incn: {}", err); + panic!("failed to write parent conftest: {}", err); } if let Err(err) = std::fs::write( - &beta_path, + override_unit_dir.join("conftest.incn"), r#" -from std.testing import assert_eq +from std.testing import fixture -def test_beta_abs_only() -> None: - assert_eq(20, 20) +@fixture +def shared() -> str: + return "child" "#, ) { - panic!("failed to write test_beta_abs.incn: {}", err); + panic!("failed to write nested conftest: {}", err); } + if let Err(err) = std::fs::write( + override_unit_dir.join("test_precedence.incn"), + r#" +from std.testing import assert_eq - let first = run_incan_test_path(&alpha_path); - let first_stdout = String::from_utf8_lossy(&first.stdout); - let first_stderr = String::from_utf8_lossy(&first.stderr); - assert!( - first.status.success(), - "expected first absolute-path run to succeed.\nstdout:\n{}\nstderr:\n{}", - first_stdout, - first_stderr, - ); - - let second = run_incan_test_path(&beta_path); - let second_stdout = String::from_utf8_lossy(&second.stdout); - let second_stderr = String::from_utf8_lossy(&second.stderr); - let second_combined = format!("{second_stdout}\n{second_stderr}"); - assert!( - second.status.success(), - "expected second absolute-path run to succeed.\nstdout:\n{}\nstderr:\n{}", - second_stdout, - second_stderr, - ); - assert!( - second_combined.contains("test_beta_abs.incn::test_beta_abs_only"), - "expected the requested absolute-path beta test to run.\noutput:\n{}", - second_combined, - ); - assert!( - !second_combined.contains("test_alpha_abs.incn::test_alpha_abs_one"), - "expected no alpha absolute-path tests in second run.\noutput:\n{}", - second_combined, - ); - assert!( - !second_combined.contains("Test runner did not report outcome"), - "expected no missing-outcome diagnostic in second absolute-path run.\noutput:\n{}", - second_combined, - ); - } - - #[test] - fn e2e_nested_package_modules_in_tests_succeed() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "nested_test" -version = "0.1.0" +def test_uses_nearest_fixture(shared: str) -> None: + assert_eq(shared, "child") "#, - ); - let src_dir = dir.join("src"); - let tests_dir = dir.join("tests"); - - if let Err(err) = std::fs::create_dir_all(src_dir.join("dataset")) { - panic!("failed to create nested src dirs: {}", err); - } - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); - } - if let Err(err) = std::fs::write( - src_dir.join("dataset").join("mod.incn"), - "pub const DATASET_VERSION: int = 1\n", - ) { - panic!("failed to write dataset mod source: {}", err); - } - if let Err(err) = std::fs::write( - src_dir.join("dataset").join("ops.incn"), - "from dataset import DATASET_VERSION\npub def filter_ds(value: int) -> int:\n return value + DATASET_VERSION\n", ) { - panic!("failed to write dataset ops source: {}", err); + panic!("failed to write nested conftest test: {}", err); } - if let Err(err) = std::fs::write( - tests_dir.join("test_dataset.incn"), + + let output = run_incan_test(&override_dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected nearest conftest fixture to override parent fixture without duplicate generated functions.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr + ); + assert!(stdout.contains("test_uses_nearest_fixture")); + } + + #[test] + fn e2e_builtin_fixture_and_assert_helper_share_one_project() { + let dir = write_test_project( + "test_builtin_fixture_and_assert_helper.incn", r#" from std.testing import assert_eq -from dataset import DATASET_VERSION -from dataset.ops import filter_ds +import std.testing as testing +from rust::std::path import PathBuf -def test_nested_dataset_modules() -> None: - assert_eq(DATASET_VERSION, 1) - assert_eq(filter_ds(41), 42) +def test_tmp_path_fixture(tmp_path: PathBuf) -> None: + assert_eq(tmp_path.exists(), true) + +def test_assert_helper() -> None: + testing.assert(True) "#, - ) { - panic!("failed to write nested dataset test: {}", err); - } + ); let output = run_incan_test(&dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( output.status.success(), - "expected nested package module test to succeed.\nstdout:\n{}\nstderr:\n{}", + "expected built-in tmp_path fixture to succeed.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); - assert!( - !stderr.contains("file for module `dataset` found at both"), - "expected no stale flat-vs-nested module collision.\nstderr:\n{}", - stderr, - ); + assert!(stdout.contains("test_assert_helper")); } #[test] - fn e2e_test_runner_preserves_project_fixture_cwd_for_file_and_batch_runs() { + fn e2e_markers_parametrize_timeout_and_collection_errors_share_projects() { + let platform = std::env::consts::OS; let dir = write_test_project( - "incan.toml", - r#"[project] -name = "fixture_cwd_parity" -version = "0.1.0" -"#, - ); - let tests_dir = dir.join("tests"); - let fixtures_dir = tests_dir.join("fixtures"); + "test_runner_collection_surface.incn", + &format!( + r#" +from rust::std::thread import sleep +from rust::std::time import Duration +from std.testing import assert_eq, feature, mark, param_case, parametrize, platform, skipif, slow, timeout, xfail, xfailif - if let Err(err) = std::fs::create_dir_all(&fixtures_dir) { - panic!("failed to create fixture dir: {}", err); - } - if let Err(err) = std::fs::write(fixtures_dir.join("orders.csv"), "id\n1\n") { - panic!("failed to write fixture file: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_fixture_path.incn"), - r#" -from std.testing import assert_eq -from rust::std::path import Path +const TEST_MARKERS: List[str] = ["api", "db", "smoke"] +const TEST_MARKS: List[str] = ["smoke"] -const FIXTURE: str = "tests/fixtures/orders.csv" +def test_inherited_smoke() -> None: + assert_eq(1, 1) -def test_fixture_path_exists() -> None: - assert_eq(Path.new(FIXTURE).exists(), true) -"#, - ) { - panic!("failed to write fixture path test: {}", err); - } +@mark("api") +def test_api() -> None: + assert_eq(1, 1) - let single = run_incan_test_relative(&dir, "tests/test_fixture_path.incn"); - let single_stdout = String::from_utf8_lossy(&single.stdout); - let single_stderr = String::from_utf8_lossy(&single.stderr); +@mark("api") +@slow +def test_api_slow() -> None: + assert_eq(1, 1) + +@mark("db") +def test_db() -> None: + assert_eq(1, 1) + +def test_fast() -> None: + assert_eq(1, 1) + +@slow +def test_slow_case() -> None: + assert_eq(1, 1) + +@parametrize("x, expected", [ + param_case((1, 3), marks=[xfail("known")], id="one-three"), + (2, 4), +], ids=["ignored", "two-four"]) +def test_marked_double(x: int, expected: int) -> None: + assert_eq(x * 2, expected) + +@parametrize("x", [1, 2], ids=["one", "two"]) +@parametrize("y", [10, 20], ids=["ten", "twenty"]) +def test_pair(x: int, y: int) -> None: + assert_eq(x < y, true) + +@parametrize("a, b, expected", [(1, 2, 3), (10, 20, 30), (0, 0, 0)]) +def test_add(a: int, b: int, expected: int) -> None: + assert_eq(a + b, expected) + +@parametrize("x, expected", [(2, 4), (3, 7)]) +def test_double_failure(x: int, expected: int) -> None: + assert_eq(x * 2, expected) + +@skipif(platform() == "{platform}", reason="host platform") +def test_skip_on_platform_probe() -> None: + assert_eq(1, 0) + +@xfailif(feature("known_bug"), reason="feature-gated known issue") +def test_feature_xfail() -> None: + assert_eq(1, 0) + +@timeout("1ms") +def test_timeout_marker() -> None: + sleep(Duration.from_millis(100)) +"# + ), + ); + + let strict_smoke = run_incan_test_with_args(&dir, &["--list", "-m", "smoke", "--strict-markers"]); + let strict_smoke_stdout = String::from_utf8_lossy(&strict_smoke.stdout); + let strict_smoke_stderr = String::from_utf8_lossy(&strict_smoke.stderr); assert!( - single.status.success(), - "expected single-file fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", - single_stdout, - single_stderr, + strict_smoke.status.success(), + "expected strict registered marker list to succeed.\nstdout:\n{}\nstderr:\n{}", + strict_smoke_stdout, + strict_smoke_stderr, ); + assert!(strict_smoke_stdout.contains("test_runner_collection_surface.incn::test_inherited_smoke")); - let batch = run_incan_test_relative(&dir, "tests"); - let batch_stdout = String::from_utf8_lossy(&batch.stdout); - let batch_stderr = String::from_utf8_lossy(&batch.stderr); + let strict_error = run_incan_test_with_args(&dir, &["--list", "-m", "missing", "--strict-markers"]); + let strict_stderr = String::from_utf8_lossy(&strict_error.stderr); assert!( - batch.status.success(), - "expected batched fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", - batch_stdout, - batch_stderr, + !strict_error.status.success(), + "expected unknown strict marker to fail.\nstderr:\n{}", + strict_stderr, ); - } + assert!(strict_stderr.contains("unknown marker `missing`")); - #[test] - fn e2e_test_runner_preserves_fixture_cwd_without_manifest_for_file_and_batch_runs() { - use std::time::{SystemTime, UNIX_EPOCH}; + let marker_list = run_incan_test_with_args( + &dir, + &["--list", "-m", "api and not slow", "--strict-markers", "--slow"], + ); + let marker_stdout = String::from_utf8_lossy(&marker_list.stdout); + let marker_stderr = String::from_utf8_lossy(&marker_list.stderr); + assert!( + marker_list.status.success(), + "expected boolean marker expression to collect.\nstdout:\n{}\nstderr:\n{}", + marker_stdout, + marker_stderr, + ); + assert!(marker_stdout.contains("test_runner_collection_surface.incn::test_api")); + assert!(!marker_stdout.contains("test_runner_collection_surface.incn::test_api_slow")); + assert!(!marker_stdout.contains("test_runner_collection_surface.incn::test_db")); - let mut dir = std::env::temp_dir(); - let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else { - panic!("system time before UNIX epoch"); - }; - dir.push(format!("incan_e2e_test_nomani_{}", duration.as_nanos())); - if let Err(err) = std::fs::create_dir_all(&dir) { - panic!("failed to create temp dir: {}", err); - } - let tests_dir = dir.join("tests"); - let fixtures_dir = tests_dir.join("fixtures"); + let default_list = run_incan_test_with_args(&dir, &["--list"]); + let default_stdout = String::from_utf8_lossy(&default_list.stdout); + assert!( + default_list.status.success(), + "expected default list to succeed.\nstdout:\n{}", + default_stdout, + ); + assert!(default_stdout.contains("test_runner_collection_surface.incn::test_fast")); + assert!(!default_stdout.contains("test_runner_collection_surface.incn::test_slow_case")); - if let Err(err) = std::fs::create_dir_all(&fixtures_dir) { - panic!("failed to create fixture dir: {}", err); - } - if let Err(err) = std::fs::write(fixtures_dir.join("ok.txt"), "ok\n") { - panic!("failed to write fixture file: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_cwd.incn"), - r#" -from std.testing import assert_eq -from rust::std::path import Path + let slow_list = run_incan_test_with_args(&dir, &["--list", "--slow"]); + let slow_stdout = String::from_utf8_lossy(&slow_list.stdout); + assert!( + slow_list.status.success(), + "expected --slow list to succeed.\nstdout:\n{}", + slow_stdout, + ); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_fast")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_slow_case")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_marked_double[one-three]")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_marked_double[two-four]")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_pair[one-ten]")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_pair[one-twenty]")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_pair[two-ten]")); + assert!(slow_stdout.contains("test_runner_collection_surface.incn::test_pair[two-twenty]")); -def test_cwd__fixture_path_is_repo_relative() -> None: - assert_eq( - Path.new("tests/fixtures/ok.txt").exists(), - true, - "fixture path should resolve from the project root in both per-file and batched test runs", - ) -"#, - ) { - panic!("failed to write fixture path test: {}", err); - } + let marked_run = run_incan_test_with_args(&dir, &["-k", "test_marked_double"]); + let marked_stdout = String::from_utf8_lossy(&marked_run.stdout); + let marked_stderr = String::from_utf8_lossy(&marked_run.stderr); + assert!( + marked_run.status.success(), + "expected xfailed case and passing case to make the run succeed.\nstdout:\n{}\nstderr:\n{}", + marked_stdout, + marked_stderr, + ); + assert!(marked_stdout.contains("xfailed") || marked_stdout.contains("XFAIL")); - let single = run_incan_test_relative(&dir, "tests/test_cwd.incn"); - let single_stdout = String::from_utf8_lossy(&single.stdout); - let single_stderr = String::from_utf8_lossy(&single.stderr); + let add_run = run_incan_test_with_args(&dir, &["--verbose", "-k", "test_add"]); + let add_stdout = String::from_utf8_lossy(&add_run.stdout); + let add_stderr = String::from_utf8_lossy(&add_run.stderr); assert!( - single.status.success(), - "expected manifest-less single-file fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", - single_stdout, - single_stderr, + add_run.status.success(), + "expected parametrized test to succeed.\nstdout:\n{}\nstderr:\n{}", + add_stdout, + add_stderr, ); + assert!(add_stdout.contains("test_add[1-2-3]")); + assert!(add_stdout.contains("test_add[10-20-30]")); + assert!(add_stdout.contains("test_add[0-0-0]")); + assert!(add_stdout.contains("3 passed")); - let batch = run_incan_test_relative(&dir, "tests"); - let batch_stdout = String::from_utf8_lossy(&batch.stdout); - let batch_stderr = String::from_utf8_lossy(&batch.stderr); + let failing_param = run_incan_test_with_args(&dir, &["--verbose", "-k", "test_double_failure"]); + let failing_param_stdout = String::from_utf8_lossy(&failing_param.stdout); assert!( - batch.status.success(), - "expected manifest-less batched fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", - batch_stdout, - batch_stderr, + !failing_param.status.success(), + "expected one failing case to make the run fail.\nstdout:\n{}", + failing_param_stdout, ); - } + assert!(failing_param_stdout.contains("1 passed") && failing_param_stdout.contains("1 failed")); - #[test] - fn e2e_imported_pub_static_scalar_read_in_tests_succeeds() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "pub_static_scalar_read" -version = "0.1.0" -"#, + let skip_run = run_incan_test_with_args(&dir, &["-k", "test_skip_on_platform_probe"]); + let skip_stdout = String::from_utf8_lossy(&skip_run.stdout); + let skip_stderr = String::from_utf8_lossy(&skip_run.stderr); + assert!( + skip_run.status.success(), + "expected skipif probe to make the run successful.\nstdout:\n{}\nstderr:\n{}", + skip_stdout, + skip_stderr, ); - let src_dir = dir.join("src"); - let tests_dir = dir.join("tests"); + assert!(skip_stdout.contains("SKIPPED") || skip_stdout.contains("skipped")); - if let Err(err) = std::fs::create_dir_all(&src_dir) { - panic!("failed to create src dir: {}", err); - } - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); - } - if let Err(err) = std::fs::write(src_dir.join("widgets.incn"), "pub static MARKER: int = 41\n") { - panic!("failed to write widgets source: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_widgets_static.incn"), - r#" -from std.testing import assert_eq -from widgets import MARKER + let without_feature = run_incan_test_with_args(&dir, &["-k", "test_feature_xfail"]); + let without_stdout = String::from_utf8_lossy(&without_feature.stdout); + let without_stderr = String::from_utf8_lossy(&without_feature.stderr); + assert!( + !without_feature.status.success(), + "expected feature-gated xfail to run as an ordinary failing test without --feature.\nstdout:\n{}\nstderr:\n{}", + without_stdout, + without_stderr, + ); -def test_imported_pub_static_scalar_read() -> None: - assert_eq(MARKER, 41) -"#, - ) { - panic!("failed to write widget static test: {}", err); - } + let with_feature = run_incan_test_with_args(&dir, &["--feature", "known_bug", "-k", "test_feature_xfail"]); + let with_feature_stdout = String::from_utf8_lossy(&with_feature.stdout); + let with_feature_stderr = String::from_utf8_lossy(&with_feature.stderr); + assert!( + with_feature.status.success(), + "expected xfailif probe to make the run successful.\nstdout:\n{}\nstderr:\n{}", + with_feature_stdout, + with_feature_stderr, + ); + assert!(with_feature_stdout.contains("XFAIL") || with_feature_stdout.contains("xfailed")); - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let timeout = run_incan_test_with_args(&dir, &["-k", "test_timeout_marker"]); + let timeout_stdout = String::from_utf8_lossy(&timeout.stdout); + let timeout_stderr = String::from_utf8_lossy(&timeout.stderr); + assert!( + !timeout.status.success(), + "expected timeout marker to fail the test.\nstdout:\n{}\nstderr:\n{}", + timeout_stdout, + timeout_stderr, + ); + assert!(timeout_stdout.contains("timed out after")); + + let arity_dir = write_test_project( + "test_parametrize_arity.incn", + r#" +from std.testing import parametrize +@parametrize("x, y", [1]) +def test_bad_case(x: int, y: int) -> None: + pass +"#, + ); + let arity_output = run_incan_test(&arity_dir); + let arity_stdout = String::from_utf8_lossy(&arity_output.stdout); + let arity_stderr = String::from_utf8_lossy(&arity_output.stderr); assert!( - output.status.success(), - "expected imported pub static scalar read test to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + !arity_output.status.success(), + "expected arity mismatch to fail during collection.\nstdout:\n{}\nstderr:\n{}", + arity_stdout, + arity_stderr, ); - } + assert!(arity_stderr.contains("parametrize case `1`")); + assert!(arity_stderr.contains("expected 2 value(s)")); - #[test] - fn e2e_imported_const_str_materializes_at_test_call_sites() { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "imported_const_str_materialization" -version = "0.1.0" -"#, + let invalid_marker = run_incan_test_with_args(&dir, &["--list", "-m", "api and ("]); + let invalid_marker_stderr = String::from_utf8_lossy(&invalid_marker.stderr); + assert!( + !invalid_marker.status.success(), + "expected invalid marker expression to fail.\nstderr:\n{}", + invalid_marker_stderr, ); - let src_dir = dir.join("src"); - let tests_dir = dir.join("tests"); + assert!(invalid_marker_stderr.contains("expected marker name or parenthesized expression")); - if let Err(err) = std::fs::create_dir_all(&src_dir) { - panic!("failed to create src dir: {}", err); - } - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); - } - if let Err(err) = std::fs::write(src_dir.join("registry.incn"), "pub const TOKEN: str = \"token\"\n") { - panic!("failed to write registry source: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_imported_const_str.incn"), + let bad_conditional_dir = write_test_project( + "test_bad_conditional_marker.incn", r#" -from std.testing import assert_eq -from registry import TOKEN +from std.testing import skipif -def identity(value: str) -> str: - return value +def helper() -> bool: + return true -def test_imported_const_str_call_arguments_materialize() -> None: - local: str = TOKEN - assert_eq(identity(TOKEN), "token") - assert_eq(identity(TOKEN.to_string()), "token") - assert_eq(identity(local), "token") - assert_eq(TOKEN.upper(), "TOKEN") +@skipif(helper(), reason="dynamic") +def test_dynamic_condition() -> None: + pass "#, - ) { - panic!("failed to write imported const string test: {}", err); - } + ); - let output = run_incan_test(&dir); + let output = run_incan_test(&bad_conditional_dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected imported const str materialization test to succeed.\nstdout:\n{}\nstderr:\n{}", + !output.status.success(), + "expected unsupported conditional marker expression to fail collection.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - !stderr.contains("str_as_str") && !stderr.contains("expected `String`, found `&str`"), - "imported const str should not leak raw Rust string shapes.\nstderr:\n{}", + stderr.contains("platform()") && stderr.contains("feature"), + "expected collection-time expression diagnostic.\nstderr:\n{}", stderr, ); } #[test] - fn e2e_imported_decorator_factory_const_str_argument_materializes() { + fn e2e_jobs_run_independent_files_concurrently() -> Result<(), Box> { let dir = write_test_project( - "incan.toml", - r#"[project] -name = "imported_decorator_const_str_materialization" -version = "0.1.0" -"#, - ); - let src_dir = dir.join("src"); - let tests_dir = dir.join("tests"); - - if let Err(err) = std::fs::create_dir_all(&src_dir) { - panic!("failed to create src dir: {}", err); - } - if let Err(err) = std::fs::create_dir_all(&tests_dir) { - panic!("failed to create tests dir: {}", err); - } - if let Err(err) = std::fs::write( - src_dir.join("registry.incn"), + "test_sleep_a.incn", r#" -pub const TOKEN: str = "probe.value" - -def keep_int(func: (int) -> int) -> (int) -> int: - return func +from rust::std::thread import sleep +from rust::std::time import Duration -pub def registered(_name: str) -> Callable[(int) -> int, (int) -> int]: - return keep_int +def test_sleep_a() -> None: + sleep(Duration.from_millis(600)) "#, - ) { - panic!("failed to write registry source: {}", err); - } - if let Err(err) = std::fs::write( - tests_dir.join("test_imported_decorator_const_str.incn"), + ); + let second = dir.join("test_sleep_b.incn"); + std::fs::write( + &second, r#" -from std.testing import assert_eq -from registry import TOKEN, registered - -@registered(TOKEN) -def increment(value: int) -> int: - return value + 1 +from rust::std::thread import sleep +from rust::std::time import Duration -def test_imported_decorator_factory_const_str_argument_materializes() -> None: - assert_eq(increment(1), 2) +def test_sleep_b() -> None: + sleep(Duration.from_millis(600)) "#, - ) { - panic!("failed to write imported decorator const string test: {}", err); - } + )?; - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let sequential_start = std::time::Instant::now(); + let sequential = run_incan_test_with_args(&dir, &["--jobs", "1"]); + let sequential_elapsed = sequential_start.elapsed(); + let sequential_stdout = String::from_utf8_lossy(&sequential.stdout); + let sequential_stderr = String::from_utf8_lossy(&sequential.stderr); + assert!( + sequential.status.success(), + "expected sequential warm-up run to pass.\nstdout:\n{}\nstderr:\n{}", + sequential_stdout, + sequential_stderr, + ); + let parallel_start = std::time::Instant::now(); + let parallel = run_incan_test_with_args(&dir, &["--jobs", "2"]); + let parallel_elapsed = parallel_start.elapsed(); + let parallel_stdout = String::from_utf8_lossy(¶llel.stdout); + let parallel_stderr = String::from_utf8_lossy(¶llel.stderr); assert!( - output.status.success(), - "expected imported decorator factory const str materialization test to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + parallel.status.success(), + "expected parallel run to pass.\nstdout:\n{}\nstderr:\n{}", + parallel_stdout, + parallel_stderr, ); assert!( - !stderr.contains("expected `String`, found `&str`"), - "decorator factory const str argument should materialize as an owned string.\nstderr:\n{}", - stderr, + parallel_elapsed + std::time::Duration::from_millis(250) < sequential_elapsed, + "expected --jobs 2 to run independent file batches concurrently; sequential={:?}, parallel={:?}\nparallel stdout:\n{}", + sequential_elapsed, + parallel_elapsed, + parallel_stdout, ); + Ok(()) } #[test] - fn e2e_empty_list_arguments_in_tests_preserve_string_element_type() -> Result<(), Box> { + fn e2e_jobs_fail_fast_stops_launching_pending_units() -> Result<(), Box> { let dir = write_test_project( - "incan.toml", - r#"[project] -name = "empty_list_test" -version = "0.1.0" -"#, - ); - let src_dir = dir.join("src"); - let tests_dir = dir.join("tests"); - - std::fs::create_dir_all(&src_dir)?; - std::fs::create_dir_all(&tests_dir)?; - std::fs::write( - src_dir.join("helpers.incn"), + "test_a_fail.incn", r#" -pub def count_names(names: List[str]) -> int: - return len(names) +def test_a_fail() -> None: + assert 1 == 2 + +def test_c_pending() -> None: + pass "#, - )?; + ); std::fs::write( - tests_dir.join("test_empty_names.incn"), + dir.join("test_b_slow.incn"), r#" -from std.testing import assert_eq -from helpers import count_names +from rust::std::thread import sleep +from rust::std::time import Duration -def test_empty_names() -> None: - assert_eq(count_names([]), 0) +def test_b_slow() -> None: + sleep(Duration.from_millis(800)) "#, )?; + let warmup = run_incan_test_with_args(&dir, &["--jobs", "1", "-k", "test_b_slow"]); + let warmup_stdout = String::from_utf8_lossy(&warmup.stdout); + let warmup_stderr = String::from_utf8_lossy(&warmup.stderr); + assert!( + warmup.status.success(), + "expected slow test warm-up to pass.\nstdout:\n{}\nstderr:\n{}", + warmup_stdout, + warmup_stderr, + ); - let output = run_incan_test(&dir); + let output = run_incan_test_with_args(&dir, &["--jobs", "2", "-x"]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected empty list string arg test to succeed.\nstdout:\n{}\nstderr:\n{}", + !output.status.success(), + "expected fail-fast run to fail.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - !stderr.contains("type annotations needed"), - "expected no Rust inference failure for empty string list.\nstderr:\n{}", - stderr, + stdout.contains("test_a_fail"), + "expected failing test to be reported.\nstdout:\n{}", + stdout, ); assert!( - !stderr.contains("vec![].into_iter().map(|s| s.to_string()).collect()"), - "expected no untyped empty string-list conversion in generated Rust.\nstderr:\n{}", - stderr, + !stdout.contains("test_c_pending"), + "expected fail-fast scheduler not to launch pending units after the first completed failure.\nstdout:\n{}", + stdout, ); - Ok(()) } #[test] - fn e2e_assert_statement_with_module_import_succeeds() { + fn e2e_resource_marker_prevents_overlapping_workers() -> Result<(), Box> { let dir = write_test_project( - "test_assert_stmt.incn", + "test_resource_a.incn", r#" -import std.testing +from rust::std::thread import sleep +from rust::std::time import Duration +from std.testing import resource -def test_assert_statement_sugar() -> None: - assert 1 + 1 == 2 - assert 3 != 4 - assert not False - assert True +@resource("db") +def test_resource_a() -> None: + sleep(Duration.from_millis(700)) "#, ); + std::fs::write( + dir.join("test_resource_b.incn"), + r#" +from rust::std::thread import sleep +from rust::std::time import Duration +from std.testing import resource - let output = run_incan_test(&dir); +@resource("db") +def test_resource_b() -> None: + sleep(Duration.from_millis(700)) +"#, + )?; + + let warmup = run_incan_test_with_args(&dir, &["--jobs", "1"]); + let warmup_stdout = String::from_utf8_lossy(&warmup.stdout); + let warmup_stderr = String::from_utf8_lossy(&warmup.stderr); + assert!( + warmup.status.success(), + "expected resource warm-up to pass.\nstdout:\n{}\nstderr:\n{}", + warmup_stdout, + warmup_stderr, + ); + + let start = std::time::Instant::now(); + let output = run_incan_test_with_args(&dir, &["--jobs", "2"]); + let elapsed = start.elapsed(); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( output.status.success(), - "expected assert-statement test to succeed.\nstdout:\n{}\nstderr:\n{}", + "expected resource-constrained run to pass.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - stdout.contains("PASSED") || stdout.contains("passed"), - "expected PASSED in output.\nstdout:\n{}", + elapsed >= std::time::Duration::from_millis(1200), + "expected shared @resource workers not to overlap; elapsed={:?}\nstdout:\n{}", + elapsed, stdout, ); + Ok(()) } #[test] - fn e2e_inline_module_tests_are_discovered_and_run() -> Result<(), Box> { + fn e2e_serial_marker_runs_alone() -> Result<(), Box> { let dir = write_test_project( - "incan.toml", - r#"[project] -name = "inline_module_tests_run" -version = "0.1.0" + "test_serial.incn", + r#" +from rust::std::thread import sleep +from rust::std::time import Duration +from std.testing import serial + +@serial +def test_serial() -> None: + sleep(Duration.from_millis(700)) "#, ); - let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; std::fs::write( - src_dir.join("main.incn"), + dir.join("test_regular.incn"), r#" -def add(a: int, b: int) -> int: - return a + b - -def main() -> None: - pass - -module tests: - from std.testing import assert_eq +from rust::std::thread import sleep +from rust::std::time import Duration - def test_addition() -> None: - assert_eq(add(2, 3), 5) +def test_regular() -> None: + sleep(Duration.from_millis(700)) "#, )?; - let output = run_incan_test(&dir); + let warmup = run_incan_test_with_args(&dir, &["--jobs", "1"]); + let warmup_stdout = String::from_utf8_lossy(&warmup.stdout); + let warmup_stderr = String::from_utf8_lossy(&warmup.stderr); + assert!( + warmup.status.success(), + "expected serial warm-up to pass.\nstdout:\n{}\nstderr:\n{}", + warmup_stdout, + warmup_stderr, + ); + + let start = std::time::Instant::now(); + let output = run_incan_test_with_args(&dir, &["--jobs", "2"]); + let elapsed = start.elapsed(); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( output.status.success(), - "expected inline module test run to succeed.\nstdout:\n{}\nstderr:\n{}", + "expected serial-constrained run to pass.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - stdout.contains("1 passed"), - "expected inline test to run.\nstdout:\n{}", - stdout + elapsed >= std::time::Duration::from_millis(1200), + "expected @serial worker to run alone; elapsed={:?}\nstdout:\n{}", + elapsed, + stdout, ); Ok(()) } #[test] - fn e2e_inline_module_tests_can_access_private_enclosing_names() -> Result<(), Box> { + fn e2e_sequential_single_file_runs_do_not_cross_wire_paths() { let dir = write_test_project( "incan.toml", r#"[project] -name = "inline_private_access" +name = "session_isolation_relative" version = "0.1.0" "#, ); - let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; - std::fs::write( - src_dir.join("main.incn"), + let tests_dir = dir.join("tests"); + if let Err(err) = std::fs::create_dir_all(&tests_dir) { + panic!("failed to create tests dir: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_alpha.incn"), r#" -def secret() -> str: - return "private" - -def main() -> None: - pass +from std.testing import assert_eq -module tests: - from std.testing import assert_eq +def test_alpha_one() -> None: + assert_eq(1, 1) - def test_secret() -> None: - assert_eq(secret(), "private") +def test_alpha_two() -> None: + assert_eq(2, 2) "#, - )?; + ) { + panic!("failed to write test_alpha.incn: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_beta.incn"), + r#" +from std.testing import assert_eq - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); +def test_beta_only() -> None: + assert_eq(3, 3) +"#, + ) { + panic!("failed to write test_beta.incn: {}", err); + } + let first = run_incan_test_relative(&dir, "tests/test_alpha.incn"); + let first_stdout = String::from_utf8_lossy(&first.stdout); + let first_stderr = String::from_utf8_lossy(&first.stderr); assert!( - output.status.success(), - "expected inline module test to access enclosing private helper.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + first.status.success(), + "expected first single-file run to succeed.\nstdout:\n{}\nstderr:\n{}", + first_stdout, + first_stderr, ); - Ok(()) - } - #[test] - fn e2e_inline_module_std_testing_assert_helper_is_normalized_before_codegen() - -> Result<(), Box> { - let dir = write_test_project( - "incan.toml", - r#"[project] -name = "inline_assert_helper" -version = "0.1.0" -"#, + let second = run_incan_test_relative(&dir, "tests/test_beta.incn"); + let second_stdout = String::from_utf8_lossy(&second.stdout); + let second_stderr = String::from_utf8_lossy(&second.stderr); + let second_combined = format!("{second_stdout}\n{second_stderr}"); + assert!( + second.status.success(), + "expected second single-file run to succeed.\nstdout:\n{}\nstderr:\n{}", + second_stdout, + second_stderr, ); - let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; - std::fs::write( - src_dir.join("main.incn"), - r#" -def main() -> None: - pass - -module tests: - import std.testing as testing - - def test_assert_helper() -> None: - testing.assert(True) -"#, - )?; - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success(), - "expected inline one-argument std.testing.assert call to be normalized before codegen.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr + second_combined.contains("test_beta.incn::test_beta_only"), + "expected the requested beta test to run.\noutput:\n{}", + second_combined, + ); + assert!( + !second_combined.contains("test_alpha.incn::test_alpha_one") + && !second_combined.contains("test_alpha.incn::test_alpha_two"), + "expected no alpha tests in second single-file run.\noutput:\n{}", + second_combined, + ); + assert!( + !second_combined.contains("Test runner did not report outcome"), + "expected no missing-outcome diagnostic in second run.\noutput:\n{}", + second_combined, ); - assert!(stdout.contains("test_assert_helper")); - Ok(()) - } - #[test] - fn e2e_inline_module_test_imports_do_not_affect_build() -> Result<(), Box> { - let dir = write_test_project( + let abs_dir = write_test_project( "incan.toml", r#"[project] -name = "inline_imports_do_not_affect_build" +name = "session_isolation_absolute" version = "0.1.0" "#, ); - let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; - let entry = src_dir.join("main.incn"); - std::fs::write( - &entry, + let tests_dir = abs_dir.join("tests"); + if let Err(err) = std::fs::create_dir_all(&tests_dir) { + panic!("failed to create tests dir: {}", err); + } + let alpha_path = tests_dir.join("test_alpha_abs.incn"); + let beta_path = tests_dir.join("test_beta_abs.incn"); + if let Err(err) = std::fs::write( + &alpha_path, r#" -def main() -> None: - println("production") +from std.testing import assert_eq -module tests: - from std.testing import assert_eq +def test_alpha_abs_one() -> None: + assert_eq(10, 10) +"#, + ) { + panic!("failed to write test_alpha_abs.incn: {}", err); + } + if let Err(err) = std::fs::write( + &beta_path, + r#" +from std.testing import assert_eq - def test_production() -> None: - assert_eq(1 + 1, 2) +def test_beta_abs_only() -> None: + assert_eq(20, 20) "#, - )?; + ) { + panic!("failed to write test_beta_abs.incn: {}", err); + } - let out_dir = dir.join("out"); - let output = run_incan_build(&entry, &out_dir); - let stderr = String::from_utf8_lossy(&output.stderr); + let first = run_incan_test_path(&alpha_path); + let first_stdout = String::from_utf8_lossy(&first.stdout); + let first_stderr = String::from_utf8_lossy(&first.stderr); + assert!( + first.status.success(), + "expected first absolute-path run to succeed.\nstdout:\n{}\nstderr:\n{}", + first_stdout, + first_stderr, + ); + let second = run_incan_test_path(&beta_path); + let second_stdout = String::from_utf8_lossy(&second.stdout); + let second_stderr = String::from_utf8_lossy(&second.stderr); + let second_combined = format!("{second_stdout}\n{second_stderr}"); assert!( - output.status.success(), - "expected production build to ignore inline test imports.\nstderr:\n{}", - stderr, + second.status.success(), + "expected second absolute-path run to succeed.\nstdout:\n{}\nstderr:\n{}", + second_stdout, + second_stderr, ); - let main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; assert!( - !main_rs.contains("__incan_std::testing"), - "inline test import should not leak into generated production code:\n{}", - main_rs, + second_combined.contains("test_beta_abs.incn::test_beta_abs_only"), + "expected the requested absolute-path beta test to run.\noutput:\n{}", + second_combined, ); assert!( - !main_rs.contains("test_production"), - "inline test function should not leak into generated production code:\n{}", - main_rs, + !second_combined.contains("test_alpha_abs.incn::test_alpha_abs_one"), + "expected no alpha absolute-path tests in second run.\noutput:\n{}", + second_combined, + ); + assert!( + !second_combined.contains("Test runner did not report outcome"), + "expected no missing-outcome diagnostic in second absolute-path run.\noutput:\n{}", + second_combined, ); - Ok(()) } #[test] - fn e2e_inline_module_test_decorator_list_and_keyword_filter() -> Result<(), Box> { + fn e2e_nested_package_modules_in_tests_succeed() { let dir = write_test_project( "incan.toml", r#"[project] -name = "inline_decorator_list_filter" +name = "nested_test" version = "0.1.0" "#, ); let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; - std::fs::write( - src_dir.join("math.incn"), - r#" -def add(a: int, b: int) -> int: - return a + b - -module tests: - from std.testing import assert_eq, test + let tests_dir = dir.join("tests"); - @test - def checks_sum() -> None: - assert_eq(add(20, 22), 42) + if let Err(err) = std::fs::create_dir_all(src_dir.join("dataset")) { + panic!("failed to create nested src dirs: {}", err); + } + if let Err(err) = std::fs::create_dir_all(&tests_dir) { + panic!("failed to create tests dir: {}", err); + } + if let Err(err) = std::fs::write( + src_dir.join("dataset").join("mod.incn"), + "pub const DATASET_VERSION: int = 1\n", + ) { + panic!("failed to write dataset mod source: {}", err); + } + if let Err(err) = std::fs::write( + src_dir.join("dataset").join("ops.incn"), + "from dataset import DATASET_VERSION\npub def filter_ds(value: int) -> int:\n return value + DATASET_VERSION\n", + ) { + panic!("failed to write dataset ops source: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_dataset.incn"), + r#" +from std.testing import assert_eq +from dataset import DATASET_VERSION +from dataset.ops import filter_ds - def test_by_name() -> None: - assert_eq(add(1, 1), 2) +def test_nested_dataset_modules() -> None: + assert_eq(DATASET_VERSION, 1) + assert_eq(filter_ds(41), 42) "#, - )?; + ) { + panic!("failed to write nested dataset test: {}", err); + } - let output = run_incan_test_with_args(&dir, &["--list", "-k", "checks_sum"]); + let output = run_incan_test(&dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success(), - "expected inline --list -k run to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!( - stdout.lines().any(|line| line == "src/math.incn::checks_sum"), - "expected decorated inline test id in --list output.\nstdout:\n{}", + output.status.success(), + "expected nested package module test to succeed.\nstdout:\n{}\nstderr:\n{}", stdout, + stderr, ); assert!( - !stdout.contains("src/math.incn::test_by_name"), - "expected keyword filter to hide the name-discovered inline test.\nstdout:\n{}", - stdout, + !stderr.contains("file for module `dataset` found at both"), + "expected no stale flat-vs-nested module collision.\nstderr:\n{}", + stderr, ); - Ok(()) } #[test] - fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { + fn e2e_test_runner_preserves_fixture_cwd_for_file_and_batch_runs() { let dir = write_test_project( "incan.toml", r#"[project] -name = "inline_parametrize_markers" +name = "fixture_cwd_parity" version = "0.1.0" "#, ); - let src_dir = dir.join("src"); - std::fs::create_dir_all(&src_dir)?; - std::fs::write( - src_dir.join("math.incn"), - r#" -module tests: - from rust::std::thread import sleep - from rust::std::time import Duration - from std.testing import assert_eq, mark, param_case, parametrize, timeout, xfail + let tests_dir = dir.join("tests"); + let fixtures_dir = tests_dir.join("fixtures"); - const TEST_MARKERS: List[str] = ["smoke"] - const TEST_MARKS: List[str] = ["smoke"] + if let Err(err) = std::fs::create_dir_all(&fixtures_dir) { + panic!("failed to create fixture dir: {}", err); + } + if let Err(err) = std::fs::write(fixtures_dir.join("orders.csv"), "id\n1\n") { + panic!("failed to write fixture file: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_fixture_path.incn"), + r#" +from std.testing import assert_eq +from rust::std::path import Path - @parametrize("x, expected", [ - param_case((1, 3), marks=[xfail("known")], id="one-three"), - (2, 4), - ], ids=["ignored", "two-four"]) - def test_double(x: int, expected: int) -> None: - assert_eq(x * 2, expected) +const FIXTURE: str = "tests/fixtures/orders.csv" - @mark("smoke") - @timeout("1ms") - def test_timeout_marker() -> None: - sleep(Duration.from_millis(100)) +def test_fixture_path_exists() -> None: + assert_eq(Path.new(FIXTURE).exists(), true) "#, - )?; + ) { + panic!("failed to write fixture path test: {}", err); + } - let listed = run_incan_test_with_args(&dir, &["--list", "-m", "smoke", "--strict-markers"]); - let listed_stdout = String::from_utf8_lossy(&listed.stdout); - let listed_stderr = String::from_utf8_lossy(&listed.stderr); + let single = run_incan_test_relative(&dir, "tests/test_fixture_path.incn"); + let single_stdout = String::from_utf8_lossy(&single.stdout); + let single_stderr = String::from_utf8_lossy(&single.stderr); assert!( - listed.status.success(), - "expected inline strict marker list to succeed.\nstdout:\n{}\nstderr:\n{}", - listed_stdout, - listed_stderr, + single.status.success(), + "expected single-file fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", + single_stdout, + single_stderr, ); - assert!(listed_stdout.contains("src/math.incn::test_double[one-three]")); - assert!(listed_stdout.contains("src/math.incn::test_double[two-four]")); - assert!(listed_stdout.contains("src/math.incn::test_timeout_marker")); - let run = run_incan_test_with_args(&dir, &["-k", "test_double"]); - let run_stdout = String::from_utf8_lossy(&run.stdout); - let run_stderr = String::from_utf8_lossy(&run.stderr); + let batch = run_incan_test_relative(&dir, "tests"); + let batch_stdout = String::from_utf8_lossy(&batch.stdout); + let batch_stderr = String::from_utf8_lossy(&batch.stderr); assert!( - run.status.success(), - "expected inline parametrized xfail/pass cases to succeed.\nstdout:\n{}\nstderr:\n{}", - run_stdout, - run_stderr, + batch.status.success(), + "expected batched fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", + batch_stdout, + batch_stderr, ); - assert!(run_stdout.contains("XFAIL") || run_stdout.contains("xfailed")); - let timeout = run_incan_test_with_args(&dir, &["-k", "test_timeout_marker"]); - let timeout_stdout = String::from_utf8_lossy(&timeout.stdout); - let timeout_stderr = String::from_utf8_lossy(&timeout.stderr); + use std::time::{SystemTime, UNIX_EPOCH}; + + let mut bare_dir = std::env::temp_dir(); + let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else { + panic!("system time before UNIX epoch"); + }; + bare_dir.push(format!("incan_e2e_test_nomani_{}", duration.as_nanos())); + if let Err(err) = std::fs::create_dir_all(&bare_dir) { + panic!("failed to create temp dir: {}", err); + } + let tests_dir = bare_dir.join("tests"); + let fixtures_dir = tests_dir.join("fixtures"); + + if let Err(err) = std::fs::create_dir_all(&fixtures_dir) { + panic!("failed to create fixture dir: {}", err); + } + if let Err(err) = std::fs::write(fixtures_dir.join("ok.txt"), "ok\n") { + panic!("failed to write fixture file: {}", err); + } + if let Err(err) = std::fs::write( + tests_dir.join("test_cwd.incn"), + r#" +from std.testing import assert_eq +from rust::std::path import Path + +def test_cwd__fixture_path_is_repo_relative() -> None: + assert_eq( + Path.new("tests/fixtures/ok.txt").exists(), + true, + "fixture path should resolve from the project root in both per-file and batched test runs", + ) +"#, + ) { + panic!("failed to write fixture path test: {}", err); + } + + let single = run_incan_test_relative(&bare_dir, "tests/test_cwd.incn"); + let single_stdout = String::from_utf8_lossy(&single.stdout); + let single_stderr = String::from_utf8_lossy(&single.stderr); assert!( - !timeout.status.success(), - "expected inline timeout marker to fail the test.\nstdout:\n{}\nstderr:\n{}", - timeout_stdout, - timeout_stderr, + single.status.success(), + "expected manifest-less single-file fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", + single_stdout, + single_stderr, + ); + + let batch = run_incan_test_relative(&bare_dir, "tests"); + let batch_stdout = String::from_utf8_lossy(&batch.stdout); + let batch_stderr = String::from_utf8_lossy(&batch.stderr); + assert!( + batch.status.success(), + "expected manifest-less batched fixture-path run to succeed.\nstdout:\n{}\nstderr:\n{}", + batch_stdout, + batch_stderr, ); - assert!(timeout_stdout.contains("timed out after")); - Ok(()) } #[test] - fn e2e_inline_module_fixtures_builtins_and_autouse() -> Result<(), Box> { + fn e2e_inline_and_imported_surfaces_share_one_project() -> Result<(), Box> { let dir = write_test_project( "incan.toml", r#"[project] -name = "inline_fixture_builtins" +name = "inline_and_imported_surface_batch" version = "0.1.0" "#, ); let src_dir = dir.join("src"); + let tests_dir = dir.join("tests"); std::fs::create_dir_all(&src_dir)?; + std::fs::create_dir_all(&tests_dir)?; + std::fs::write(src_dir.join("widgets.incn"), "pub static MARKER: int = 41\n")?; std::fs::write( - src_dir.join("main.incn"), + src_dir.join("defaults.incn"), + r#" +pub def fallback() -> int: + return 2 +"#, + )?; + std::fs::write( + src_dir.join("helper.incn"), + r#" +from defaults import fallback + +pub def combine(left: int, middle: int = fallback(), right: int = 3) -> int: + return left + middle + right +"#, + )?; + std::fs::write( + src_dir.join("helpers.incn"), + r#" +pub def count_names(names: List[str]) -> int: + return len(names) +"#, + )?; + std::fs::write( + src_dir.join("registry.incn"), + r#" +pub const TOKEN: str = "token" +pub const DECORATOR_TOKEN: str = "probe.value" + +def keep_int(func: (int) -> int) -> (int) -> int: + return func + +pub def registered(_name: str) -> Callable[(int) -> int, (int) -> int]: + return keep_int +"#, + )?; + let entry = src_dir.join("main.incn"); + std::fs::write( + &entry, r#" +def add(a: int, b: int) -> int: + return a + b + +def secret() -> str: + return "private" + +def main() -> None: + println("production") + module tests: from rust::incan_stdlib::testing import TestEnv from rust::std::path import PathBuf - from std.testing import assert_eq, assert_is_some, fixture + import std.testing as testing + from std.testing import assert_eq, assert_is_some, fixture, test @fixture(autouse=true) def seed() -> int: @@ -9588,273 +8317,256 @@ module tests: def answer(seed: int) -> int: return seed + 2 - def test_fixture_and_tmp_path(answer: int, tmp_path: PathBuf) -> None: + def test_inline_addition(seed: int) -> None: + assert_eq(seed, 40) + assert_eq(add(2, 3), 5) + + def test_inline_private_access(seed: int) -> None: + assert_eq(seed, 40) + assert_eq(secret(), "private") + + def test_inline_assert_helper(seed: int) -> None: + assert_eq(seed, 40) + testing.assert(True) + + @test + def decorated_inline_case(seed: int) -> None: + assert_eq(seed, 40) + assert_eq(add(20, 22), 42) + + def test_inline_fixture_and_tmp_path(answer: int, tmp_path: PathBuf) -> None: assert_eq(answer, 42) assert_eq(tmp_path.exists(), true) - def test_tmp_workdir(tmp_workdir: PathBuf) -> None: + def test_inline_tmp_workdir(tmp_workdir: PathBuf) -> None: assert_eq(tmp_workdir.exists(), true) - def test_env_fixture(mut env: TestEnv) -> None: + def test_inline_env_fixture(mut env: TestEnv) -> None: env.set("INCAN_INLINE_ENV_FIXTURE", "set") assert_eq(assert_is_some(env.get("INCAN_INLINE_ENV_FIXTURE")), "set") env.unset("INCAN_INLINE_ENV_FIXTURE") assert_eq(env.get("INCAN_INLINE_ENV_FIXTURE"), None) "#, )?; - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected inline fixtures and built-ins to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!( - stdout.contains("test_fixture_and_tmp_path"), - "expected inline fixture test name in output.\nstdout:\n{}", - stdout, - ); - assert!( - stdout.contains("test_tmp_workdir"), - "expected inline tmp_workdir test name in output.\nstdout:\n{}", - stdout, - ); - Ok(()) - } - - #[test] - fn e2e_module_scoped_fixture_is_reused_within_file() -> Result<(), Box> { - let dir = write_test_project( - "test_module_scope_fixture.incn", + std::fs::write( + tests_dir.join("test_imported_surface_batch.incn"), r#" -from std.testing import assert_eq, fixture - -static calls: int = 0 +from std.testing import assert_eq +from helper import combine +from helpers import count_names +from registry import DECORATOR_TOKEN, TOKEN, registered +from widgets import MARKER -@fixture(scope="module") -def once() -> int: - calls += 1 - return calls +def identity(value: str) -> str: + return value -def test_first(once: int) -> None: - assert_eq(once, 1) +@registered(DECORATOR_TOKEN) +def increment(value: int) -> int: + return value + 1 -def test_second(once: int) -> None: - assert_eq(once, 1) -"#, - ); +def test_imported_const_str_call_arguments_materialize() -> None: + local: str = TOKEN + assert_eq(identity(TOKEN), "token") + assert_eq(identity(TOKEN.to_string()), "token") + assert_eq(identity(local), "token") + assert_eq(TOKEN.upper(), "TOKEN") - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected module-scoped fixture value to be reused across tests in the same file.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stdout.contains("test_first") && stdout.contains("test_second")); - Ok(()) - } +def test_imported_decorator_factory_const_str_argument_materializes() -> None: + assert_eq(increment(1), 2) - #[test] - fn e2e_yield_fixture_teardown_runs_after_failure() -> Result<(), Box> { - let dir = write_test_project( - "test_yield_fixture_teardown.incn", - r#" -from std.testing import assert_eq, fixture +def test_imported_pub_static_scalar_read() -> None: + assert_eq(MARKER, 41) -static calls: int = 0 +def test_empty_names() -> None: + assert_eq(count_names([]), 0) -@fixture -def resource() -> int: - calls += 1 - yield calls - calls += 10 +def test_assert_statement_sugar() -> None: + assert 1 + 1 == 2 + assert 3 != 4 + assert not False + assert True -def test_1_fails(resource: int) -> None: - assert_eq(resource, 99) +def test_imported_default_expression_expands_with_required_imports() -> None: + assert_eq(combine(left=1, right=4), 7, "default expression helper should be available after expansion") +"#, + )?; + let production_entry = src_dir.join("production_only.incn"); + std::fs::write( + &production_entry, + r#" +def main() -> None: + println("production") -def test_2_observes_teardown() -> None: - assert_eq(calls, 11) +module tests: + from std.testing import assert_eq + + def test_production() -> None: + assert_eq(1 + 1, 2) "#, - ); + )?; let output = run_incan_test(&dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + assert!( - !output.status.success(), - "expected the intentionally failing test to fail the run.\nstdout:\n{}\nstderr:\n{}", + output.status.success(), + "expected batched inline/imported test-runner surfaces to succeed.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - stdout.contains("test_2_observes_teardown PASSED"), - "expected teardown to run before the following test observed shared state.\nstdout:\n{}", - stdout, + stdout.contains("main.incn::test_inline_addition") + && stdout.contains("main.incn::test_inline_private_access") + && stdout.contains("main.incn::decorated_inline_case") + && stdout.contains("main.incn::test_inline_fixture_and_tmp_path") + && stdout.contains("test_imported_surface_batch.incn::test_imported_pub_static_scalar_read") + && stdout.contains( + "test_imported_surface_batch.incn::test_imported_default_expression_expands_with_required_imports" + ), + "expected representative batched inline/imported test names.\nstdout:\n{}", + stdout ); - Ok(()) - } - - #[test] - fn e2e_yield_fixture_teardown_failure_fails_run() -> Result<(), Box> { - let dir = write_test_project( - "test_yield_fixture_teardown_failure.incn", - r#" -from std.testing import assert_eq, fixture - -@fixture -def resource() -> int: - yield 42 - assert_eq(1, 2) - -def test_body_passes(resource: int) -> None: - assert_eq(resource, 42) -"#, + assert!( + !stderr.contains("str_as_str") && !stderr.contains("expected `String`, found `&str`"), + "imported const str call and decorator arguments should materialize as owned strings.\nstderr:\n{}", + stderr, ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); assert!( - !output.status.success(), - "expected teardown failure to fail the run.\nstdout:\n{}\nstderr:\n{}", - stdout, + !stderr.contains("type annotations needed"), + "expected no Rust inference failure for empty string list.\nstderr:\n{}", stderr, ); assert!( - stdout.contains("test_body_passes FAILED") || stderr.contains("test_body_passes"), - "expected passing body with failing teardown to be reported as failed.\nstdout:\n{}\nstderr:\n{}", - stdout, + !stderr.contains("vec![].into_iter().map(|s| s.to_string()).collect()"), + "expected no untyped empty string-list conversion in generated Rust.\nstderr:\n{}", stderr, ); - Ok(()) - } - - #[test] - fn e2e_yield_fixture_teardown_failures_are_aggregated() -> Result<(), Box> { - let dir = write_test_project( - "test_yield_fixture_teardown_aggregate.incn", - r#" -from std.testing import assert_eq, fixture -@fixture -def parent() -> int: - yield 1 - assert_eq(1, 2, "parent teardown failed") + let listed = run_incan_test_with_args(&dir, &["--list", "-k", "decorated_inline_case"]); + let listed_stdout = String::from_utf8_lossy(&listed.stdout); + let listed_stderr = String::from_utf8_lossy(&listed.stderr); + assert!( + listed.status.success(), + "expected inline --list -k run to succeed.\nstdout:\n{}\nstderr:\n{}", + listed_stdout, + listed_stderr, + ); + assert!( + listed_stdout + .lines() + .any(|line| line == "src/main.incn::decorated_inline_case"), + "expected decorated inline test id in --list output.\nstdout:\n{}", + listed_stdout, + ); + assert!( + !listed_stdout.contains("src/main.incn::test_inline_addition"), + "expected keyword filter to hide the name-discovered inline test.\nstdout:\n{}", + listed_stdout, + ); -@fixture -def child(parent: int) -> int: - yield parent + 1 - assert_eq(3, 4, "child teardown failed") + let out_dir = dir.join("out"); + let build_output = run_incan_build(&production_entry, &out_dir); + let build_stderr = String::from_utf8_lossy(&build_output.stderr); -def test_body_passes(child: int) -> None: - assert_eq(child, 2) -"#, + assert!( + build_output.status.success(), + "expected production build to ignore inline test imports.\nstderr:\n{}", + build_stderr, ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}\n{stderr}"); + let main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; assert!( - !output.status.success(), - "expected aggregate teardown failures to fail the run.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + !main_rs.contains("__incan_std::testing"), + "inline test import should not leak into generated production code:\n{}", + main_rs, ); assert!( - combined.contains("fixture teardown failed") - && combined.contains("child teardown failed") - && combined.contains("parent teardown failed"), - "expected both teardown failures in aggregate output.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + !main_rs.contains("test_inline_addition"), + "inline test function should not leak into generated production code:\n{}", + main_rs, ); Ok(()) } #[test] - fn e2e_yield_fixture_teardown_captures_setup_locals() -> Result<(), Box> { + fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { let dir = write_test_project( - "test_yield_fixture_capture.incn", + "incan.toml", + r#"[project] +name = "inline_parametrize_markers" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + src_dir.join("math.incn"), r#" -from std.testing import assert_eq, fixture - -static observed: int = 0 +module tests: + from rust::std::thread import sleep + from rust::std::time import Duration + from std.testing import assert_eq, mark, param_case, parametrize, timeout, xfail -@fixture -def resource() -> int: - value: int = 41 - yield value + 1 - observed += value + const TEST_MARKERS: List[str] = ["smoke"] + const TEST_MARKS: List[str] = ["smoke"] -def test_body(resource: int) -> None: - assert_eq(resource, 42) + @parametrize("x, expected", [ + param_case((1, 3), marks=[xfail("known")], id="one-three"), + (2, 4), + ], ids=["ignored", "two-four"]) + def test_double(x: int, expected: int) -> None: + assert_eq(x * 2, expected) -def test_after_teardown() -> None: - assert_eq(observed, 41) + @mark("smoke") + @timeout("1ms") + def test_timeout_marker() -> None: + sleep(Duration.from_millis(100)) "#, - ); + )?; - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let listed = run_incan_test_with_args(&dir, &["--list", "-m", "smoke", "--strict-markers"]); + let listed_stdout = String::from_utf8_lossy(&listed.stdout); + let listed_stderr = String::from_utf8_lossy(&listed.stderr); assert!( - output.status.success(), - "expected yield teardown to capture setup locals.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + listed.status.success(), + "expected inline strict marker list to succeed.\nstdout:\n{}\nstderr:\n{}", + listed_stdout, + listed_stderr, ); - Ok(()) - } - - #[test] - fn e2e_module_yield_fixture_teardown_runs_at_module_boundary() -> Result<(), Box> { - let dir = write_test_project( - "test_module_yield_fixture.incn", - r#" -from std.testing import assert_eq, fixture - -static calls: int = 0 - -@fixture(scope="module") -def shared() -> int: - yield 10 - assert_eq(calls, 2) - -def test_first(shared: int) -> None: - calls += 1 - assert_eq(shared, 10) + assert!(listed_stdout.contains("src/math.incn::test_double[one-three]")); + assert!(listed_stdout.contains("src/math.incn::test_double[two-four]")); + assert!(listed_stdout.contains("src/math.incn::test_timeout_marker")); -def test_second(shared: int) -> None: - calls += 1 - assert_eq(shared, 10) -"#, + let run = run_incan_test_with_args(&dir, &["-k", "test_double"]); + let run_stdout = String::from_utf8_lossy(&run.stdout); + let run_stderr = String::from_utf8_lossy(&run.stderr); + assert!( + run.status.success(), + "expected inline parametrized xfail/pass cases to succeed.\nstdout:\n{}\nstderr:\n{}", + run_stdout, + run_stderr, ); + assert!(run_stdout.contains("XFAIL") || run_stdout.contains("xfailed")); - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let timeout = run_incan_test_with_args(&dir, &["-k", "test_timeout_marker"]); + let timeout_stdout = String::from_utf8_lossy(&timeout.stdout); + let timeout_stderr = String::from_utf8_lossy(&timeout.stderr); assert!( - output.status.success(), - "expected module yield teardown after all tests in the file.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + !timeout.status.success(), + "expected inline timeout marker to fail the test.\nstdout:\n{}\nstderr:\n{}", + timeout_stdout, + timeout_stderr, ); + assert!(timeout_stdout.contains("timed out after")); Ok(()) } #[test] - fn e2e_session_fixture_reused_across_files_with_single_worker() -> Result<(), Box> { + fn e2e_fixture_lifetime_success_scenarios_share_one_project() -> Result<(), Box> { let dir = write_test_project( "incan.toml", r#"[project] -name = "session_fixture_reuse" +name = "fixture_lifetime_success_batch" version = "0.1.0" "#, ); @@ -9893,234 +8605,247 @@ def test_b(session_value: int) -> None: assert_eq(session_value, 1) "#, )?; + std::fs::write( + tests_dir.join("test_fixture_lifetimes.incn"), + r#" +from std.async import sleep_ms +from std.testing import assert_eq, fixture, parametrize - let output = run_incan_test_with_args(&dir, &["--jobs", "1"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected session fixture to be reused across files in one worker batch.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - Ok(()) - } +static module_scope_calls: int = 0 +static yield_observed: int = 0 +static module_yield_calls: int = 0 +static teardown_order: int = 0 +static async_order: int = 0 +static async_reverse_order: str = "" +static async_param_setups: int = 0 - #[test] - fn e2e_yield_fixture_teardown_runs_in_reverse_dependency_order() -> Result<(), Box> { - let dir = write_test_project( - "test_yield_teardown_order.incn", - r#" -from std.testing import assert_eq, fixture +@fixture(scope="module") +def once() -> int: + module_scope_calls += 1 + return module_scope_calls + +def test_module_scope_first(once: int) -> None: + assert_eq(once, 1) + +def test_module_scope_second(once: int) -> None: + assert_eq(once, 1) + +@fixture +def captured_resource() -> int: + value: int = 41 + yield value + 1 + yield_observed += value + +def test_yield_capture_body(captured_resource: int) -> None: + assert_eq(captured_resource, 42) + +def test_yield_capture_after_teardown() -> None: + assert_eq(yield_observed, 41) + +@fixture(scope="module") +def module_shared() -> int: + yield 10 + assert_eq(module_yield_calls, 2) + +def test_module_yield_first(module_shared: int) -> None: + module_yield_calls += 1 + assert_eq(module_shared, 10) -static order: int = 0 +def test_module_yield_second(module_shared: int) -> None: + module_yield_calls += 1 + assert_eq(module_shared, 10) @fixture def outer() -> int: yield 1 - assert_eq(order, 1) - order += 1 + assert_eq(teardown_order, 1) + teardown_order += 1 @fixture def inner(outer: int) -> int: yield outer + 1 - assert_eq(order, 0) - order += 1 + assert_eq(teardown_order, 0) + teardown_order += 1 -def test_body(inner: int) -> None: +def test_reverse_teardown_body(inner: int) -> None: assert_eq(inner, 2) -def test_after() -> None: - assert_eq(order, 2) -"#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected dependent fixtures to tear down in reverse dependency order.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - Ok(()) - } - - #[test] - fn e2e_async_yield_fixture_setup_and_teardown_are_awaited() -> Result<(), Box> { - let dir = write_test_project( - "test_async_yield_fixture.incn", - r#" -from std.async import sleep_ms -from std.testing import assert_eq, fixture - -static order: int = 0 +def test_reverse_teardown_after() -> None: + assert_eq(teardown_order, 2) @fixture def seed() -> int: - order += 1 + async_order += 1 return 40 @fixture async def resource(seed: int) -> int: await sleep_ms(1) - order += 1 + async_order += 1 yield seed + 2 await sleep_ms(1) - order += 10 + async_order += 10 def test_1_uses_async_fixture(resource: int) -> None: assert_eq(resource, 42) - assert_eq(order, 2) + assert_eq(async_order, 2) def test_2_observes_async_teardown() -> None: - assert_eq(order, 12) + assert_eq(async_order, 12) + +@fixture +async def parent() -> int: + async_reverse_order += "setup-parent;" + await sleep_ms(1) + yield 1 + await sleep_ms(1) + async_reverse_order += "teardown-parent;" + +@fixture +async def child(parent: int) -> int: + async_reverse_order += "setup-child;" + await sleep_ms(1) + yield parent + 1 + await sleep_ms(1) + async_reverse_order += "teardown-child;" + +def test_1_uses_child(child: int) -> None: + assert_eq(child, 2) + assert_eq(async_reverse_order, "setup-parent;setup-child;") + +def test_2_observes_reverse_teardown() -> None: + assert_eq(async_reverse_order, "setup-parent;setup-child;teardown-child;teardown-parent;") + +@fixture +async def base() -> int: + async_param_setups += 1 + await sleep_ms(1) + yield 10 + +@parametrize("value", [1, 2]) +async def test_param_async_fixture(value: int, base: int) -> None: + await sleep_ms(1) + assert_eq(base, 10) + assert_eq(value > 0, true) + +def test_after_param_cases() -> None: + assert_eq(async_param_setups, 2) "#, - ); + )?; - let output = run_incan_test(&dir); + let output = run_incan_test_with_args(&dir, &["--jobs", "1"]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "expected async fixture setup and teardown to be awaited.\nstdout:\n{}\nstderr:\n{}", + "expected fixture lifetime success batch to pass.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); + assert!(stdout.contains("test_module_scope_first") && stdout.contains("test_module_scope_second")); + assert!(stdout.contains("test_param_async_fixture[1]") && stdout.contains("test_param_async_fixture[2]")); Ok(()) } #[test] - fn e2e_async_yield_fixture_teardown_runs_after_failure() -> Result<(), Box> { + fn e2e_fixture_teardown_failure_scenarios_share_one_project() -> Result<(), Box> { let dir = write_test_project( - "test_async_yield_fixture_failure.incn", + "test_yield_fixture_teardown.incn", r#" -from std.async import sleep_ms from std.testing import assert_eq, fixture static calls: int = 0 @fixture -async def resource() -> int: +def resource() -> int: calls += 1 - await sleep_ms(1) yield calls - await sleep_ms(1) calls += 10 def test_1_fails(resource: int) -> None: assert_eq(resource, 99) -def test_2_observes_async_teardown() -> None: +def test_2_observes_teardown() -> None: assert_eq(calls, 11) "#, ); + std::fs::write( + dir.join("test_yield_fixture_teardown_failure.incn"), + r#" +from std.testing import assert_eq, fixture - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !output.status.success(), - "expected the intentionally failing async-fixture test to fail the run.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!( - stdout.contains("test_2_observes_async_teardown PASSED"), - "expected async teardown to run before the following test observed shared state.\nstdout:\n{}", - stdout, - ); - Ok(()) - } +@fixture +def resource() -> int: + yield 42 + assert_eq(1, 2) - #[test] - fn e2e_async_yield_fixture_teardown_runs_in_reverse_dependency_order() -> Result<(), Box> { - let dir = write_test_project( - "test_async_yield_teardown_order.incn", +def test_body_passes(resource: int) -> None: + assert_eq(resource, 42) +"#, + )?; + std::fs::write( + dir.join("test_yield_fixture_teardown_aggregate.incn"), r#" -from std.async import sleep_ms from std.testing import assert_eq, fixture -static order: str = "" - @fixture -async def parent() -> int: - order += "setup-parent;" - await sleep_ms(1) +def parent() -> int: yield 1 - await sleep_ms(1) - order += "teardown-parent;" + assert_eq(1, 2, "parent teardown failed") @fixture -async def child(parent: int) -> int: - order += "setup-child;" - await sleep_ms(1) +def child(parent: int) -> int: yield parent + 1 - await sleep_ms(1) - order += "teardown-child;" + assert_eq(3, 4, "child teardown failed") -def test_1_uses_child(child: int) -> None: +def test_body_passes(child: int) -> None: assert_eq(child, 2) - assert_eq(order, "setup-parent;setup-child;") - -def test_2_observes_reverse_teardown() -> None: - assert_eq(order, "setup-parent;setup-child;teardown-child;teardown-parent;") "#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "expected async yield teardowns to run in reverse dependency order.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - Ok(()) - } - - #[test] - fn e2e_async_fixture_composes_with_parametrize_before_resolution() -> Result<(), Box> { - let dir = write_test_project( - "test_async_param_fixture.incn", + )?; + std::fs::write( + dir.join("test_async_yield_fixture_failure.incn"), r#" from std.async import sleep_ms -from std.testing import assert_eq, fixture, parametrize +from std.testing import assert_eq, fixture -static setups: int = 0 +static calls: int = 0 @fixture -async def base() -> int: - setups += 1 +async def resource() -> int: + calls += 1 await sleep_ms(1) - yield 10 - -@parametrize("value", [1, 2]) -async def test_param_async_fixture(value: int, base: int) -> None: + yield calls await sleep_ms(1) - assert_eq(base, 10) - assert_eq(value > 0, true) + calls += 10 -def test_after_param_cases() -> None: - assert_eq(setups, 2) +def test_1_fails(resource: int) -> None: + assert_eq(resource, 99) + +def test_2_observes_async_teardown() -> None: + assert_eq(calls, 11) "#, - ); + )?; let output = run_incan_test(&dir); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}\n{stderr}"); assert!( - output.status.success(), - "expected parametrized async tests to resolve async fixtures per expanded case.\nstdout:\n{}\nstderr:\n{}", + !output.status.success(), + "expected fixture teardown failure batch to fail.\nstdout:\n{}\nstderr:\n{}", stdout, stderr, ); assert!( - stdout.contains("test_param_async_fixture[1]") && stdout.contains("test_param_async_fixture[2]"), - "expected both parametrized async cases in reporter output.\nstdout:\n{}", + combined.contains("test_2_observes_teardown PASSED") + && combined.contains("test_2_observes_async_teardown PASSED") + && combined.contains("test_body_passes") + && combined.contains("fixture teardown failed") + && combined.contains("child teardown failed") + && combined.contains("parent teardown failed"), + "expected teardown diagnostics and observer tests in failure batch.\nstdout:\n{}\nstderr:\n{}", stdout, + stderr, ); Ok(()) } @@ -10202,216 +8927,104 @@ module tests: "#, )?; - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !output.status.success(), - "expected tests/conftest fixture not to apply to src inline tests.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, - ); - assert!(stderr.contains("missing fixture `shared`")); - Ok(()) - } - - #[test] - fn e2e_assert_failure_message_is_reported() { - let dir = write_test_project( - "test_assert_message.incn", - r#" -def test_message() -> None: - assert False, "custom boom" -"#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}\n{stderr}"); - - assert!( - !output.status.success(), - "expected assertion failure test to fail.\n{}", - combined, - ); - assert!( - combined.contains("AssertionError: custom boom"), - "expected custom assertion message in output.\n{}", - combined, - ); - } - - #[test] - fn e2e_assert_eq_failure_reports_kind_and_message() { - let dir = write_test_project( - "test_assert_eq_message.incn", - r#" -def test_eq_message() -> None: - assert 1 == 2, "math broke" -"#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}\n{stderr}"); - - assert!( - !output.status.success(), - "expected assertion failure test to fail.\n{}", - combined, - ); - assert!( - combined.contains("AssertionError: math broke"), - "expected custom equality assertion message in output.\n{}", - combined, - ); - assert!( - combined.contains("left != right"), - "expected equality failure kind in output.\n{}", - combined, - ); - } - - // ---- Failing test ---- - - #[test] - fn e2e_failing_test_reports_failure() { - let dir = write_test_project( - "test_bad.incn", - r#" -from std.testing import assert_eq - -def test_wrong() -> None: - assert_eq(1 + 1, 99) -"#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !output.status.success(), - "expected failing test to exit non-zero.\nstdout:\n{}", - stdout, - ); - assert!( - stdout.contains("FAILED") || stdout.contains("failed"), - "expected FAILED in output.\nstdout:\n{}", - stdout, - ); - } - - // ---- Skip marker ---- - - #[test] - fn e2e_skip_marker_skips_test() { - let dir = write_test_project( - "test_skip.incn", - r#" -from std.testing import skip - -@skip("not implemented yet") -def test_todo() -> None: - pass -"#, - ); - - let output = run_incan_test(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - output.status.success(), - "expected skipped test to succeed overall.\nstdout:\n{}", - stdout, - ); + let output = run_incan_test(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stdout.contains("SKIPPED") || stdout.contains("skipped"), - "expected SKIPPED in output.\nstdout:\n{}", + !output.status.success(), + "expected tests/conftest fixture not to apply to src inline tests.\nstdout:\n{}\nstderr:\n{}", stdout, + stderr, ); + assert!(stderr.contains("missing fixture `shared`")); + Ok(()) } - // ---- Parametrize expansion ---- - #[test] - fn e2e_parametrize_expands_and_runs_all_cases() { + fn e2e_failure_skip_and_assert_reporting_share_one_project() { let dir = write_test_project( - "test_param.incn", + "test_failure_skip_and_assert_reporting.incn", r#" -from std.testing import parametrize, assert_eq +from std.testing import assert_eq, skip -@parametrize("a, b, expected", [(1, 2, 3), (10, 20, 30), (0, 0, 0)]) -def test_add(a: int, b: int, expected: int) -> None: - assert_eq(a + b, expected) +def test_message() -> None: + assert False, "custom boom" + +def test_eq_message() -> None: + assert 1 == 2, "math broke" + +def test_wrong() -> None: + assert_eq(1 + 1, 99) + +@skip("not implemented yet") +def test_todo() -> None: + pass "#, ); - let output = run_incan_test_with_args(&dir, &["--verbose"]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let message = run_incan_test_with_args(&dir, &["-k", "test_message"]); + let message_stdout = String::from_utf8_lossy(&message.stdout); + let message_stderr = String::from_utf8_lossy(&message.stderr); + let message_combined = format!("{message_stdout}\n{message_stderr}"); assert!( - output.status.success(), - "expected parametrized test to succeed.\nstdout:\n{}\nstderr:\n{}", - stdout, - stderr, + !message.status.success(), + "expected assertion failure test to fail.\n{}", + message_combined, ); - - // All three parametrized variants should appear in the output. assert!( - stdout.contains("test_add[1-2-3]"), - "expected test_add[1-2-3] in output.\nstdout:\n{}", - stdout, + message_combined.contains("AssertionError: custom boom"), + "expected custom assertion message in output.\n{}", + message_combined, ); + + let eq = run_incan_test_with_args(&dir, &["-k", "test_eq_message"]); + let eq_stdout = String::from_utf8_lossy(&eq.stdout); + let eq_stderr = String::from_utf8_lossy(&eq.stderr); + let eq_combined = format!("{eq_stdout}\n{eq_stderr}"); + assert!( - stdout.contains("test_add[10-20-30]"), - "expected test_add[10-20-30] in output.\nstdout:\n{}", - stdout, + !eq.status.success(), + "expected assertion failure test to fail.\n{}", + eq_combined, ); assert!( - stdout.contains("test_add[0-0-0]"), - "expected test_add[0-0-0] in output.\nstdout:\n{}", - stdout, + eq_combined.contains("AssertionError: math broke"), + "expected custom equality assertion message in output.\n{}", + eq_combined, ); - - // Should report 3 passed assert!( - stdout.contains("3 passed"), - "expected '3 passed' in output.\nstdout:\n{}", - stdout, + eq_combined.contains("left != right"), + "expected equality failure kind in output.\n{}", + eq_combined, ); - } - - // ---- Parametrize with a failing case ---- - #[test] - fn e2e_parametrize_reports_failing_case() { - let dir = write_test_project( - "test_param_fail.incn", - r#" -from std.testing import parametrize, assert_eq + let wrong = run_incan_test_with_args(&dir, &["-k", "test_wrong"]); + let wrong_stdout = String::from_utf8_lossy(&wrong.stdout); -@parametrize("x, expected", [(2, 4), (3, 7)]) -def test_double(x: int, expected: int) -> None: - assert_eq(x * 2, expected) -"#, + assert!( + !wrong.status.success(), + "expected failing test to exit non-zero.\nstdout:\n{}", + wrong_stdout, + ); + assert!( + wrong_stdout.contains("FAILED") || wrong_stdout.contains("failed"), + "expected FAILED in output.\nstdout:\n{}", + wrong_stdout, ); - let output = run_incan_test_with_args(&dir, &["--verbose"]); - let stdout = String::from_utf8_lossy(&output.stdout); + let skip = run_incan_test_with_args(&dir, &["-k", "test_todo"]); + let skip_stdout = String::from_utf8_lossy(&skip.stdout); - // 2*2==4 passes, 3*2==6!=7 fails assert!( - !output.status.success(), - "expected one failing case to make the run fail.\nstdout:\n{}", - stdout, + skip.status.success(), + "expected skipped test to succeed overall.\nstdout:\n{}", + skip_stdout, ); assert!( - stdout.contains("1 passed") && stdout.contains("1 failed"), - "expected '1 passed' and '1 failed'.\nstdout:\n{}", - stdout, + skip_stdout.contains("SKIPPED") || skip_stdout.contains("skipped"), + "expected SKIPPED in output.\nstdout:\n{}", + skip_stdout, ); } } @@ -10602,7 +9215,7 @@ def main() -> None: println(str(from_classmethod.value)) println(str(from_staticmethod.value)) "#; - let output = std::process::Command::new(super::incan_debug_binary()) + let output = super::incan_command() .args(["run", "-c", source]) .env("CARGO_NET_OFFLINE", "true") .output()?; @@ -10794,10 +9407,6 @@ mod rfc031_pub_import_integration_tests { use sha2::{Digest, Sha256}; use std::path::PathBuf; - fn incan_bin_path() -> std::path::PathBuf { - super::incan_debug_binary() - } - fn write_project_files( root: &Path, manifest_content: &str, @@ -10811,11 +9420,11 @@ mod rfc031_pub_import_integration_tests { } fn run_check(main_path: &Path) -> Result> { - Ok(Command::new(incan_bin_path()).arg("--check").arg(main_path).output()?) + Ok(super::incan_command().arg("--check").arg(main_path).output()?) } fn run_build(main_path: &Path, out_dir: &Path) -> Result> { - Ok(Command::new(incan_bin_path()) + Ok(super::incan_command() .args([ "build", main_path.to_string_lossy().as_ref(), @@ -10826,19 +9435,26 @@ mod rfc031_pub_import_integration_tests { } fn run_lock(entry_path: &Path) -> Result> { - Ok(Command::new(incan_bin_path()) + Ok(super::incan_command() .args(["lock", entry_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?) } fn run_test(target: &Path) -> Result> { - Ok(Command::new(incan_bin_path()) + Ok(super::incan_command() .args(["test", target.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") + .env("INCAN_TEST_SHARED_TARGET_DIR", shared_test_runner_target_dir()) .output()?) } + fn shared_test_runner_target_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("incan_e2e_shared_target") + } + fn test_runner_batch_manifest_path(file_path: &Path) -> PathBuf { let canonical = std::fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf()); let mut hasher = Sha256::new(); @@ -10852,7 +9468,7 @@ mod rfc031_pub_import_integration_tests { } fn run_build_lib(project_root: &Path) -> Result> { - Ok(Command::new(incan_bin_path()) + Ok(super::incan_command() .args(["build", "--lib"]) .current_dir(project_root) .env("CARGO_NET_OFFLINE", "true") @@ -10961,175 +9577,30 @@ def main() -> None: } #[test] - fn explicit_serialize_trait_adoption_runs_with_default_to_json() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"serialize_trait_default\"\n", - "from std.serde.json import Serialize\n\nmodel Payload with Serialize:\n value: int\n\ndef main() -> None:\n println(Payload(value=1).to_json())\n", - )?; - - let output = Command::new(incan_bin_path()) - .arg("run") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected explicit Serialize adoption to run successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("{\"value\":1}"), - "expected JSON output from default Serialize trait implementation, got:\n{}", - stdout - ); - Ok(()) - } - - #[test] - fn generated_runtime_helpers_run_for_pop_min_max_and_to_json() -> Result<(), Box> { + fn std_json_and_generated_runtime_surfaces_share_one_generated_run() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let main_path = write_project_files( tmp.path(), - "[project]\nname = \"generated_runtime_helpers\"\nversion = \"0.3.0-dev.1\"\n", - "from std.serde.json import Serialize\n\nmodel Payload with Serialize:\n value: int\n\ndef main() -> None:\n mut xs = [3, 1, 4]\n println(xs.pop())\n println(min(xs))\n println(max(xs))\n println(Payload(value=2).to_json())\n", - )?; - - let output = Command::new(incan_bin_path()) - .arg("run") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected generated runtime helper path project to run successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + "[project]\nname = \"std_json_runtime_surface_batch\"\nversion = \"0.3.0-dev.1\"\n", + r#"from std.serde import json +from std.serde.json import Deserialize, Serialize +from std.json import JsonValue - let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!( - lines.first().copied(), - Some("4"), - "expected xs.pop() output first, got:\n{stdout}" - ); - assert_eq!( - lines.get(1).copied(), - Some("1"), - "expected min(xs) after pop, got:\n{stdout}" - ); - assert_eq!( - lines.get(2).copied(), - Some("3"), - "expected max(xs) after pop, got:\n{stdout}" - ); - assert_eq!( - lines.get(3).copied(), - Some("{\"value\":2}"), - "expected Payload.to_json() output, got:\n{stdout}" - ); - Ok(()) - } +model SerializePayload with Serialize: + value: int - #[test] - fn std_json_deserialize_from_json_runs_through_incan_surface() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"std_json_deserialize_from_json\"\nversion = \"0.3.0-dev.1\"\n", - r#"from std.serde import json +model HelperPayload with Serialize: + value: int @derive(json) -model Payload: +model JsonPayload: value: int label: str -def main() -> None: - match Payload.from_json('{"value":7,"label":"dogfood"}'): - case Ok(payload): - println(payload.to_json()) - case Err(err): - println(err) -"#, - )?; - - let output = Command::new(incan_bin_path()) - .arg("run") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected std JSON Deserialize.from_json to run successfully through Incan source.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!( - stdout.lines().next(), - Some("{\"value\":7,\"label\":\"dogfood\"}"), - "expected round-tripped JSON payload, got:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn direct_std_json_deserialize_derive_runs_through_incan_surface() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"direct_std_json_deserialize_derive\"\nversion = \"0.3.0-dev.1\"\n", - r#"from std.serde.json import Deserialize - @derive(Deserialize) -model Payload: +model DirectPayload: value: int -def main() -> None: - match Payload.from_json('{"value":7}'): - case Ok(payload): - println(f"{payload.value}") - case Err(err): - println(err) -"#, - )?; - - let output = Command::new(incan_bin_path()) - .arg("run") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected directly imported Deserialize.from_json to run successfully through Incan source.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.lines().next(), Some("7")); - Ok(()) - } - - #[test] - fn std_json_value_model_field_roundtrips_and_indexes() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"json_value_model_field_roundtrip\"\nversion = \"0.3.0-dev.1\"\n", - r#"from std.serde import json -from std.json import JsonValue - @derive(json) model Envelope: status: int @@ -11141,7 +9612,33 @@ model Probe: first: Option[JsonValue] missing: Option[JsonValue] -def main() -> None: +const NUMBERS: FrozenList[float] = [3.0, 1.5, 4.25] + +def run_explicit_serialize_trait() -> None: + println(SerializePayload(value=1).to_json()) + +def run_generated_runtime_helpers() -> None: + mut xs = [3, 1, 4] + println(xs.pop()) + println(min(xs)) + println(max(xs)) + println(HelperPayload(value=2).to_json()) + +def run_std_json_deserialize() -> None: + match JsonPayload.from_json('{"value":7,"label":"dogfood"}'): + case Ok(payload): + println(payload.to_json()) + case Err(err): + println(err) + +def run_direct_deserialize_derive() -> None: + match DirectPayload.from_json('{"value":7}'): + case Ok(payload): + println(f"{payload.value}") + case Err(err): + println(err) + +def run_json_value_model_field_roundtrip() -> None: match Envelope.from_json('{"status":200,"data":{"name":"Ada","items":[1,2]}}'): case Ok(envelope): match envelope.data["items"]: @@ -11152,40 +9649,8 @@ def main() -> None: println("missing items") case Err(err): println(err) -"#, - )?; - - let output = Command::new(incan_bin_path()) - .arg("run") - .arg(&main_path) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - - assert!( - output.status.success(), - "expected JsonValue model-field round trip to run successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!( - stdout.lines().next(), - Some("{\"name\":\"Ada\",\"first\":1,\"missing\":null}"), - "expected checked JsonValue indexing to produce optional fields, got:\n{stdout}" - ); - Ok(()) - } - - #[test] - fn std_json_value_broad_surface_runs() -> Result<(), Box> { - let output = Command::new(incan_debug_binary()) - .args([ - "run", - "-c", - r#"from std.json import JsonValue -def main() -> None: +def run_std_json_value_broad_surface() -> None: match JsonValue.parse('{"items":[1,2],"name":"Ada","n":null}'): case Ok(data): assert data.kind().as_str() == "object" @@ -11232,30 +9697,23 @@ def main() -> None: case Err(err): println(err.message()) assert false -"#, - ]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "expected std.json broad surface smoke program to run successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - } +def run_frozen_float_helpers() -> None: + println(min(NUMBERS)) + println(max(NUMBERS)) - #[test] - fn generated_runtime_helpers_support_frozen_float_list_min_max() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"generated_runtime_helpers_frozen_float\"\nversion = \"0.3.0-dev.1\"\n", - "const NUMBERS: FrozenList[float] = [3.0, 1.5, 4.25]\n\ndef main() -> None:\n println(min(NUMBERS))\n println(max(NUMBERS))\n", +def main() -> None: + run_explicit_serialize_trait() + run_generated_runtime_helpers() + run_std_json_deserialize() + run_direct_deserialize_derive() + run_json_value_model_field_roundtrip() + run_std_json_value_broad_surface() + run_frozen_float_helpers() +"#, )?; - let output = Command::new(incan_bin_path()) + let output = super::incan_command() .arg("run") .arg(&main_path) .env("CARGO_NET_OFFLINE", "true") @@ -11263,22 +9721,27 @@ def main() -> None: assert!( output.status.success(), - "expected frozen-list min/max helper path project to run successfully.\nstdout:\n{}\nstderr:\n{}", + "expected std/json and generated runtime surface batch to run successfully.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - let lines: Vec<&str> = stdout.lines().collect(); assert_eq!( - lines.first().copied(), - Some("1.5"), - "expected min(NUMBERS) output first, got:\n{stdout}" - ); - assert_eq!( - lines.get(1).copied(), - Some("4.25"), - "expected max(NUMBERS) output second, got:\n{stdout}" + stdout.lines().collect::>(), + vec![ + "{\"value\":1}", + "4", + "1", + "3", + "{\"value\":2}", + "{\"value\":7,\"label\":\"dogfood\"}", + "7", + "{\"name\":\"Ada\",\"first\":1,\"missing\":null}", + "1.5", + "4.25", + ], + "expected std/json and generated runtime surface transcript, got:\n{stdout}" ); Ok(()) } @@ -11440,6 +9903,7 @@ pub def display[T](data: DataSet[T]) -> None: Ok(()) } + #[allow(dead_code)] fn write_nested_wasm_vocab_companion_crate( project_root: &Path, relative_path: &str, @@ -12473,10 +10937,12 @@ incan_vocab::export_wasm_desugarer!(NestedOutputDesugarer); Ok(()) } - fn write_pub_library_with_assert_keyword( + fn write_pub_library_with_provider_requirements_and_assert_keyword( root: &Path, dependency_key: &str, manifest_name: &str, + required_dependencies: Vec, + required_stdlib_features: Vec<&str>, ) -> Result<(), Box> { let artifact_root = root.join("deps").join(dependency_key).join("target").join("lib"); std::fs::create_dir_all(artifact_root.join("src"))?; @@ -12497,7 +10963,14 @@ incan_vocab::export_wasm_desugarer!(NestedOutputDesugarer); valid_decorators: Vec::new(), }], dsl_surfaces: Vec::new(), - provider_manifest: incan_vocab::LibraryManifest::default(), + provider_manifest: incan_vocab::LibraryManifest { + required_dependencies, + required_stdlib_features: required_stdlib_features + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + ..incan_vocab::LibraryManifest::default() + }, desugarer_artifact: None, }); manifest.write_to_path(&artifact_root.join(format!("{manifest_name}.incnlib")))?; @@ -12650,594 +11123,439 @@ incan_vocab::export_wasm_desugarer!(NestedOutputDesugarer); .path() .join("deps") .join("mylib") - .join("target") - .join("lib") - .join("mylib.incnlib"); - std::fs::create_dir_all(dep_manifest_path.parent().ok_or("missing dependency manifest parent")?)?; - mylib_manifest_with_widget().write_to_path(&dep_manifest_path)?; - // Intentionally do not write Cargo.toml / src/lib.rs to exercise artifact-contract diagnostics. - - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"app\"\n\n[dependencies]\nmylib = { path = \"deps/mylib\" }\n", - "from pub::mylib import Widget\n", - )?; - - let output = run_check(&main_path)?; - assert!( - !output.status.success(), - "expected check to fail for missing crate artifacts, stderr:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - let stderr = strip_ansi_escapes(&String::from_utf8_lossy(&output.stderr)); - assert!( - stderr.contains("Missing generated crate artifacts for `pub::mylib`"), - "expected missing-artifact diagnostic, got:\n{stderr}" - ); - Ok(()) - } - - #[test] - fn check_reports_pub_library_artifact_mismatch() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let dep_artifact_root = tmp.path().join("deps").join("widgets-lib").join("target").join("lib"); - std::fs::create_dir_all(&dep_artifact_root)?; - let mut manifest = LibraryManifest::new("widgets_core", "0.1.0"); - manifest.exports.models.push(ModelExport { - name: "Widget".to_string(), - type_params: Vec::new(), - traits: Vec::new(), - trait_adoptions: Vec::new(), - derives: Vec::new(), - fields: Vec::new(), - methods: Vec::new(), - }); - manifest.write_to_path(&dep_artifact_root.join("widgets_core.incnlib"))?; - write_minimal_library_crate(&dep_artifact_root, "different_package_name")?; - - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"app\"\n\n[dependencies]\nwidgets = { path = \"deps/widgets-lib\" }\n", - "from pub::widgets import Widget\n", - )?; - - let output = run_check(&main_path)?; - assert!( - !output.status.success(), - "expected check to fail for artifact mismatch, stderr:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - let stderr = strip_ansi_escapes(&String::from_utf8_lossy(&output.stderr)); - assert!( - stderr.contains("Generated crate metadata mismatch for `pub::widgets`"), - "expected artifact mismatch diagnostic, got:\n{stderr}" - ); - Ok(()) - } - - #[test] - fn build_lib_artifacts_and_consumer_alias_linkage() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let producer_root = tmp.path().join("widgets_core_project"); - std::fs::create_dir_all(producer_root.join("src"))?; - std::fs::write( - producer_root.join("incan.toml"), - "[project]\nname = \"widgets_core\"\nversion = \"0.1.0\"\n", - )?; - std::fs::write( - producer_root.join("src/widgets.incn"), - "pub model Widget:\n name: str\n\npub def make_widget(name: str) -> Widget:\n return Widget(name=name)\n", - )?; - std::fs::write( - producer_root.join("src/lib.incn"), - "pub from widgets import Widget, make_widget\n", - )?; - - let producer_build = run_build_lib(&producer_root)?; - assert!( - producer_build.status.success(), - "expected `build --lib` to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&producer_build.stdout), - String::from_utf8_lossy(&producer_build.stderr) - ); - let producer_artifact_root = producer_root.join("target").join("lib"); - assert!(producer_artifact_root.join("Cargo.toml").is_file()); - assert!(producer_artifact_root.join("src/lib.rs").is_file()); - assert!(producer_artifact_root.join("widgets_core.incnlib").is_file()); - - let consumer_root = tmp.path().join("consumer_app"); - std::fs::create_dir_all(consumer_root.join("src"))?; - std::fs::write( - consumer_root.join("incan.toml"), - "[project]\nname = \"consumer\"\n\n[dependencies]\nwidgets = { path = \"../widgets_core_project\" }\n", - )?; - let consumer_main = consumer_root.join("src/main.incn"); - std::fs::write( - &consumer_main, - "from pub::widgets import Widget as PublicWidget, make_widget\n\ndef main() -> None:\n w: PublicWidget = make_widget(\"ok\")\n print(w.name)\n", - )?; - - let out_dir = consumer_root.join("out"); - let consumer_build = run_build(&consumer_main, &out_dir)?; - assert!( - consumer_build.status.success(), - "expected consumer build to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&consumer_build.stdout), - String::from_utf8_lossy(&consumer_build.stderr) - ); - - let generated_toml = std::fs::read_to_string(out_dir.join("Cargo.toml"))?; - assert!( - generated_toml.contains("[dependencies.widgets]"), - "expected library alias dependency entry, got:\n{generated_toml}" - ); - assert!( - generated_toml.contains("package = \"widgets_core\""), - "expected package alias mapping in Cargo.toml, got:\n{generated_toml}" - ); - assert!( - generated_toml.contains("path = "), - "expected path dependency in Cargo.toml, got:\n{generated_toml}" - ); - - let generated_main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; - assert!( - generated_main_rs.contains("use widgets::Widget as PublicWidget;"), - "expected pub:: item alias import emission, got:\n{generated_main_rs}" - ); - assert!( - generated_main_rs.contains("use widgets::make_widget;"), - "expected pub:: item import emission, got:\n{generated_main_rs}" - ); - assert!( - !generated_main_rs.contains("pub use widgets::Widget as PublicWidget;"), - "private pub:: item alias import should not become a public Rust reexport, got:\n{generated_main_rs}" - ); - assert!( - !generated_main_rs.contains("pub use widgets::make_widget;"), - "private pub:: item import should not become a public Rust reexport, got:\n{generated_main_rs}" - ); - - Ok(()) - } - - #[test] - fn build_accepts_pub_from_reexport_in_src_submodule_facade() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("session_facade_project"); - std::fs::create_dir_all(project_root.join("src/session"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"session_facade\"\nversion = \"0.1.0\"\n", - )?; - std::fs::write( - project_root.join("src/session/types.incn"), - "pub class Session:\n pub id: int\n", - )?; - std::fs::write( - project_root.join("src/session/mod.incn"), - "pub from crate.session.types import Session\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "from session import Session\n\ndef main() -> None:\n s = Session(id=1)\n print(s.id)\n", - )?; - - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected `build` to accept src submodule facade re-export.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); - - Ok(()) - } - - #[test] - fn build_succeeds_for_imported_enum_loop_ownership() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("imported_enum_loop_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"imported_enum_loop\"\nversion = \"0.1.0\"\n", - )?; - std::fs::write( - project_root.join("src/rels.incn"), - "@derive(Clone)\npub enum ConformanceRel:\n Read\n Filter\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "from rels import ConformanceRel\n\ndef relation_kind_name_from_conformance(rel: ConformanceRel) -> str:\n match rel:\n ConformanceRel.Read =>\n return \"ReadRel\"\n _ =>\n return \"Other\"\n\ndef scenario_matches(required: list[ConformanceRel]) -> bool:\n for expected in required:\n if expected == ConformanceRel.Read:\n if relation_kind_name_from_conformance(expected) == \"ReadRel\":\n return true\n return false\n\ndef main() -> None:\n println(scenario_matches([ConformanceRel.Read]))\n", - )?; - - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected imported enum loop project to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); - - Ok(()) - } - - #[test] - fn build_succeeds_for_len_comparison_on_recursive_list_field() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("len_comparison_recursive_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"len_comparison_recursive\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "@derive(Clone)\npub enum ExprKind:\n Column\n Add\n\n@derive(Clone)\npub model Expr:\n pub kind: ExprKind\n pub column_name: str\n pub arguments: list[Expr]\n\npub def lower(expr: Expr) -> int:\n if expr.kind == ExprKind.Column:\n return 0\n if len(expr.arguments) < 2:\n return -1\n return 1\n\ndef main() -> None:\n println(lower(Expr(kind=ExprKind.Add, column_name=\"root\", arguments=[])))\n", - )?; - - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected recursive list-field len comparison project to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); - - Ok(()) - } - - #[test] - fn build_succeeds_for_loop_helper_shared_string_list() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("loop_helper_shared_string_list_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"loop_helper_shared_string_list\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "def match_index(xs: list[str], y: int) -> int:\n mut idx = 0\n while idx < len(xs):\n if len(xs[idx]) == y:\n return idx\n idx = idx + 1\n return -1\n\n\ -def helper_loop(xs: list[str], ys: list[int]) -> list[int]:\n mut out: list[int] = []\n for y in ys:\n out.append(match_index(xs, y))\n return out\n\n\ -def main() -> None:\n helper_loop([\"a\", \"bb\", \"ccc\"], [1, 2])\n", - )?; - - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected loop helper shared string-list project to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); - - Ok(()) - } - - #[test] - fn build_succeeds_for_dict_comp_reusing_noncopy_key() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("dict_comp_reuses_noncopy_key_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"dict_comp_reuses_noncopy_key\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "def lengths(names: list[str]) -> dict[str, int]:\n return {name: len(name) for name in names}\n\n\ -def main() -> None:\n values = lengths([\"alice\", \"bob\"])\n println(values[\"alice\"])\n", - )?; - - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected dict comprehension with reused non-Copy key to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); - - Ok(()) - } - - #[test] - fn build_succeeds_for_for_tuple_unpack_enumerate() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("for_tuple_unpack_enumerate_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"for_tuple_unpack_enumerate\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "model Binding:\n name: str\n output_index: int\n expr_index: int\n\n\ -def field_ref(index: int) -> int:\n return index\n\n\ -pub def bind(xs: list[str]) -> list[Binding]:\n mut out: list[Binding] = []\n for idx, name in enumerate(xs):\n out.append(Binding(name=name, output_index=idx, expr_index=field_ref(idx)))\n return out\n\n\ -def main() -> None:\n bind([\"a\", \"bb\"])\n", - )?; - - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected for-loop tuple unpacking with enumerate to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); - - Ok(()) - } - - #[test] - fn build_succeeds_for_list_comp_tuple_unpack_enumerate() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("list_comp_tuple_unpack_enumerate_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"list_comp_tuple_unpack_enumerate\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "model Binding:\n name: str\n output_index: int\n expr_index: int\n\n\ -def field_ref(index: int) -> int:\n return index\n\n\ -pub def bind(xs: list[str]) -> list[Binding]:\n return [Binding(name=name, output_index=idx, expr_index=field_ref(idx)) for idx, name in enumerate(xs)]\n\n\ -def main() -> None:\n bind([\"a\", \"bb\"])\n", + .join("target") + .join("lib") + .join("mylib.incnlib"); + std::fs::create_dir_all(dep_manifest_path.parent().ok_or("missing dependency manifest parent")?)?; + mylib_manifest_with_widget().write_to_path(&dep_manifest_path)?; + // Intentionally do not write Cargo.toml / src/lib.rs to exercise artifact-contract diagnostics. + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"app\"\n\n[dependencies]\nmylib = { path = \"deps/mylib\" }\n", + "from pub::mylib import Widget\n", )?; - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; + let output = run_check(&main_path)?; + assert!( + !output.status.success(), + "expected check to fail for missing crate artifacts, stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = strip_ansi_escapes(&String::from_utf8_lossy(&output.stderr)); assert!( - project_build.status.success(), - "expected list-comprehension tuple unpacking with enumerate to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) + stderr.contains("Missing generated crate artifacts for `pub::mylib`"), + "expected missing-artifact diagnostic, got:\n{stderr}" ); - Ok(()) } #[test] - fn build_succeeds_for_list_str_append_literal() -> Result<(), Box> { + fn check_reports_pub_library_artifact_mismatch() -> Result<(), Box> { let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("list_str_append_literal_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"list_str_append_literal\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "pub def columns(input_columns: list[str]) -> list[str]:\n mut columns: list[str] = []\n columns.append(input_columns[0])\n columns.append(\"count\")\n return columns\n\n\ -def main() -> None:\n columns([\"orders_total\"])\n", + let dep_artifact_root = tmp.path().join("deps").join("widgets-lib").join("target").join("lib"); + std::fs::create_dir_all(&dep_artifact_root)?; + let mut manifest = LibraryManifest::new("widgets_core", "0.1.0"); + manifest.exports.models.push(ModelExport { + name: "Widget".to_string(), + type_params: Vec::new(), + traits: Vec::new(), + trait_adoptions: Vec::new(), + derives: Vec::new(), + fields: Vec::new(), + methods: Vec::new(), + }); + manifest.write_to_path(&dep_artifact_root.join("widgets_core.incnlib"))?; + write_minimal_library_crate(&dep_artifact_root, "different_package_name")?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"app\"\n\n[dependencies]\nwidgets = { path = \"deps/widgets-lib\" }\n", + "from pub::widgets import Widget\n", )?; - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; + let output = run_check(&main_path)?; assert!( - project_build.status.success(), - "expected list[str] literal append to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) + !output.status.success(), + "expected check to fail for artifact mismatch, stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = strip_ansi_escapes(&String::from_utf8_lossy(&output.stderr)); + assert!( + stderr.contains("Generated crate metadata mismatch for `pub::widgets`"), + "expected artifact mismatch diagnostic, got:\n{stderr}" ); - Ok(()) } #[test] - fn build_succeeds_for_imported_sum_helper_shadowing() -> Result<(), Box> { + fn build_lib_artifacts_and_consumer_alias_typecheck() -> Result<(), Box> { let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("imported_sum_shadow_project"); - std::fs::create_dir_all(project_root.join("src"))?; + let producer_root = tmp.path().join("widgets_core_project"); + std::fs::create_dir_all(producer_root.join("src"))?; std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"imported_sum_shadow\"\nversion = \"0.1.0\"\n", + producer_root.join("incan.toml"), + "[project]\nname = \"widgets_core\"\nversion = \"0.1.0\"\n", )?; std::fs::write( - project_root.join("src/functions.incn"), - "pub model ColumnRef:\n pub name: str\n\npub model AggregateMeasure:\n pub column_name: str\n\npub def col(name: str) -> ColumnRef:\n return ColumnRef(name=name)\n\npub def sum(expr: ColumnRef) -> AggregateMeasure:\n return AggregateMeasure(column_name=expr.name)\n", + producer_root.join("src/widgets.incn"), + "pub model Widget:\n name: str\n\npub def make_widget(name: str) -> Widget:\n return Widget(name=name)\n", )?; - let main_path = project_root.join("src/main.incn"); std::fs::write( - &main_path, - "from functions import col, sum\n\ndef selected_column_name() -> str:\n amount = col(\"amount\")\n result = sum(amount)\n return result.column_name\n\ndef main() -> None:\n println(selected_column_name())\n", + producer_root.join("src/boxmod.incn"), + "pub class Box:\n def get[T with Clone](self, value: T) -> T:\n return value\n", + )?; + std::fs::write( + producer_root.join("src/lib.incn"), + "pub from boxmod import Box\npub from widgets import Widget, make_widget\n", )?; - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; + let producer_build = run_build_lib(&producer_root)?; assert!( - project_build.status.success(), - "expected imported sum helper to shadow builtin sum and build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) + producer_build.status.success(), + "expected `build --lib` to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&producer_build.stdout), + String::from_utf8_lossy(&producer_build.stderr) ); + let producer_artifact_root = producer_root.join("target").join("lib"); + assert!(producer_artifact_root.join("Cargo.toml").is_file()); + assert!(producer_artifact_root.join("src/lib.rs").is_file()); + assert!(producer_artifact_root.join("widgets_core.incnlib").is_file()); - Ok(()) - } - - #[test] - fn build_succeeds_for_cross_module_ordinary_union_forwarding() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("cross_module_union_project"); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"cross_module_union\"\nversion = \"0.1.0\"\n", - )?; - std::fs::write( - project_root.join("src/producers.incn"), - "pub def parse_value(flag: bool) -> int | str:\n if flag:\n return 1\n return \"fallback\"\n", - )?; + let consumer_root = tmp.path().join("consumer_app"); + std::fs::create_dir_all(consumer_root.join("src"))?; std::fs::write( - project_root.join("src/consumers.incn"), - "pub def describe(value: int | str) -> str:\n if isinstance(value, int):\n return \"number\"\n else:\n return value.upper()\n", + consumer_root.join("incan.toml"), + "[project]\nname = \"consumer\"\n\n[dependencies]\nwidgets = { path = \"../widgets_core_project\" }\n", )?; - let main_path = project_root.join("src/main.incn"); + let consumer_main = consumer_root.join("src/main.incn"); std::fs::write( - &main_path, - "from producers import parse_value\nfrom consumers import describe\n\n\ -def main() -> None:\n println(describe(parse_value(False)))\n println(describe(\"literal\"))\n", + &consumer_main, + "from pub::widgets import Box, Widget as PublicWidget, make_widget\n\ndef main() -> None:\n w: PublicWidget = make_widget(\"ok\")\n box: Box = Box()\n value: int = box.get(1)\n print(w.name)\n print(value)\n", )?; - let build_output = run_build(&main_path, &project_root.join("out"))?; + let consumer_check = run_check(&consumer_main)?; assert!( - build_output.status.success(), - "expected cross-module ordinary union project to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&build_output.stdout), - String::from_utf8_lossy(&build_output.stderr) + consumer_check.status.success(), + "expected consumer check to accept pub:: alias and generic carrier imports.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&consumer_check.stdout), + String::from_utf8_lossy(&consumer_check.stderr) ); Ok(()) } #[test] - fn build_succeeds_for_qualified_enum_constructor_match() -> Result<(), Box> { + fn build_succeeds_for_pub_import_regression_batch() -> Result<(), Box> { let tmp = tempfile::tempdir()?; - let project_root = tmp.path().join("enum_constructor_match_project"); + let project_root = tmp.path().join("pub_import_regression_batch_project"); std::fs::create_dir_all(project_root.join("src"))?; std::fs::write( project_root.join("incan.toml"), - "[project]\nname = \"enum_constructor_match\"\nversion = \"0.1.0\"\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write( - &main_path, - "pub enum ConformanceRel:\n Read\n Filter\n Project\n\npub def relation_kind_name_from_conformance(rel: ConformanceRel) -> str:\n match rel:\n ConformanceRel.Read =>\n return \"ReadRel\"\n ConformanceRel.Filter =>\n return \"FilterRel\"\n ConformanceRel.Project =>\n return \"ProjectRel\"\n _ =>\n return \"UnknownRel\"\n\ndef main() -> None:\n println(relation_kind_name_from_conformance(ConformanceRel.Filter))\n", + "[project]\nname = \"pub_import_regression_batch\"\nversion = \"0.1.0\"\n", )?; - let out_dir = project_root.join("out"); - let project_build = run_build(&main_path, &out_dir)?; - assert!( - project_build.status.success(), - "expected qualified enum constructor match project to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&project_build.stdout), - String::from_utf8_lossy(&project_build.stderr) - ); + let files = [ + ( + "src/session/types.incn", + r#"pub class Session: + pub id: int +"#, + ), + ("src/session/mod.incn", "pub from crate.session.types import Session\n"), + ( + "src/session_facade_case.incn", + r#"from session import Session - Ok(()) - } +pub def run_session_facade() -> None: + s = Session(id=1) + print(s.id) +"#, + ), + ( + "src/imported_enum_loop_rels.incn", + r#"@derive(Clone) +pub enum ConformanceRel: + Read + Filter +"#, + ), + ( + "src/imported_enum_loop_case.incn", + r#"from imported_enum_loop_rels import ConformanceRel + +def relation_kind_name_from_conformance(rel: ConformanceRel) -> str: + match rel: + ConformanceRel.Read => + return "ReadRel" + _ => + return "Other" + +def scenario_matches(required: list[ConformanceRel]) -> bool: + for expected in required: + if expected == ConformanceRel.Read: + if relation_kind_name_from_conformance(expected) == "ReadRel": + return true + return false + +pub def run_imported_enum_loop() -> None: + println(scenario_matches([ConformanceRel.Read])) +"#, + ), + ( + "src/len_comparison_recursive_case.incn", + r#"@derive(Clone) +pub enum ExprKind: + Column + Add - #[test] - fn build_and_run_rfc088_iterator_adapter_pipeline() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"rfc088_iterator_pipeline\"\nversion = \"0.1.0\"\n", - "def is_even(n: int) -> bool:\n return n % 2 == 0\n\n\ -def double(n: int) -> int:\n return n * 2\n\n\ -def main() -> None:\n xs = [1, 2, 3, 4, 5]\n ys = xs.iter().filter(is_even).map(double).take(2).collect()\n batches = xs.iter().batch(2).collect()\n println(len(ys))\n println(ys[0])\n println(len(batches))\n", - )?; +@derive(Clone) +pub model Expr: + pub kind: ExprKind + pub column_name: str + pub arguments: list[Expr] + +pub def lower(expr: Expr) -> int: + if expr.kind == ExprKind.Column: + return 0 + if len(expr.arguments) < 2: + return -1 + return 1 - let out_dir = tmp.path().join("out"); - let build_output = run_build(&main_path, &out_dir)?; - assert!( - build_output.status.success(), - "expected RFC 088 iterator pipeline to build successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&build_output.stdout), - String::from_utf8_lossy(&build_output.stderr) - ); +pub def run_len_comparison_recursive() -> None: + println(lower(Expr(kind=ExprKind.Add, column_name="root", arguments=[]))) +"#, + ), + ( + "src/loop_helper_shared_string_list_case.incn", + r#"def match_index(xs: list[str], y: int) -> int: + mut idx = 0 + while idx < len(xs): + if len(xs[idx]) == y: + return idx + idx = idx + 1 + return -1 + +def helper_loop(xs: list[str], ys: list[int]) -> list[int]: + mut out: list[int] = [] + for y in ys: + out.append(match_index(xs, y)) + return out + +pub def run_loop_helper_shared_string_list() -> None: + helper_loop(["a", "bb", "ccc"], [1, 2]) +"#, + ), + ( + "src/dict_comp_reuses_noncopy_key_case.incn", + r#"def lengths(names: list[str]) -> dict[str, int]: + return {name: len(name) for name in names} - let run_output = Command::new(incan_bin_path()) - .args(["run", main_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - run_output.status.success(), - "expected RFC 088 iterator pipeline to run successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&run_output.stdout), - String::from_utf8_lossy(&run_output.stderr) - ); +pub def run_dict_comp_reuses_noncopy_key() -> None: + values = lengths(["alice", "bob"]) + println(values["alice"]) +"#, + ), + ( + "src/tuple_unpack_enumerate_cases.incn", + r#"model Binding: + name: str + output_index: int + expr_index: int - let stdout = String::from_utf8_lossy(&run_output.stdout); - assert_eq!(stdout.lines().collect::>(), vec!["2", "4", "3"]); +def field_ref(index: int) -> int: + return index - Ok(()) - } +def bind_loop(xs: list[str]) -> list[Binding]: + mut out: list[Binding] = [] + for idx, name in enumerate(xs): + out.append(Binding(name=name, output_index=idx, expr_index=field_ref(idx))) + return out - #[test] - fn build_and_run_list_comprehension_stays_eager_after_rfc088() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"rfc088_comprehension_regression\"\nversion = \"0.1.0\"\n", - "def main() -> None:\n xs = [1, 2, 3]\n ys = [n * 2 for n in xs if n > 1]\n println(len(ys))\n println(ys[0])\n println(len(xs))\n", - )?; +def bind_comp(xs: list[str]) -> list[Binding]: + return [Binding(name=name, output_index=idx, expr_index=field_ref(idx)) for idx, name in enumerate(xs)] - let out_dir = tmp.path().join("out"); - let build_output = run_build(&main_path, &out_dir)?; +pub def run_tuple_unpack_enumerate_cases() -> None: + bind_loop(["a", "bb"]) + bind_comp(["a", "bb"]) +"#, + ), + ( + "src/list_str_append_literal_case.incn", + r#"pub def columns(input_columns: list[str]) -> list[str]: + mut columns: list[str] = [] + columns.append(input_columns[0]) + columns.append("count") + return columns + +pub def run_list_str_append_literal() -> None: + columns(["orders_total"]) +"#, + ), + ( + "src/imported_sum_functions.incn", + r#"pub model ColumnRef: + pub name: str + +pub model AggregateMeasure: + pub column_name: str + +pub def col(name: str) -> ColumnRef: + return ColumnRef(name=name) + +pub def sum(expr: ColumnRef) -> AggregateMeasure: + return AggregateMeasure(column_name=expr.name) +"#, + ), + ( + "src/imported_sum_shadow_case.incn", + r#"from imported_sum_functions import col, sum + +def selected_column_name() -> str: + amount = col("amount") + result = sum(amount) + return result.column_name + +pub def run_imported_sum_shadow() -> None: + println(selected_column_name()) +"#, + ), + ( + "src/cross_module_union_producers.incn", + r#"pub def parse_value(flag: bool) -> int | str: + if flag: + return 1 + return "fallback" +"#, + ), + ( + "src/cross_module_union_consumers.incn", + r#"pub def describe(value: int | str) -> str: + if isinstance(value, int): + return "number" + else: + return value.upper() +"#, + ), + ( + "src/cross_module_union_case.incn", + r#"from cross_module_union_producers import parse_value +from cross_module_union_consumers import describe + +pub def run_cross_module_union() -> None: + println(describe(parse_value(False))) + println(describe("literal")) +"#, + ), + ( + "src/qualified_enum_constructor_match_case.incn", + r#"pub enum QualifiedConformanceRel: + Read + Filter + Project + +pub def relation_kind_name_from_conformance(rel: QualifiedConformanceRel) -> str: + match rel: + QualifiedConformanceRel.Read => + return "ReadRel" + QualifiedConformanceRel.Filter => + return "FilterRel" + QualifiedConformanceRel.Project => + return "ProjectRel" + _ => + return "UnknownRel" + +pub def run_qualified_enum_constructor_match() -> None: + println(relation_kind_name_from_conformance(QualifiedConformanceRel.Filter)) +"#, + ), + ( + "src/main.incn", + r#"from cross_module_union_case import run_cross_module_union +from dict_comp_reuses_noncopy_key_case import run_dict_comp_reuses_noncopy_key +from imported_enum_loop_case import run_imported_enum_loop +from imported_sum_shadow_case import run_imported_sum_shadow +from len_comparison_recursive_case import run_len_comparison_recursive +from list_str_append_literal_case import run_list_str_append_literal +from loop_helper_shared_string_list_case import run_loop_helper_shared_string_list +from qualified_enum_constructor_match_case import run_qualified_enum_constructor_match +from session_facade_case import run_session_facade +from tuple_unpack_enumerate_cases import run_tuple_unpack_enumerate_cases + +def main() -> None: + run_session_facade() + run_imported_enum_loop() + run_len_comparison_recursive() + run_loop_helper_shared_string_list() + run_dict_comp_reuses_noncopy_key() + run_tuple_unpack_enumerate_cases() + run_list_str_append_literal() + run_imported_sum_shadow() + run_cross_module_union() + run_qualified_enum_constructor_match() +"#, + ), + ]; + + for (relative, source) in files { + let path = project_root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, source)?; + } + + let main_path = project_root.join("src/main.incn"); + let build_output = run_build(&main_path, &project_root.join("out"))?; assert!( build_output.status.success(), - "expected eager list comprehension regression to build successfully.\nstdout:\n{}\nstderr:\n{}", + "expected pub import regression batch project to build successfully.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&build_output.stdout), String::from_utf8_lossy(&build_output.stderr) ); - let run_output = Command::new(incan_bin_path()) - .args(["run", main_path.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - run_output.status.success(), - "expected eager list comprehension regression to run successfully.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&run_output.stdout), - String::from_utf8_lossy(&run_output.stderr) - ); - - let stdout = String::from_utf8_lossy(&run_output.stdout); - assert_eq!(stdout.lines().collect::>(), vec!["2", "4", "3"]); - Ok(()) } #[test] - fn build_and_run_rfc049_if_let_while_let() -> Result<(), Box> { + fn build_and_run_iterator_comprehension_and_if_let_scenarios() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let main_path = write_project_files( tmp.path(), - "[project]\nname = \"rfc049_if_let_while_let\"\nversion = \"0.1.0\"\n", - "def maybe_double(opt: Option[int]) -> int:\n if let Some(value) = opt:\n return value * 2\n return 0\n\n\ + "[project]\nname = \"iterator_comprehension_if_let_batch\"\nversion = \"0.1.0\"\n", + "def is_even(n: int) -> bool:\n return n % 2 == 0\n\n\ +def double(n: int) -> int:\n return n * 2\n\n\ +def maybe_double(opt: Option[int]) -> int:\n if let Some(value) = opt:\n return value * 2\n return 0\n\n\ def next_value(values: list[Option[int]], idx: int) -> Option[int]:\n if idx < len(values):\n return values[idx]\n return None\n\n\ def sum_values(values: list[Option[int]]) -> int:\n mut idx = 0\n mut total = 0\n while let Some(value) = next_value(values, idx):\n total = total + value\n idx = idx + 1\n return total\n\n\ -def main() -> None:\n println(maybe_double(Some(21)))\n println(maybe_double(None))\n println(sum_values([Some(1), Some(2), None, Some(99)]))\n", +def main() -> None:\n xs = [1, 2, 3, 4, 5]\n ys = xs.iter().filter(is_even).map(double).take(2).collect()\n batches = xs.iter().batch(2).collect()\n println(len(ys))\n println(ys[0])\n println(len(batches))\n comp_source = [1, 2, 3]\n comp = [n * 2 for n in comp_source if n > 1]\n println(len(comp))\n println(comp[0])\n println(len(comp_source))\n println(maybe_double(Some(21)))\n println(maybe_double(None))\n println(sum_values([Some(1), Some(2), None, Some(99)]))\n", )?; let out_dir = tmp.path().join("out"); let build_output = run_build(&main_path, &out_dir)?; assert!( build_output.status.success(), - "expected RFC 049 sample project to build successfully.\nstdout:\n{}\nstderr:\n{}", + "expected iterator/comprehension/if-let batch to build successfully.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&build_output.stdout), String::from_utf8_lossy(&build_output.stderr) ); - let run_output = Command::new(incan_bin_path()) + let run_output = super::incan_command() .args(["run", main_path.to_string_lossy().as_ref()]) .env("CARGO_NET_OFFLINE", "true") .output()?; assert!( run_output.status.success(), - "expected RFC 049 sample project to run successfully.\nstdout:\n{}\nstderr:\n{}", + "expected iterator/comprehension/if-let batch to run successfully.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&run_output.stdout), String::from_utf8_lossy(&run_output.stderr) ); let stdout = String::from_utf8_lossy(&run_output.stdout); - assert_eq!(stdout.lines().collect::>(), vec!["42", "0", "3"]); + assert_eq!( + stdout.lines().collect::>(), + vec!["2", "4", "3", "2", "4", "3", "42", "0", "3"] + ); Ok(()) } @@ -13251,84 +11569,38 @@ def main() -> None:\n println(maybe_double(Some(21)))\n println(maybe_double(N producer_root.join("incan.toml"), "[project]\nname = \"widgets_core\"\nversion = \"0.1.0\"\n\n[vocab]\ncrate = \"vocab_companion\"\n", )?; - std::fs::write( - producer_root.join("src/lib.incn"), - "pub def make_widget(name: str) -> str:\n return name\n", - )?; - write_vocab_companion_crate(&producer_root, "vocab_companion", "widgets_vocab_companion")?; - - let producer_build = run_build_lib(&producer_root)?; - assert!( - producer_build.status.success(), - "expected `build --lib` with vocab companion to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&producer_build.stdout), - String::from_utf8_lossy(&producer_build.stderr) - ); - - let manifest_path = producer_root.join("target").join("lib").join("widgets_core.incnlib"); - let manifest = LibraryManifest::read_from_path(&manifest_path)?; - let vocab = manifest.vocab.as_ref().ok_or("expected vocab payload in .incnlib")?; - assert_eq!(vocab.crate_path, "vocab_companion"); - assert_eq!(vocab.package_name, "widgets_vocab_companion"); - assert_eq!(vocab.keyword_registrations.len(), 1); - assert_eq!( - manifest.soft_keywords.activations, - vec![incan::library_manifest::SoftKeywordActivation { - namespace: "widgets.dsl".to_string(), - keyword: "await".to_string(), - }] - ); - Ok(()) - } - - #[test] - fn build_lib_preserves_generic_instance_methods_for_consumers() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let producer_root = tmp.path().join("generic_methods_lib"); - std::fs::create_dir_all(producer_root.join("src"))?; - std::fs::write( - producer_root.join("incan.toml"), - "[project]\nname = \"generic_methods_core\"\nversion = \"0.1.0\"\n", - )?; - std::fs::write( - producer_root.join("src/boxmod.incn"), - "pub class Box:\n def get[T with Clone](self, value: T) -> T:\n return value\n", + std::fs::write( + producer_root.join("src/lib.incn"), + "pub def make_widget(name: str) -> str:\n return name\n", )?; - std::fs::write(producer_root.join("src/lib.incn"), "pub from boxmod import Box\n")?; + write_vocab_companion_crate(&producer_root, "vocab_companion", "widgets_vocab_companion")?; let producer_build = run_build_lib(&producer_root)?; assert!( producer_build.status.success(), - "expected `build --lib` to succeed.\nstdout:\n{}\nstderr:\n{}", + "expected `build --lib` with vocab companion to succeed.\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&producer_build.stdout), String::from_utf8_lossy(&producer_build.stderr) ); - let consumer_root = tmp.path().join("generic_methods_consumer"); - std::fs::create_dir_all(consumer_root.join("src"))?; - std::fs::write( - consumer_root.join("incan.toml"), - "[project]\nname = \"consumer\"\n\n[dependencies]\nboxlib = { path = \"../generic_methods_lib\" }\n", - )?; - let consumer_main = consumer_root.join("src/main.incn"); - std::fs::write( - &consumer_main, - "from pub::boxlib import Box\n\ndef main() -> None:\n box: Box = Box()\n value: int = box.get(1)\n print(value)\n", - )?; - - let out_dir = consumer_root.join("out"); - let consumer_build = run_build(&consumer_main, &out_dir)?; - assert!( - consumer_build.status.success(), - "expected consumer build to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&consumer_build.stdout), - String::from_utf8_lossy(&consumer_build.stderr) + let manifest_path = producer_root.join("target").join("lib").join("widgets_core.incnlib"); + let manifest = LibraryManifest::read_from_path(&manifest_path)?; + let vocab = manifest.vocab.as_ref().ok_or("expected vocab payload in .incnlib")?; + assert_eq!(vocab.crate_path, "vocab_companion"); + assert_eq!(vocab.package_name, "widgets_vocab_companion"); + assert_eq!(vocab.keyword_registrations.len(), 1); + assert_eq!( + manifest.soft_keywords.activations, + vec![incan::library_manifest::SoftKeywordActivation { + namespace: "widgets.dsl".to_string(), + keyword: "await".to_string(), + }] ); Ok(()) } #[test] - fn build_lib_preserves_ordinal_map_for_consumers() -> Result<(), Box> { + fn build_lib_preserves_ordinal_map_metadata_for_consumer_check() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let producer_root = tmp.path().join("ordinal_keys_lib"); std::fs::create_dir_all(producer_root.join("src"))?; @@ -13457,37 +11729,26 @@ pub def small_key_map_bytes() -> bytes: "from std.collections import OrdinalMap, OrdinalMapError\nfrom pub::ordinal_keys import SmallKey, Status, echo_key, small_key_map_bytes, status_map_bytes\n\ndef run() -> Result[None, OrdinalMapError]:\n probe = echo_key(\"probe\")\n if len(probe) == 0:\n print(probe)\n status_map: OrdinalMap[Status] = OrdinalMap.from_bytes(status_map_bytes())?\n small_key_map: OrdinalMap[SmallKey] = OrdinalMap.from_bytes(small_key_map_bytes())?\n print(status_map.require(Status.Paid)?)\n print(small_key_map.require(SmallKey(value=2))?)\n return Ok(None)\n\ndef main() -> None:\n match run():\n Ok(_) => pass\n Err(err) => print(err.message())\n", )?; - let out_dir = consumer_root.join("out"); - let consumer_build = run_build(&consumer_main, &out_dir)?; - assert!( - consumer_build.status.success(), - "expected consumer build to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&consumer_build.stdout), - String::from_utf8_lossy(&consumer_build.stderr) - ); - let consumer_run = Command::new(incan_bin_path()) - .args(["run", consumer_main.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; + let consumer_check = run_check(&consumer_main)?; assert!( - consumer_run.status.success(), - "expected consumer run to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&consumer_run.stdout), - String::from_utf8_lossy(&consumer_run.stderr) + consumer_check.status.success(), + "expected consumer check to accept imported OrdinalMap metadata.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&consumer_check.stdout), + String::from_utf8_lossy(&consumer_check.stderr) ); - assert_eq!(String::from_utf8_lossy(&consumer_run.stdout).trim(), "1\n20"); Ok(()) } #[test] - fn check_pub_boundary_preserves_method_result_types_for_question_mark() -> Result<(), Box> { + fn check_pub_boundary_preserves_consumer_type_fidelity_cases() -> Result<(), Box> { let tmp = tempfile::tempdir()?; write_pub_boundary_type_fidelity_library(tmp.path())?; - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"consumer\"\n\n[dependencies]\npubdemo = { path = \"pub_boundary_library\" }\n", - r#"from pub::pubdemo import LazyFrame, SessionError + let cases = [ + ( + "question_mark_result", + "`lazy.collect()?` across pub boundary", + r#"from pub::pubdemo import LazyFrame, SessionError model Row: value: int @@ -13498,27 +11759,11 @@ def main() -> Result[None, SessionError]: print(df.to_substrait_plan()) return Ok(None) "#, - )?; - - let output = run_check(&main_path)?; - assert!( - output.status.success(), - "expected `lazy.collect()?` across pub boundary to typecheck.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - } - - #[test] - fn check_pub_boundary_preserves_derived_method_chain_result_types() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - write_pub_boundary_type_fidelity_library(tmp.path())?; - - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"consumer\"\n\n[dependencies]\npubdemo = { path = \"pub_boundary_library\" }\n", - r#"from pub::pubdemo import LazyFrame, SessionError + ), + ( + "derived_method_chain", + "`lazy.clone().collect()?` across pub boundary", + r#"from pub::pubdemo import LazyFrame, SessionError model Row: value: int @@ -13529,27 +11774,11 @@ def main() -> Result[None, SessionError]: print(df.to_substrait_plan()) return Ok(None) "#, - )?; - - let output = run_check(&main_path)?; - assert!( - output.status.success(), - "expected `lazy.clone().collect()?` across pub boundary to typecheck.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Ok(()) - } - - #[test] - fn check_pub_boundary_preserves_trait_supertype_acceptance() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - write_pub_boundary_type_fidelity_library(tmp.path())?; - - let main_path = write_project_files( - tmp.path(), - "[project]\nname = \"consumer\"\n\n[dependencies]\npubdemo = { path = \"pub_boundary_library\" }\n", - r#"from pub::pubdemo import DataFrame, SessionError, display + ), + ( + "trait_supertype", + "`DataFrame[T]` satisfying `DataSet[T]` across pub boundary", + r#"from pub::pubdemo import DataFrame, SessionError, display model Row: value: int @@ -13559,15 +11788,25 @@ def main() -> Result[None, SessionError]: display(df) return Ok(None) "#, - )?; + ), + ]; - let output = run_check(&main_path)?; - assert!( - output.status.success(), - "expected `DataFrame[T]` to satisfy `DataSet[T]` across pub boundary.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + for (name, description, source) in cases { + let case_root = tmp.path().join(name); + let main_path = write_project_files( + &case_root, + "[project]\nname = \"consumer\"\n\n[dependencies]\npubdemo = { path = \"../pub_boundary_library\" }\n", + source, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected {description} to typecheck.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } Ok(()) } @@ -13764,166 +12003,6 @@ def main() -> Result[None, SessionError]: Ok(()) } - #[test] - fn consumer_run_accepts_nested_real_wasm_desugar_output() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let producer_root = tmp.path().join("nested_vocab_project"); - std::fs::create_dir_all(producer_root.join("src"))?; - std::fs::write( - producer_root.join("incan.toml"), - "[project]\nname = \"nested_core\"\nversion = \"0.1.0\"\n\n[vocab]\ncrate = \"vocab_companion\"\n", - )?; - std::fs::write( - producer_root.join("src/helpers.incn"), - r#"pub def surface_with_governance( - name: str, - title: str, - base: str, - actions: list[str], - layouts: list[str], - pages: list[str], - projections: list[str], -) -> str: - return name - -pub def action(name: str, capability: str, required_evidence: str) -> str: - return name - -pub def layout(name: str, regions: list[str]) -> str: - return name - -pub def page_with_interactions( - name: str, - route: str, - title: str, - layout_name: str, - regions: list[str], - interactions: list[str], -) -> str: - return name - -pub def region(name: str, nodes: list[str]) -> str: - return name - -pub def heading(text: str) -> str: - return text - -pub def text(text: str) -> str: - return text - -pub def interaction(name: str, action_name: str, constraints: list[str]) -> str: - return name - -pub def required_input( - interaction_name: str, - field: str, - label: str, - min_length: str, - evidence_key: str, -) -> str: - return field - -pub def projection(name: str, target: str) -> str: - return name -"#, - )?; - std::fs::write( - producer_root.join("src/lib.incn"), - "pub from helpers import action, heading, interaction, layout, page_with_interactions, projection, region, required_input, surface_with_governance, text\n", - )?; - write_nested_wasm_vocab_companion_crate(&producer_root, "vocab_companion", "nested_vocab_companion")?; - - let producer_build = run_build_lib(&producer_root)?; - assert!( - producer_build.status.success(), - "expected `build --lib` with real wasm vocab companion to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&producer_build.stdout), - String::from_utf8_lossy(&producer_build.stderr) - ); - - let consumer_root = tmp.path().join("nested_consumer"); - let consumer_name = unique_test_project_name("nested_consumer"); - let consumer_main = write_project_files( - &consumer_root, - &format!( - "[project]\nname = \"{consumer_name}\"\n\n[dependencies]\nnested = {{ path = \"../nested_vocab_project\" }}\n" - ), - r#"import pub::nested - -def main() -> None: - compose FullNestedCase: - title = "Full Nested Case" - base = "/" - - action EscalateCase: - capability = "case.escalate" - requires = "escalation.explanation" - - layout SimplePage: - region body: - pass - - page Review: - route = "/cases/123" - title = "Case Review" - layout = "SimplePage" - - region body: - heading "Case Review": - pass - text "High risk case requires escalation review.": - pass - - interaction Escalate: - action = "EscalateCase" - - require input: - field = "explanation" - label = "Explanation" - min_length = 20 - evidence = "escalation.explanation" - - projection web: - target = "static-web" - "#, - )?; - - let out_dir = consumer_root.join("out"); - let consumer_build = run_build(&consumer_main, &out_dir)?; - assert!( - consumer_build.status.success(), - "expected consumer build to accept nested real wasm desugar output.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&consumer_build.stdout), - String::from_utf8_lossy(&consumer_build.stderr) - ); - - let generated_main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; - assert!( - generated_main_rs.contains("__incan_vocab_helper_nested_surface_with_governance"), - "expected hidden helper alias for nested surface output, got:\n{generated_main_rs}" - ); - assert!( - generated_main_rs.contains("__incan_vocab_helper_nested_required_input"), - "expected hidden helper alias for nested required-input output, got:\n{generated_main_rs}" - ); - assert!( - generated_main_rs.contains("let _nested_artifact ="), - "expected wasm desugar output to splice a let binding, got:\n{generated_main_rs}" - ); - - let run_output = Command::new(incan_bin_path()) - .args(["run", consumer_main.to_string_lossy().as_ref()]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - run_output.status.success(), - "expected consumer run to accept nested real wasm desugar output.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&run_output.stdout), - String::from_utf8_lossy(&run_output.stderr) - ); - Ok(()) - } - #[test] fn consumer_build_injects_helper_import_for_vocab_desugarer_calls() -> Result<(), Box> { let tmp = tempfile::tempdir()?; @@ -14039,7 +12118,7 @@ def main() -> None: } #[test] - fn equivalent_helper_backed_keywords_emit_identical_rust() -> Result<(), Box> { + fn equivalent_helper_backed_keywords_typecheck() -> Result<(), Box> { let tmp = tempfile::tempdir()?; let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Call { callee: Box::new(incan_vocab::IncanExpr::Helper("filter".to_string())), @@ -14066,41 +12145,33 @@ def main() -> None: "import pub::querykit\n\ndef main() -> None:\n screen true:\n pass\n", )?; - let where_out = tmp.path().join("where_out"); - let where_build = run_build(&where_main, &where_out)?; + let where_check = run_check(&where_main)?; assert!( - where_build.status.success(), - "expected helper-backed `where` build to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&where_build.stdout), - String::from_utf8_lossy(&where_build.stderr) + where_check.status.success(), + "expected helper-backed `where` check to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&where_check.stdout), + String::from_utf8_lossy(&where_check.stderr) ); - let screen_out = tmp.path().join("screen_out"); - let screen_build = run_build(&screen_main, &screen_out)?; + let screen_check = run_check(&screen_main)?; assert!( - screen_build.status.success(), - "expected helper-backed `screen` build to succeed.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&screen_build.stdout), - String::from_utf8_lossy(&screen_build.stderr) - ); - - let where_rust = std::fs::read_to_string(where_out.join("src/main.rs"))?; - let screen_rust = std::fs::read_to_string(screen_out.join("src/main.rs"))?; - assert_eq!( - where_rust, screen_rust, - "expected equivalent helper-backed keywords to emit identical Rust" + screen_check.status.success(), + "expected helper-backed `screen` check to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&screen_check.stdout), + String::from_utf8_lossy(&screen_check.stderr) ); Ok(()) } #[test] - fn provider_requirements_flow_through_build_test_and_lock() -> Result<(), Box> { + fn provider_requirements_and_pub_vocab_flow_through_build_test_and_lock() -> Result<(), Box> + { let tmp = tempfile::tempdir()?; let project_root = tmp.path(); std::fs::create_dir_all(project_root.join("src"))?; std::fs::create_dir_all(project_root.join("tests"))?; - write_pub_library_with_provider_requirements( + write_pub_library_with_provider_requirements_and_assert_keyword( project_root, "widgets", "widgets_core", @@ -14119,7 +12190,7 @@ def main() -> None: std::fs::write(&main_path, "def main() -> None:\n pass\n")?; std::fs::write( project_root.join("tests/test_provider.incn"), - "def test_provider_parity() -> None:\n pass\n", + "import pub::widgets\n\ndef test_provider_parity() -> None:\n assert true\n", )?; let build_out_dir = project_root.join("out"); @@ -14178,65 +12249,6 @@ def main() -> None: Ok(()) } - #[test] - fn test_runner_activates_pub_vocab_keywords_from_dependency_manifest() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path(); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::create_dir_all(project_root.join("tests"))?; - - write_pub_library_with_assert_keyword(project_root, "widgets", "widgets_core")?; - - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"consumer\"\n\n[dependencies]\nwidgets = { path = \"deps/widgets\" }\n", - )?; - std::fs::write(project_root.join("src/main.incn"), "def main() -> None:\n pass\n")?; - std::fs::write( - project_root.join("tests/test_pub_vocab.incn"), - "import pub::widgets\n\ndef test_pub_vocab() -> None:\n assert true\n", - )?; - - let test_output = run_test(&project_root.join("tests"))?; - assert!( - test_output.status.success(), - "expected `incan test` to honor serialized pub vocab keywords.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&test_output.stdout), - String::from_utf8_lossy(&test_output.stderr) - ); - Ok(()) - } - - #[test] - fn lock_parses_tests_using_pub_vocab_keywords() -> Result<(), Box> { - let tmp = tempfile::tempdir()?; - let project_root = tmp.path(); - std::fs::create_dir_all(project_root.join("src"))?; - std::fs::create_dir_all(project_root.join("tests"))?; - - write_pub_library_with_assert_keyword(project_root, "widgets", "widgets_core")?; - - std::fs::write( - project_root.join("incan.toml"), - "[project]\nname = \"consumer\"\n\n[dependencies]\nwidgets = { path = \"deps/widgets\" }\n", - )?; - let main_path = project_root.join("src/main.incn"); - std::fs::write(&main_path, "def main() -> None:\n pass\n")?; - std::fs::write( - project_root.join("tests/test_pub_vocab.incn"), - "import pub::widgets\n\ndef test_pub_vocab() -> None:\n assert true\n", - )?; - - let lock_output = run_lock(&main_path)?; - assert!( - lock_output.status.success(), - "expected `incan lock` to parse test files with pub vocab keywords.\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&lock_output.stdout), - String::from_utf8_lossy(&lock_output.stderr) - ); - Ok(()) - } - #[test] fn conflicting_provider_requirements_fail_build_test_and_lock() -> Result<(), Box> { let tmp = tempfile::tempdir()?; @@ -14329,74 +12341,4 @@ def main() -> None: Ok(()) } - - #[test] - fn test_std_tempfile_compile_and_run_named_file_and_directory() -> Result<(), Box> { - let source = r#" -from std.fs import IoError, Path -from std.tempfile import NamedTemporaryFile, SpooledTemporaryFile, TemporaryDirectory - -def run() -> Result[None, IoError]: - file = NamedTemporaryFile.try_new_with("incan-", ".txt", None)? - path = file.path() - path.write_text("hello", "utf-8", "strict", None)? - println(path.read_text("utf-8", "strict")?) - - directory = TemporaryDirectory.try_new_with("incan-dir-", "", None)? - child = directory.path() / "child.txt" - child.write_text("world", "utf-8", "strict", None)? - println(child.read_text("utf-8", "strict")?) - - mut memory = SpooledTemporaryFile(max_size=64) - memory.write(b"memory")? - println(memory.rolled_to_disk()) - memory.seek(0, 0)? - println(len(memory.read(-1)?)) - - mut spool = SpooledTemporaryFile(max_size=4) - spool.write(b"rolled")? - println(spool.rolled_to_disk()) - println(spool.path()?.exists()) - spool.seek(0, 0)? - println(len(spool.read(-1)?)) - kept_spool = spool.persist()? - println(kept_spool.exists()) - kept_spool.unlink()? - - kept_file = file.persist()? - println(kept_file.exists()) - kept_file.unlink()? - - kept_directory = directory.persist()? - println(kept_directory.exists()) - kept_directory.remove_tree()? - return Ok(None) - -def main() -> None: - match run(): - Ok(_) => pass - Err(err) => println(err.message()) -"#; - let output = Command::new(incan_debug_binary()) - .args(["run", "-c", source]) - .env("CARGO_NET_OFFLINE", "true") - .output()?; - assert!( - output.status.success(), - "incan run std.tempfile smoke failed: status={:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let lines = stdout.lines().collect::>(); - assert_eq!( - lines, - vec![ - "hello", "world", "false", "6", "true", "true", "6", "true", "true", "true", - ], - "unexpected std.tempfile output:\n{stdout}" - ); - Ok(()) - } } diff --git a/tests/std_encoding_algorithm_modules.rs b/tests/std_encoding_algorithm_modules.rs index c455655ad..054dbeb8b 100644 --- a/tests/std_encoding_algorithm_modules.rs +++ b/tests/std_encoding_algorithm_modules.rs @@ -1,29 +1,25 @@ use std::fs; use std::process::Command; -use std::sync::Mutex; -static INCAN_RUN_LOCK: Mutex<()> = Mutex::new(()); - -fn run_module_case(module_path: &str, assertions: &str) -> Result<(), Box> { - let _guard = match INCAN_RUN_LOCK.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - let module_source = fs::read_to_string(module_path)?; +fn run_source_case(source: &str) -> Result<(), Box> { let dir = tempfile::tempdir()?; let source_path = dir.path().join("main.incn"); - fs::write(&source_path, format!("{module_source}\n\n{assertions}"))?; + fs::write(&source_path, source)?; let output = Command::new(env!("CARGO_BIN_EXE_incan")) .arg("--no-banner") .arg("run") .arg(&source_path) .env("CARGO_NET_OFFLINE", "true") + .env( + "INCAN_GENERATED_CARGO_TARGET_DIR", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/incan_generated_shared_target"), + ) .output()?; assert!( output.status.success(), - "module case failed for {module_path}\nstdout:\n{}\nstderr:\n{}", + "encoding algorithm case failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -31,11 +27,16 @@ fn run_module_case(module_path: &str, assertions: &str) -> Result<(), Box Result<(), Box> { - run_module_case( - "crates/incan_stdlib/stdlib/encoding/base64.incn", - r#" -def main() -> None: +fn std_encoding_algorithm_vectors_and_invalid_cases() -> Result<(), Box> { + run_source_case( + r#"from std.encoding.base32 import b32decode, b32decode_lenient, b32encode, b32hexencode +from std.encoding._shared import EncodingError +from std.encoding.base58 import b58decode, b58decode_lenient, b58encode +from std.encoding.base64 import b64decode, b64decode_lenient, b64encode, urlsafe_b64encode +from std.encoding.base85 import a85decode_lenient, a85encode, b85decode, b85encode, z85decode, z85encode +from std.encoding.bech32 import Bech32Variant, bech32_decode, bech32_encode, bech32m_encode, decode as bech32_decode_any + +def check_base64() -> None: assert b64encode(b"hello") == "aGVsbG8=" assert urlsafe_b64encode(b"\xfb\xff") == "-_8=" match b64decode_lenient("aG Vs\nbG8="): @@ -50,16 +51,8 @@ def main() -> None: match b64decode("a=AA"): Ok(_) => assert false, "invalid-padding base64 unexpectedly decoded" Err(err) => assert err.kind == "invalid_padding" -"#, - ) -} -#[test] -fn base32_vectors_and_lenient_decode() -> Result<(), Box> { - run_module_case( - "crates/incan_stdlib/stdlib/encoding/base32.incn", - r#" -def main() -> None: +def check_base32() -> None: assert b32encode(b"foo") == "MZXW6===" assert b32hexencode(b"foo") == "CPNMU===" match b32decode_lenient("mz xw6==="): @@ -71,16 +64,8 @@ def main() -> None: match b32decode("MZ=XW6=="): Ok(_) => assert false, "misplaced-padding base32 unexpectedly decoded" Err(err) => assert err.kind == "invalid_padding" -"#, - ) -} -#[test] -fn base58_vectors_and_lenient_decode() -> Result<(), Box> { - run_module_case( - "crates/incan_stdlib/stdlib/encoding/base58.incn", - r#" -def main() -> None: +def check_base58() -> None: assert b58encode(b"hello world") == "StV1DL6CwTryKyV" assert b58encode(b"\x00\x00") == "11" match b58decode_lenient(" StV1DL6CwTryKyV\n"): @@ -89,16 +74,8 @@ def main() -> None: match b58decode("0"): Ok(_) => assert false, "invalid base58 unexpectedly decoded" Err(err) => assert err.kind == "invalid_character" -"#, - ) -} -#[test] -fn base85_vectors_and_lenient_decode() -> Result<(), Box> { - run_module_case( - "crates/incan_stdlib/stdlib/encoding/base85.incn", - r#" -def main() -> None: +def check_base85() -> None: assert a85encode(b"\x00\x00\x00\x00") == "z" match b85decode(b85encode(b"hello")): Ok(data) => assert data == b"hello" @@ -118,20 +95,12 @@ def main() -> None: match b85decode("\t"): Ok(_) => assert false, "invalid-character base85 unexpectedly decoded" Err(err) => assert err.kind == "invalid_character" -"#, - ) -} -#[test] -fn bech32_vectors_and_invalid_cases() -> Result<(), Box> { - run_module_case( - "crates/incan_stdlib/stdlib/encoding/bech32.incn", - r#" -def main() -> None: +def check_bech32() -> None: match bech32_encode("a", []): Ok(text) => assert text == "a12uel5l" Err(err) => assert false, err.detail - match decode("A12UEL5L"): + match bech32_decode_any("A12UEL5L"): Ok(decoded) => assert decoded.hrp == "a" and len(decoded.data) == 0 and decoded.variant == Bech32Variant.Bech32 Err(err) => assert false, err.detail match bech32m_encode("a", []): @@ -140,6 +109,13 @@ def main() -> None: match bech32_decode("a1lqfn3a"): Ok(_) => assert false, "bech32 accepted a bech32m checksum" Err(err) => assert err.kind == "invalid_checksum" + +def main() -> None: + check_base64() + check_base32() + check_base58() + check_base85() + check_bech32() "#, ) } From c1803d30cd0e57b7eb2e885c4dae30725522714d Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 23 May 2026 13:15:47 +0200 Subject: [PATCH 19/58] feature - support generic decorator factories (#640) (#643) --- Cargo.lock | 18 ++--- Cargo.toml | 2 +- .../src/bin/generate_lang_reference.rs | 26 ++++++- crates/incan_core/src/lang/features.rs | 8 +- crates/incan_syntax/src/ast/decls.rs | 2 + .../src/diagnostics/catalog/errors/types.rs | 5 ++ .../src/parser/decl/decorators.rs | 2 + crates/incan_syntax/src/parser/expr.rs | 4 +- crates/incan_syntax/src/parser/tests.rs | 22 ++++++ src/backend/ir/lower/decl/functions.rs | 9 ++- src/backend/ir/lower/decl/helpers.rs | 1 + src/backend/ir/lower/decl/methods.rs | 9 ++- src/format/formatter/declarations.rs | 10 +++ src/format/formatter/expressions.rs | 11 ++- src/format/mod.rs | 28 +++++++ src/frontend/api_metadata.rs | 51 +++++++++++++ src/frontend/typechecker/check_decl.rs | 22 ++++-- src/frontend/typechecker/check_expr/comps.rs | 61 +++++++++++++++ src/frontend/typechecker/check_expr/mod.rs | 3 + src/frontend/typechecker/tests.rs | 74 +++++++++++++++++++ src/frontend/vocab_ast_bridge.rs | 6 ++ tests/integration_tests.rs | 64 ++++++++++++++++ .../036_user_defined_decorators.md | 35 +++++++-- .../language/reference/feature_inventory.md | 8 +- .../docs/language/reference/language.md | 26 ++++++- .../docs-site/docs/release_notes/0_3.md | 4 +- 26 files changed, 472 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cae11f76a..dce337561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc9" +version = "0.3.0-rc10" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index eb984f30d..035d5fccf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc9" +version = "0.3.0-rc10" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/bin/generate_lang_reference.rs b/crates/incan_core/src/bin/generate_lang_reference.rs index 22ca33faa..57fb45e59 100644 --- a/crates/incan_core/src/bin/generate_lang_reference.rs +++ b/crates/incan_core/src/bin/generate_lang_reference.rs @@ -481,7 +481,7 @@ fn render_decorators_section(out: &mut String) { start_section(out, "## Decorators"); out.push_str( - r#"User-defined decorators are valid on top-level `def` / `async def` declarations and instance methods. A decorator is an ordinary callable value that receives the decorated function value and returns the binding that should replace it: + r#"User-defined decorators are valid on top-level `def` / `async def` declarations and instance methods. A decorator is an ordinary callable value that receives the decorated function or method callable and returns the callable that should replace it: ```incan def parse(value: int) -> int: @@ -500,6 +500,30 @@ def main() -> None: Stacked decorators apply bottom-up, matching Python's declaration model: the decorator closest to `def` receives the original function value first, and the outer decorators receive each previous result. Decorator factories such as `@logged("name")` are checked by first evaluating the factory expression as a callable-producing expression and then applying the produced decorator to the function value. +!!! tip "Coming from Python?" + Python decorators can replace a function with any object. Incan user-defined function decorators are stricter: the decorator input is the decorated callable, and the result must also be callable. Python's `Callable[[A, B], R]` corresponds to Incan's `(A, B) -> R`; `=>` is only for closure expressions, not callable types. Use `(F) -> F` when a decorator preserves the original callable signature, and spell the source and replacement callable types separately when it intentionally changes the signature, such as `((str) -> R) -> ((str, str) -> R)`. + +Decorator factories can be generic over the decorated function type. This is the usual shape for registry, catalog, routing, telemetry, and validation decorators that record metadata but return the original function unchanged: + +```incan +def registered[F](function_ref: str) -> ((F) -> F): + return (func) => func + +@registered("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +``` + +The compiler infers `F` from the decorated function when the factory result is applied. If inference needs help, pass the decorated function type explicitly on the decorator factory call: + +```incan +@registered[(str) -> ColumnExpr]("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +``` + +The post-decoration binding keeps the concrete callable signature of the decorated function unless the decorator deliberately returns a different callable shape. Checked API metadata and imports observe that concrete signature, not the generic helper's `F`. + Method decorators receive an unbound callable shape with the receiver first. A decorator on `def label(self, value: int) -> str` sees `(&Box, int) -> str`; a decorator on `def bump(mut self, value: int) -> int` sees `(&mut Box, int) -> int`. The wrapper passes the actual receiver borrow through to the decorated callable, so method decorators do not require cloning the receiver. Class, model, trait, enum, newtype, field, alias, and module decorators remain limited to compiler-owned decorators. Compiler-owned decorators such as `@derive`, `@route`, `@rust.extern`, `@rust.allow`, `@staticmethod`, `@classmethod`, and `@requires` keep their existing special behavior. diff --git a/crates/incan_core/src/lang/features.rs b/crates/incan_core/src/lang/features.rs index e7ad82d54..f53d83377 100644 --- a/crates/incan_core/src/lang/features.rs +++ b/crates/incan_core/src/lang/features.rs @@ -500,8 +500,12 @@ pub const FEATURES: &[FeatureDescriptor] = &[ introduced_in_rfc: RFC::_036, stability: Stability::Stable, activation: "None for user-defined decorators; compiler-owned decorators keep their documented imports.", - summary: "Decorators are ordinary callable values applied to functions and methods, including decorator factories.", - canonical_forms: &["@logged", "@route(\"/users\")", "@trace(level=Level.INFO)"], + summary: "Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type.", + canonical_forms: &[ + "@logged", + "@registered(\"catalog.ref\")", + "@registered[(str) -> ColumnExpr](\"catalog.ref\")", + ], prefer_over: "Boilerplate wrapper declarations around every function that needs the same callable transform.", references: links![ ("Language reference", "language.md#decorators"), diff --git a/crates/incan_syntax/src/ast/decls.rs b/crates/incan_syntax/src/ast/decls.rs index 1a94aa38d..335f0de74 100644 --- a/crates/incan_syntax/src/ast/decls.rs +++ b/crates/incan_syntax/src/ast/decls.rs @@ -391,6 +391,8 @@ pub enum ParamKind { pub struct Decorator { pub path: ImportPath, pub name: Ident, + /// Explicit call-site type arguments for decorator factories, as in `@factory[T](...)`. + pub type_args: Vec>, /// Whether the decorator was written with a call suffix, including zero-argument factory calls like `@factory()`. pub is_call: bool, pub args: Vec, diff --git a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs index 22a9e0db6..3ba4ec969 100644 --- a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs +++ b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs @@ -186,6 +186,11 @@ pub fn decorator_factory_not_callable(path: &str, span: Span) -> CompileError { CompileError::type_error(format!("'{path}' does not return a callable"), span) } +/// Report a decorator callable whose application result is not callable. +pub fn decorator_result_not_callable(path: &str, span: Span) -> CompileError { + CompileError::type_error(format!("decorator '{path}' must return a callable"), span) +} + /// Report a type-valued decorator argument on a user-defined decorator factory. pub fn decorator_type_argument_not_supported(path: &str, span: Span) -> CompileError { CompileError::type_error( diff --git a/crates/incan_syntax/src/parser/decl/decorators.rs b/crates/incan_syntax/src/parser/decl/decorators.rs index 617b979cb..104a6fa38 100644 --- a/crates/incan_syntax/src/parser/decl/decorators.rs +++ b/crates/incan_syntax/src/parser/decl/decorators.rs @@ -11,6 +11,7 @@ impl<'a> Parser<'a> { .last() .cloned() .ok_or_else(|| errors::decorator_path_expected(self.current_span()))?; + let type_args = self.call_site_type_args()?; let is_call = self.match_punct(PunctuationId::LParen); let args = if is_call { let args = self.decorator_args()?; @@ -24,6 +25,7 @@ impl<'a> Parser<'a> { Decorator { path, name, + type_args, is_call, args, }, diff --git a/crates/incan_syntax/src/parser/expr.rs b/crates/incan_syntax/src/parser/expr.rs index 650bbb05b..5bc0ea3c4 100644 --- a/crates/incan_syntax/src/parser/expr.rs +++ b/crates/incan_syntax/src/parser/expr.rs @@ -498,7 +498,7 @@ impl<'a> Parser<'a> { } /// Parse one call-site type argument: either a full [`Type`] or the inference placeholder `_`. - fn call_site_type_arg(&mut self) -> Result, CompileError> { + pub(super) fn call_site_type_arg(&mut self) -> Result, CompileError> { if let TokenKind::Ident(name) = &self.peek().kind && name == "_" { @@ -512,7 +512,7 @@ impl<'a> Parser<'a> { /// Parse optional explicit call-site type arguments (`[T, U]`) without consuming non-call brackets. /// /// This is intentionally conservative: we only treat brackets as call-site type args when the matching `]` is followed immediately by `(`. - fn call_site_type_args(&mut self) -> Result>, CompileError> { + pub(super) fn call_site_type_args(&mut self) -> Result>, CompileError> { if !self.check(&TokenKind::Punctuation(PunctuationId::LBracket)) { return Ok(Vec::new()); } diff --git a/crates/incan_syntax/src/parser/tests.rs b/crates/incan_syntax/src/parser/tests.rs index 46bec96f7..235c6397e 100644 --- a/crates/incan_syntax/src/parser/tests.rs +++ b/crates/incan_syntax/src/parser/tests.rs @@ -1249,6 +1249,28 @@ async def create() -> None: Ok(()) } + #[test] + fn test_parse_decorator_factory_with_explicit_type_args() -> Result<(), Vec> { + let source = r#" +@registered[(str) -> ColumnExpr]("inql.functions.col") +def col(name: str) -> ColumnExpr: + pass +"#; + let program = parse_str(source)?; + let func = match &program.declarations[0].node { + Declaration::Function(f) => f, + _ => panic!("Expected function"), + }; + let dec = &func.decorators[0].node; + assert_eq!(dec.path.segments, vec!["registered"]); + assert_eq!(dec.name, "registered"); + assert!(dec.is_call); + assert_eq!(dec.type_args.len(), 1); + assert!(matches!(&dec.type_args[0].node, Type::Function(_, _))); + assert_eq!(dec.args.len(), 1); + Ok(()) + } + #[test] fn test_parse_decorator_with_rust_namespace() -> Result<(), Vec> { // RFC 023: @rust.extern decorator must parse correctly (rust is a keyword) diff --git a/src/backend/ir/lower/decl/functions.rs b/src/backend/ir/lower/decl/functions.rs index 94421b2cc..85e5adf9b 100644 --- a/src/backend/ir/lower/decl/functions.rs +++ b/src/backend/ir/lower/decl/functions.rs @@ -233,19 +233,22 @@ impl AstLowering { Self::decorator_path_expr_from_import_path(&base_path, Self::decorator_synthetic_callee_span()); let method = path.last().cloned().unwrap_or_default(); Spanned::new( - Expr::MethodCall(Box::new(base), method, Vec::new(), args), + Expr::MethodCall(Box::new(base), method, decorator.node.type_args.clone(), args), decorator.span, ) } else { let callee = Self::decorator_path_expr(&decorator.node, Self::decorator_synthetic_callee_span()); - Spanned::new(Expr::Call(Box::new(callee), Vec::new(), args), decorator.span) + Spanned::new( + Expr::Call(Box::new(callee), decorator.node.type_args.clone(), args), + decorator.span, + ) } } else { Self::decorator_path_expr(&decorator.node, decorator.span) }; current = Spanned::new( Expr::Call(Box::new(callable), Vec::new(), vec![ast::CallArg::Positional(current)]), - decorator.span, + Self::decorator_synthetic_callee_span(), ); } Ok(current) diff --git a/src/backend/ir/lower/decl/helpers.rs b/src/backend/ir/lower/decl/helpers.rs index 3a53ccbd4..c5b0b7405 100644 --- a/src/backend/ir/lower/decl/helpers.rs +++ b/src/backend/ir/lower/decl/helpers.rs @@ -585,6 +585,7 @@ impl AstLowering { parent_levels: 0, }, name: derive_name.to_string(), + type_args: Vec::new(), is_call: false, args: Vec::new(), }, diff --git a/src/backend/ir/lower/decl/methods.rs b/src/backend/ir/lower/decl/methods.rs index 15759dbdf..b40224e01 100644 --- a/src/backend/ir/lower/decl/methods.rs +++ b/src/backend/ir/lower/decl/methods.rs @@ -77,12 +77,15 @@ impl AstLowering { Self::decorator_path_expr_from_import_path(&base_path, Self::decorator_synthetic_callee_span()); let method_name = path.last().cloned().unwrap_or_default(); Spanned::new( - ast::Expr::MethodCall(Box::new(base), method_name, Vec::new(), args), + ast::Expr::MethodCall(Box::new(base), method_name, decorator.node.type_args.clone(), args), decorator.span, ) } else { let callee = Self::decorator_path_expr(&decorator.node, Self::decorator_synthetic_callee_span()); - Spanned::new(ast::Expr::Call(Box::new(callee), Vec::new(), args), decorator.span) + Spanned::new( + ast::Expr::Call(Box::new(callee), decorator.node.type_args.clone(), args), + decorator.span, + ) } } else { Self::decorator_path_expr(&decorator.node, decorator.span) @@ -94,7 +97,7 @@ impl AstLowering { }; current = Spanned::new( ast::Expr::Call(Box::new(callable), Vec::new(), vec![ast::CallArg::Positional(arg)]), - decorator.span, + Self::decorator_synthetic_callee_span(), ); } Ok(current) diff --git a/src/format/formatter/declarations.rs b/src/format/formatter/declarations.rs index 9e56fe3d7..f0f05b2ba 100644 --- a/src/format/formatter/declarations.rs +++ b/src/format/formatter/declarations.rs @@ -1048,6 +1048,16 @@ impl Formatter { fn format_decorator(&mut self, dec: &Decorator) { self.writer.write("@"); self.format_decorator_path(&dec.path); + if !dec.type_args.is_empty() { + self.writer.write("["); + for (idx, arg) in dec.type_args.iter().enumerate() { + if idx > 0 { + self.writer.write(", "); + } + self.format_type(&arg.node); + } + self.writer.write("]"); + } if dec.is_call { self.writer.write("("); for (i, arg) in dec.args.iter().enumerate() { diff --git a/src/format/formatter/expressions.rs b/src/format/formatter/expressions.rs index 651083233..23ebc7bb1 100644 --- a/src/format/formatter/expressions.rs +++ b/src/format/formatter/expressions.rs @@ -289,7 +289,7 @@ impl Formatter { } Expr::Closure(params, body) => { self.writer.write("("); - self.format_params(params); + self.format_closure_params(params); self.writer.write(") => "); self.format_expr(&body.node); } @@ -543,6 +543,15 @@ impl Formatter { // ---- Call args ---- + fn format_closure_params(&mut self, params: &[Spanned]) { + for (i, param) in params.iter().enumerate() { + if i > 0 { + self.writer.write(", "); + } + self.writer.write(¶m.node.name); + } + } + fn format_call_args(&mut self, args: &[CallArg]) { for (i, arg) in args.iter().enumerate() { if i > 0 { diff --git a/src/format/mod.rs b/src/format/mod.rs index 7e08710c8..4d3c89d9d 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -450,6 +450,34 @@ def MixedName() -> int: Ok(()) } + #[test] + fn test_format_source_decorator_factory_type_args() -> Result<(), FormatError> { + let source = r#"@registered[(str)->ColumnExpr]("inql.functions.col") +def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +"#; + let formatted = format_source(source)?; + let expected = r#"@registered[(str) -> ColumnExpr]("inql.functions.col") +def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +"#; + assert_eq!(formatted, expected); + Ok(()) + } + + #[test] + fn test_format_source_preserves_untyped_closure_params() -> Result<(), FormatError> { + let source = r#"pub def registered[F](_function_ref: str) -> (F) -> F: + return (func) => func +"#; + let formatted = format_source(source)?; + let expected = r#"pub def registered[F](_function_ref: str) -> (F) -> F: + return (func) => func +"#; + assert_eq!(formatted, expected); + Ok(()) + } + #[test] fn test_format_source_wraps_long_function_signature() -> Result<(), FormatError> { let source = r#"def append_node(store_id: int, kind: PrismNodeKind, input_ids: list[int], named_table: str, predicate: bool, limit_count: int) -> int: diff --git a/src/frontend/api_metadata.rs b/src/frontend/api_metadata.rs index 64fe5c46d..61ed54800 100644 --- a/src/frontend/api_metadata.rs +++ b/src/frontend/api_metadata.rs @@ -1935,6 +1935,57 @@ pub def decorated(value: int) -> int: Ok(()) } + #[test] + fn checked_api_metadata_preserves_generic_decorator_factory_source_signature() -> Result<(), String> { + let source = r#" +model ColumnExpr: + name: str + +def registered[F](name: str) -> ((F) -> F): + return (func) => func + +@registered("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + """Build a column expression. + + Args: + name: Column name. + """ + return ColumnExpr(name=name) +"#; + let metadata = metadata_for(source).map_err(|errs| format!("{errs:?}"))?; + let function = metadata + .declarations + .iter() + .find_map(|decl| match decl { + ApiDeclaration::Function(function) if function.name == "col" => Some(function), + _ => None, + }) + .ok_or_else(|| "expected decorated function metadata".to_string())?; + + assert_eq!(function.params.len(), 1); + assert_eq!(function.params[0].name, "name"); + assert_eq!( + function.params[0].ty, + TypeRef::Named { + name: "str".to_string(), + } + ); + assert_eq!( + function.return_type, + TypeRef::Named { + name: "ColumnExpr".to_string(), + } + ); + + let diagnostics = validate_checked_api_docstrings(&[metadata]); + assert!( + diagnostics.is_empty(), + "expected generic decorator factory source signature to satisfy docstring validation, got {diagnostics:?}" + ); + Ok(()) + } + #[test] fn checked_api_docstring_validation_matches_overloaded_method_by_params() -> Result<(), String> { let source = r#" diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index 9a6aa79c9..cede65bd0 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -3935,17 +3935,20 @@ impl TypeChecker { let base = Self::decorator_path_expr_from_import_path(&base_path, decorator.span); let method = path.last().cloned().unwrap_or_default(); Spanned::new( - Expr::MethodCall(Box::new(base), method, Vec::new(), args), + Expr::MethodCall(Box::new(base), method, decorator.node.type_args.clone(), args), decorator.span, ) } else { let callee = Self::decorator_path_expr(&decorator.node, decorator.span); - Spanned::new(Expr::Call(Box::new(callee), Vec::new(), args), decorator.span) + Spanned::new( + Expr::Call(Box::new(callee), decorator.node.type_args.clone(), args), + decorator.span, + ) }; self.check_expr(&factory_expr) } - /// Apply a callable decorator value to the decorated binding type and return the post-decoration binding type. + /// Apply a callable decorator value to the decorated binding type and return the post-decoration callable type. fn apply_decorator_callable( &mut self, display: &str, @@ -3974,7 +3977,12 @@ impl TypeChecker { if self.errors.len() != error_count { return ResolvedType::Unknown; } - substitute_resolved_type(&ret, &type_bindings) + let result_ty = substitute_resolved_type(&ret, &type_bindings); + if !matches!(result_ty, ResolvedType::Function(_, _) | ResolvedType::Unknown) { + self.errors.push(errors::decorator_result_not_callable(display, span)); + return ResolvedType::Unknown; + } + result_ty } /// Convert decorator arguments into ordinary call arguments for user-defined decorator factory checking. @@ -4001,7 +4009,11 @@ impl TypeChecker { fn decorator_display(decorator: &Decorator) -> String { let path = decorator.path.segments.join("."); if decorator.is_call { - format!("{path}(...)") + if decorator.type_args.is_empty() { + format!("{path}(...)") + } else { + format!("{path}[...](...)") + } } else { path } diff --git a/src/frontend/typechecker/check_expr/comps.rs b/src/frontend/typechecker/check_expr/comps.rs index 6b84de5a9..b702b65d9 100644 --- a/src/frontend/typechecker/check_expr/comps.rs +++ b/src/frontend/typechecker/check_expr/comps.rs @@ -4,6 +4,7 @@ //! and type-checking the generated element/value expressions in a nested scope. use crate::frontend::ast::*; +use crate::frontend::diagnostics::errors; use crate::frontend::symbols::*; use crate::frontend::typechecker::helpers::{dict_ty, generator_ty, list_ty}; @@ -121,4 +122,64 @@ impl TypeChecker { ResolvedType::Function(param_types, Box::new(return_ty)) } + + /// Type-check a closure expression against an expected function shape. + pub(in crate::frontend::typechecker::check_expr) fn check_closure_with_expected( + &mut self, + params: &[Spanned], + body: &Spanned, + expected_params: &[CallableParam], + expected_ret: &ResolvedType, + span: Span, + ) -> ResolvedType { + if params.len() != expected_params.len() { + self.errors.push(errors::builtin_arity( + "closure", + expected_params.len(), + params.len(), + span, + )); + return ResolvedType::Unknown; + } + + self.symbols.enter_scope(ScopeKind::Function); + + let prev_in_async_body = self.in_async_body; + self.in_async_body = false; + let prev_return_error_type = self.current_return_error_type.take(); + + let param_types: Vec<_> = params + .iter() + .zip(expected_params.iter()) + .map(|(param, expected)| { + let ty = expected.ty.clone(); + self.symbols.define(Symbol { + name: param.node.name.clone(), + kind: SymbolKind::Variable(VariableInfo { + ty: ty.clone(), + is_mutable: false, + is_used: false, + }), + span: param.span, + scope: 0, + }); + CallableParam::named(param.node.name.clone(), ty, param.node.kind) + }) + .collect(); + + let return_ty = self.check_expr_with_expected(body, Some(expected_ret)); + if !matches!(return_ty, ResolvedType::Unknown) && !self.types_compatible(&return_ty, expected_ret) { + self.errors.push(errors::type_mismatch( + &expected_ret.to_string(), + &return_ty.to_string(), + body.span, + )); + } + + self.current_return_error_type = prev_return_error_type; + self.in_async_body = prev_in_async_body; + self.symbols.exit_scope(); + + ResolvedType::Function(param_types, Box::new(expected_ret.clone())) + } } diff --git a/src/frontend/typechecker/check_expr/mod.rs b/src/frontend/typechecker/check_expr/mod.rs index a889234c3..e47f65ec4 100644 --- a/src/frontend/typechecker/check_expr/mod.rs +++ b/src/frontend/typechecker/check_expr/mod.rs @@ -283,6 +283,9 @@ impl TypeChecker { (Expr::MethodCall(base, method, type_args, args), Some(expected_ty)) => { self.check_method_call_with_expected(base, method, type_args, args, expr.span, Some(expected_ty)) } + (Expr::Closure(params, body), Some(ResolvedType::Function(expected_params, expected_ret))) => { + self.check_closure_with_expected(params, body, expected_params, expected_ret, expr.span) + } (Expr::List(elems), expected_ty) => self.check_list_with_expected(elems, expected_ty), (Expr::Dict(entries), expected_ty) => self.check_dict_with_expected(entries, expected_ty), (Expr::Loop(loop_expr), expected_ty) => self.check_loop_expr(loop_expr, expected_ty, expr.span), diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index 1fbcf4a43..e4b2adbd6 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -5070,6 +5070,62 @@ def main() -> int: assert_check_ok(source); } +#[test] +fn test_generic_decorator_factory_with_explicit_function_type_arg_preserves_binding_type() { + let source = r#" +model ColumnExpr: + name: str + +def registered[F](name: str) -> ((F) -> F): + return (func) => func + +@registered[(str) -> ColumnExpr]("inql.functions.col") +def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) + +def main() -> ColumnExpr: + return col("id") +"#; + assert_check_ok(source); +} + +#[test] +fn test_generic_decorator_factory_infers_decorated_function_type() -> Result<(), Box> { + let source = r#" +model ColumnExpr: + name: str + +def registered[F](name: str) -> ((F) -> F): + return (func) => func + +@registered("inql.functions.col") +def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) + +def main() -> ColumnExpr: + return col("id") +"#; + let tokens = lexer::lex(source).map_err(|errs| format!("lex failed: {errs:?}"))?; + let ast = parser::parse(&tokens).map_err(|errs| format!("parse failed: {errs:?}"))?; + let mut checker = TypeChecker::new(); + checker + .check_program(&ast) + .map_err(|errs| format!("typecheck failed: {errs:?}"))?; + let symbol = checker + .lookup_symbol("col") + .ok_or_else(|| "expected decorated col binding".to_string())?; + let SymbolKind::Variable(info) = &symbol.kind else { + return Err(format!("expected decorated binding to be a value, got {:?}", symbol.kind).into()); + }; + let ResolvedType::Function(params, ret) = &info.ty else { + return Err(format!("expected decorated binding to stay callable, got {:?}", info.ty).into()); + }; + assert_eq!(params.len(), 1); + assert_eq!(params[0].ty, ResolvedType::Str); + assert_eq!(**ret, ResolvedType::Named("ColumnExpr".to_string())); + Ok(()) +} + #[test] fn test_user_defined_decorator_on_async_def_is_kept_as_candidate() { let source = r#" @@ -5194,6 +5250,24 @@ def label() -> int: .any(|err| err.message.contains("'count_factory(...)' does not return a callable")), "expected non-callable factory diagnostic, got {bad_factory:?}" ); + + let bad_result = check_str_err( + r#" +def count(func: () -> int) -> int: + return 1 + +@count +def label() -> int: + return 1 +"#, + "decorator returning non-callable should be rejected", + ); + assert!( + bad_result + .iter() + .any(|err| err.message.contains("decorator 'count' must return a callable")), + "expected non-callable decorator result diagnostic, got {bad_result:?}" + ); } #[test] diff --git a/src/frontend/vocab_ast_bridge.rs b/src/frontend/vocab_ast_bridge.rs index a752d24fb..e1664b4c1 100644 --- a/src/frontend/vocab_ast_bridge.rs +++ b/src/frontend/vocab_ast_bridge.rs @@ -880,6 +880,12 @@ fn public_scoped_symbol_call_to_internal( fn public_decorator_from_internal( decorator: &ast::Spanned, ) -> Result { + if !decorator.node.type_args.is_empty() { + return Err(VocabAstBridgeError::UnsupportedInternalExpression( + "typed decorator call-site arguments are not currently bridgeable", + )); + } + let mut args = Vec::new(); for arg in &decorator.node.args { match arg { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0002c7ec1..2d632a875 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8488,6 +8488,70 @@ module tests: Ok(()) } + #[test] + fn e2e_imported_generic_decorator_factory_preserves_function_signatures() -> Result<(), Box> + { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "generic_decorator_factory" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + let tests_dir = dir.join("tests"); + std::fs::create_dir_all(&src_dir)?; + std::fs::create_dir_all(&tests_dir)?; + std::fs::write( + src_dir.join("registry.incn"), + r#" +pub def registered[F](name: str) -> ((F) -> F): + return (func) => func +"#, + )?; + std::fs::write( + src_dir.join("columns.incn"), + r#" +from registry import registered + +pub model ColumnExpr: + pub name: str + +@registered[(str) -> ColumnExpr]("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) + +@registered("inql.functions.literal") +pub def literal() -> ColumnExpr: + return ColumnExpr(name="literal") +"#, + )?; + std::fs::write( + tests_dir.join("test_generic_decorator_factory.incn"), + r#" +from std.testing import assert_eq +from columns import col, literal + +def test_explicit_generic_decorator_factory_signature() -> None: + assert_eq(col("id").name, "id") + +def test_inferred_generic_decorator_factory_signature() -> None: + assert_eq(literal().name, "literal") +"#, + )?; + + let output = run_incan_test(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected imported generic decorator factory project to pass.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + Ok(()) + } + #[test] fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { let dir = write_test_project( diff --git a/workspaces/docs-site/docs/RFCs/closed/implemented/036_user_defined_decorators.md b/workspaces/docs-site/docs/RFCs/closed/implemented/036_user_defined_decorators.md index 6ab95efcf..7ed90b70d 100644 --- a/workspaces/docs-site/docs/RFCs/closed/implemented/036_user_defined_decorators.md +++ b/workspaces/docs-site/docs/RFCs/closed/implemented/036_user_defined_decorators.md @@ -13,7 +13,7 @@ - RFC 031 (Library system — enables decorator libraries to ship as `pub::` packages) - RFC 037 (Native web and HTTP stdlib redesign — consumer of `@app.get` / `@app.post`) - RFC 084 (RHS partial callable presets — future decorator factory ergonomics) -- **Issue:** [#170](https://github.com/dannys-code-corner/incan/issues/170) +- **Issue:** [#170](https://github.com/dannys-code-corner/incan/issues/170), [#640](https://github.com/dannys-code-corner/incan/issues/640) - **RFC PR:** — - **Written against:** v0.2 - **Shipped in:** v0.3 @@ -92,7 +92,7 @@ This desugars to the `@app.get`/`@app.post` decorator form, which itself desugar - Desugar user-defined decorators to ordinary callable application before type checking. - Apply stacked decorators bottom-up, matching Python's decorator ordering. - Type-check decorator application through the ordinary callable and assignment rules. -- Allow decorator calls to change the visible type of the decorated binding. +- Allow decorator calls to change the visible callable type of the decorated binding. - Keep decorator semantics compile-time and declaration-oriented; the language must not introduce arbitrary module-level statement execution or module-initialization side effects for decorators. - Provide the primitive needed for library-owned patterns such as `@app.get`, `@cache`, `@retry`, and `@validate`. @@ -257,7 +257,7 @@ f = D2(f) f = D1(f) ``` -This means `D1` wraps `D2`'s result, which wraps `D3`'s result, which wraps the original `f`. Each step may change the type of `f`. +This means `D1` wraps `D2`'s result, which wraps `D3`'s result, which wraps the original `f`. Each step may change the callable type of `f`. **Scope of desugaring** — user-defined decorators desugar on `def`, `async def`, and method declarations. Class, model, trait, newtype, enum, field, alias, and module declarations are out of scope for this RFC. @@ -275,10 +275,35 @@ After desugaring, the typechecker treats `f = D(f)` as a regular call expression 1. `D` must be a callable. If it is not, the compiler emits `decorator 'D' is not callable`. 2. The argument type of `D`'s first parameter must be compatible with `f`'s declared type. -3. The return type of `D(f)` becomes the new type of `f` in the enclosing scope. If `D` returns the same function type it received, `f`'s type is unchanged. If the return type cannot be inferred, an explicit return type annotation on `D` is required. +3. The return type of `D(f)` must itself be callable and becomes the new callable type of `f` in the enclosing scope. If `D` returns the same function type it received, `f`'s type is unchanged. If the return type cannot be inferred, an explicit return type annotation on `D` is required. For decorator factories, step 1 applies to `D(args)` — the factory expression must produce a callable-shaped value — and then steps 2 and 3 apply to that callable applied to `f`. +### v0.3 amendment: generic decorator factories + +Issue #640 was accepted as an implementation amendment to this RFC because it naturally extends decorator factories rather than introducing a separate decorator model. A decorator factory may be generic over the decorated function type and return `((F) -> F)`, letting libraries write one registration helper instead of one helper per callable signature: + +```incan +pub def registered[F](function_ref: str) -> ((F) -> F): + return (func) => func + +@registered("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +``` + +The compiler infers `F` from the decorated function when applying the produced decorator. If inference needs an explicit call-site type, the decorator factory call accepts the same bracketed type-argument syntax as ordinary generic calls: + +```incan +@registered[(str) -> ColumnExpr]("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +``` + +This amendment preserves RFC 036's binding contract: later references, exports, imports, checked API metadata, and editor surfaces observe the concrete decorated function signature unless the decorator intentionally returns a different callable shape. + +Python decorators can replace a function binding with an arbitrary object. Incan intentionally does not copy that dynamic part of Python's model: user-defined function and method decorators are callable-to-callable transforms. Python's `Callable[[A, B], R]` corresponds to Incan's `(A, B) -> R`; `=>` is only for closure expressions, not callable types. The common generic registry shape is `(F) -> F`; wrappers that intentionally change the callable signature should spell both the source callable type and replacement callable type explicitly. + ### Async decorators A decorator applied to an `async def` receives an async function value. The decorator is responsible for preserving async semantics correctly — typically by defining an `async def wrapper(...)` internally. The compiler does not automatically lift a synchronous wrapper to async; a sync decorator applied to an async function produces a sync-typed result, which is likely a type error at the call site. @@ -296,7 +321,7 @@ A decorator applied to an `async def` receives an async function value. The deco ### Syntax -No new decorator syntax is introduced. `@name` and `@name(args)` already parse. Unknown decorator names no longer produce an error on `def`, `async def`, or method declarations — they desugar instead. +RFC 036 originally required no new decorator syntax beyond `@name` and `@name(args)`. The v0.3 implementation amendment also accepts explicit generic call-site arguments on decorator factory calls, as in `@name[T](args)`, using the same type-argument syntax as ordinary generic calls. Unknown decorator names no longer produce an error on `def`, `async def`, or method declarations — they desugar instead. Method decorator signatures use reference callable parameters for receivers. Immutable method receivers are written as `&Owner`, and mutable method receivers are written as `&mut Owner`, for example `(&Box, int) -> str` and `(&mut Counter, int) -> int`. diff --git a/workspaces/docs-site/docs/language/reference/feature_inventory.md b/workspaces/docs-site/docs/language/reference/feature_inventory.md index 063d94bae..23f4f1ab5 100644 --- a/workspaces/docs-site/docs/language/reference/feature_inventory.md +++ b/workspaces/docs-site/docs/language/reference/feature_inventory.md @@ -39,7 +39,7 @@ Use it when deciding whether code should use an existing Incan surface before ad | Symbol, method, and variant aliases | Syntax | 0.3 | None. | `pub average = alias avg`
`mean = avg`
`WARNING = alias WARN` | Aliases expose another resolved name for the same declaration, method, or enum variant without duplicating behavior. | Wrapper functions or duplicated enum variants used only for compatibility names. | [Symbol aliases](symbol_aliases.md), [Imports and modules](imports_and_modules.md), [Release 0.3](../../release_notes/0_3.md) | | Callable presets with `partial` | Syntax | 0.3 | None. | `pub get = partial route(method="GET")`
`set_alive = partial set_state(state=true)` | `partial` creates a callable surface from an existing callable by supplying named preset values. | Hand-written wrappers whose only job is to pass the same keyword defaults. | [Callable presets](callable_presets.md), [Callable presets explained](../explanation/callable_presets.md), [Release 0.3](../../release_notes/0_3.md) | | Rest parameters, unpacking, and spreads | Syntax | 0.3 | None. | `def log(*items: str, **fields: str) -> None:`
`f(*xs, **kw)`
`[*prefix, item]`
`{**base, "x": 1}` | Functions can capture `*args` / `**kwargs`; calls and literals support typed unpack/spread forms. | Manually spelling every forwarding arity or merging collections one element at a time. | [Functions and calls](functions.md), [Release 0.3](../../release_notes/0_3.md) | -| User-defined decorators | Syntax | 0.3 | None for user-defined decorators; compiler-owned decorators keep their documented imports. | `@logged`
`@route("/users")`
`@trace(level=Level.INFO)` | Decorators are ordinary callable values applied to functions and methods, including decorator factories. | Boilerplate wrapper declarations around every function that needs the same callable transform. | [Language reference](language.md#decorators), [Derives and traits](derives_and_traits.md), [Release 0.3](../../release_notes/0_3.md) | +| User-defined decorators | Syntax | 0.3 | None for user-defined decorators; compiler-owned decorators keep their documented imports. | `@logged`
`@registered("catalog.ref")`
`@registered[(str) -> ColumnExpr]("catalog.ref")` | Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type. | Boilerplate wrapper declarations around every function that needs the same callable transform. | [Language reference](language.md#decorators), [Derives and traits](derives_and_traits.md), [Release 0.3](../../release_notes/0_3.md) | | Generators | Syntax | 0.3 | None. | `def numbers() -> Generator[int]:`
`yield value`
`(x * 2 for x in values)` | `yield`-based functions and generator expressions produce lazy `Generator[T]` values. | Eager list construction when callers only need lazy iteration. | [Generators](generators.md), [Generators how-to](../how-to/generators.md), [Release 0.3](../../release_notes/0_3.md) | | Iterator adapters and terminal consumers | Stdlib | 0.3 | Use iterator values. | `values.iter().map(parse).filter(valid).collect()`
`items.enumerate().take(10)`
`numbers.fold(0, add)` | Iterator pipelines expose lazy adapters and explicit terminal consumers. | Manual loop accumulators for ordinary map/filter/fold pipeline shapes. | [Collection protocols](stdlib_traits/collection_protocols.md), [Release 0.3](../../release_notes/0_3.md) | | `Result[T, E]` combinators | Stdlib | 0.3 | Use `Result[T, E]` values. | `result.map(transform)`
`result.and_then(validate)`
`result.inspect(log_success)` | `Result` values support branch-local transforms, fallible chaining, recovery, and inspection taps. | Nested matches that only rewrap `Ok` / `Err` around one transformed branch. | [std.result](stdlib/result.md), [Fallible and infallible paths](../tutorials/fallible_and_infallible_paths.md), [Release 0.3](../../release_notes/0_3.md) | @@ -464,13 +464,13 @@ Canonical forms: - **Use instead of:** Boilerplate wrapper declarations around every function that needs the same callable transform. - **References:** [Language reference](language.md#decorators), [Derives and traits](derives_and_traits.md), [Release 0.3](../../release_notes/0_3.md) -Decorators are ordinary callable values applied to functions and methods, including decorator factories. +Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type. Canonical forms: - `@logged` -- `@route("/users")` -- `@trace(level=Level.INFO)` +- `@registered("catalog.ref")` +- `@registered[(str) -> ColumnExpr]("catalog.ref")` ### Generators diff --git a/workspaces/docs-site/docs/language/reference/language.md b/workspaces/docs-site/docs/language/reference/language.md index ad8e0c62b..4eae0ce04 100644 --- a/workspaces/docs-site/docs/language/reference/language.md +++ b/workspaces/docs-site/docs/language/reference/language.md @@ -289,7 +289,7 @@ def main() -> None: ## Decorators -User-defined decorators are valid on top-level `def` / `async def` declarations and instance methods. A decorator is an ordinary callable value that receives the decorated function value and returns the binding that should replace it: +User-defined decorators are valid on top-level `def` / `async def` declarations and instance methods. A decorator is an ordinary callable value that receives the decorated function or method callable and returns the callable that should replace it: ```incan def parse(value: int) -> int: @@ -308,6 +308,30 @@ def main() -> None: Stacked decorators apply bottom-up, matching Python's declaration model: the decorator closest to `def` receives the original function value first, and the outer decorators receive each previous result. Decorator factories such as `@logged("name")` are checked by first evaluating the factory expression as a callable-producing expression and then applying the produced decorator to the function value. +!!! tip "Coming from Python?" + Python decorators can replace a function with any object. Incan user-defined function decorators are stricter: the decorator input is the decorated callable, and the result must also be callable. Python's `Callable[[A, B], R]` corresponds to Incan's `(A, B) -> R`; `=>` is only for closure expressions, not callable types. Use `(F) -> F` when a decorator preserves the original callable signature, and spell the source and replacement callable types separately when it intentionally changes the signature, such as `((str) -> R) -> ((str, str) -> R)`. + +Decorator factories can be generic over the decorated function type. This is the usual shape for registry, catalog, routing, telemetry, and validation decorators that record metadata but return the original function unchanged: + +```incan +def registered[F](function_ref: str) -> ((F) -> F): + return (func) => func + +@registered("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +``` + +The compiler infers `F` from the decorated function when the factory result is applied. If inference needs help, pass the decorated function type explicitly on the decorator factory call: + +```incan +@registered[(str) -> ColumnExpr]("inql.functions.col") +pub def col(name: str) -> ColumnExpr: + return ColumnExpr(name=name) +``` + +The post-decoration binding keeps the concrete callable signature of the decorated function unless the decorator deliberately returns a different callable shape. Checked API metadata and imports observe that concrete signature, not the generic helper's `F`. + Method decorators receive an unbound callable shape with the receiver first. A decorator on `def label(self, value: int) -> str` sees `(&Box, int) -> str`; a decorator on `def bump(mut self, value: int) -> int` sees `(&mut Box, int) -> int`. The wrapper passes the actual receiver borrow through to the decorated callable, so method decorators do not require cloning the receiver. Class, model, trait, enum, newtype, field, alias, and module decorators remain limited to compiler-owned decorators. Compiler-owned decorators such as `@derive`, `@route`, `@rust.extern`, `@rust.allow`, `@staticmethod`, `@classmethod`, and `@requires` keep their existing special behavior. diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 976a1e57d..2946d5434 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -39,7 +39,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t - **Language**: Numeric annotations now cover exact integer widths, pointer-sized integers, `f32` / `f64`, analytics aliases, fixed-scale `decimal[p, s]` / `numeric[p, s]`, lossless widening, explicit resize helpers, and Rust boundary adaptation ([RFC 009], #325). - **Language**: Control flow gained `loop:` with `break `, single-pattern `if let` / `while let`, pattern alternation, anonymous union annotations with narrowing, and value enums with raw `str` / `int` representations ([RFC 016], [RFC 049], [RFC 071], [RFC 029], [RFC 032], #327, #333, #387, #317). -- **Language**: Enums can own methods and adopt traits; models, classes, traits, and wrappers gained computed properties, protocol hooks, operator hooks, typed decorators, declaration aliases, RHS partial callable presets, variadic parameters, call unpacking, and generator values ([RFC 050], [RFC 046], [RFC 068], [RFC 028], [RFC 036], [RFC 083], [RFC 084], [RFC 038], [RFC 006], #334, #203, #86, #162, #170, #437, #453, #83, #324). +- **Language**: Enums can own methods and adopt traits; models, classes, traits, and wrappers gained computed properties, protocol hooks, operator hooks, typed decorators, generic decorator factories, declaration aliases, RHS partial callable presets, variadic parameters, call unpacking, and generator values ([RFC 050], [RFC 046], [RFC 068], [RFC 028], [RFC 036], [RFC 083], [RFC 084], [RFC 038], [RFC 006], #334, #203, #86, #162, #170, #437, #453, #83, #324, #640). - **Rust interop**: Newtypes and rusttypes can adopt Rust traits with Incan source syntax, disambiguate same-name methods with `for Trait`, declare associated types, forward supported `@rust.derive(...)` metadata, and preserve inspected Rust signatures through generated calls ([RFC 043], [RFC 041], #200, #175). - **Stdlib**: `std.collections` adds specialized containers, and `std.collections.OrdinalMap` adds deterministic immutable key-to-ordinal lookup for schemas, catalogs, dictionary-encoded domains, and reproducible serialized lookup tables ([RFC 030], [RFC 101]). - **Stdlib**: `std.graph`, `std.json.JsonValue`, `std.regex`, `std.datetime`, and `std.logging` add first-party surfaces for dependency graphs, dynamic JSON payloads, safe-default regular expressions, temporal values, and structured logging ([RFC 047], [RFC 051], [RFC 059], [RFC 058], [RFC 072]). @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, and imported/decorator `const str` argument materialization (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, and generic decorator factory inference across package-style module boundaries (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 1634ba483ae77df2556b3f5bf6566f46b7599836 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 23 May 2026 20:17:37 +0200 Subject: [PATCH 20/58] bugfix - lower statically failing asserts as diverging (#644) --- .../emit/expressions/calls/testing_asserts.rs | 18 ++++++++++++-- tests/integration_tests.rs | 24 +++++++++++++++++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/backend/ir/emit/expressions/calls/testing_asserts.rs b/src/backend/ir/emit/expressions/calls/testing_asserts.rs index 0202d30f7..56ea8540e 100644 --- a/src/backend/ir/emit/expressions/calls/testing_asserts.rs +++ b/src/backend/ir/emit/expressions/calls/testing_asserts.rs @@ -27,11 +27,14 @@ impl<'a> IrEmitter<'a> { match helper_id { TestingAssertHelperId::Assert => { let condition = Self::canonical_assert_arg(helper_id, args, 0)?; - let condition_tokens = self.emit_expr(condition)?; let failure = self.emit_assert_failure( Self::assert_failure_message(helper_id)?, args.get(1).map(|arg| &arg.expr), )?; + if Self::constant_bool(condition) == Some(false) { + return Ok(Some(failure)); + } + let condition_tokens = self.emit_expr(condition)?; Ok(Some(quote! { if !(#condition_tokens) { #failure @@ -40,11 +43,14 @@ impl<'a> IrEmitter<'a> { } TestingAssertHelperId::AssertFalse => { let condition = Self::canonical_assert_arg(helper_id, args, 0)?; - let condition_tokens = self.emit_expr(condition)?; let failure = self.emit_assert_failure( Self::assert_failure_message(helper_id)?, args.get(1).map(|arg| &arg.expr), )?; + if Self::constant_bool(condition) == Some(true) { + return Ok(Some(failure)); + } + let condition_tokens = self.emit_expr(condition)?; Ok(Some(quote! { if #condition_tokens { #failure @@ -62,6 +68,14 @@ impl<'a> IrEmitter<'a> { } } + fn constant_bool(expr: &TypedExpr) -> Option { + match &expr.kind { + IrExprKind::Bool(value) => Some(*value), + IrExprKind::InteropCoerce { expr, .. } => Self::constant_bool(expr), + _ => None, + } + } + fn canonical_assert_arg( helper_id: TestingAssertHelperId, args: &[IrCallArg], diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 2d632a875..c8ccaa896 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1834,6 +1834,30 @@ fn runtime_error_canonicalization_cases() -> Result<(), Box Result<(), Box> { + let cases = [ + r#" +def fail_int(message: str) -> int: + assert false, message + +def main() -> None: + _ = fail_int("boom") +"#, + r#" +def fail_as[T](message: str) -> T: + assert false, message + +def main() -> None: + _ = fail_as[int]("boom") +"#, + ]; + for source in cases { + assert_runtime_error_cli(source, "AssertionError", &["boom"])?; + } + Ok(()) +} + #[test] fn test_fail_on_empty_collection() { let dir = make_temp_test_dir(); diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 2946d5434..adbbc94f7 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, and generic decorator factory inference across package-style module boundaries (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, and typed failure lowering for `assert false` in non-`None` return paths (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 903a5dad6fc8b3320995c3b9de98669ca9ce8998 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 23 May 2026 21:28:03 +0200 Subject: [PATCH 21/58] bugfix - lower method-call decorators through checked receivers (#645) --- src/backend/ir/emit/expressions/calls.rs | 23 +++ src/backend/ir/lower/expr/mod.rs | 147 +++++++++++++----- tests/integration_tests.rs | 79 ++++++++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 4 files changed, 209 insertions(+), 42 deletions(-) diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index 8d7781030..bb7c82c2e 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -628,6 +628,7 @@ impl<'a> IrEmitter<'a> { if let Some(sig) = function_sig && sig.params.iter().any(|param| param.kind != ParamKind::Normal) { + let f = Self::call_callee_tokens(func, f, type_args); let arg_tokens = self.emit_rest_aware_call_args(func, args, sig)?; return Ok(quote! { #f #turbofish (#(#arg_tokens),*) }); } @@ -792,9 +793,31 @@ impl<'a> IrEmitter<'a> { }) .collect::>()?; + let f = Self::call_callee_tokens(func, f, type_args); Ok(quote! { #f #turbofish (#(#arg_tokens),*) }) } + /// Parenthesize call targets whose emitted Rust is an expression block rather than a path/call expression. + /// + /// Storage-rooted method calls materialize arguments and enter `StaticCell::with_ref` / `with_mut`, so their + /// emitted callee has block shape. Calling that result requires `({ ... })(arg)` in Rust. + fn call_callee_tokens(func: &TypedExpr, emitted: TokenStream, type_args: &[IrType]) -> TokenStream { + if !type_args.is_empty() { + return emitted; + } + match &func.kind { + IrExprKind::MethodCall { receiver, .. } if Self::expr_is_storage_rooted(receiver) => { + quote! { ({ #emitted }) } + } + IrExprKind::If { .. } + | IrExprKind::Match { .. } + | IrExprKind::Closure { .. } + | IrExprKind::Block { .. } + | IrExprKind::Loop { .. } => quote! { ({ #emitted }) }, + _ => emitted, + } + } + pub(in super::super) fn emit_rest_aware_call_args( &self, func: &TypedExpr, diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index 17a752ee1..733bf9c19 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -370,49 +370,67 @@ impl AstLowering { /// This is a stepping stone toward fully typed lowering. pub fn lower_expr_spanned(&mut self, expr: &Spanned) -> Result { let mut lowered = self.lower_expr(&expr.node, expr.span)?; - if let Some(info) = &self.type_info { - if let Some(res_ty) = info.expr_type(expr.span) { - // Preserve reference wrappers introduced by lowering (e.g. mutable parameters are tracked as - // `RefMut(T)` in IR), while still benefiting from the typechecker's inner type information. - // - // The frontend type system does not model references, so `expr_type` typically returns `T` where - // lowering may have already marked the same binding as `Ref(T)`/`RefMut(T)`. - // - // Likewise, RFC-008 const lowering may have already refined `str`/`bytes` to their static IR forms. - // Keep those backend-specific const representations intact so later emission can materialize owned - // values only when required. - let inferred = self.lower_resolved_type(res_ty); - lowered.ty = match &lowered.ty { - IrType::Ref(existing_inner) => { - IrType::Ref(Box::new(Self::merge_inferred_ir_type(existing_inner, inferred))) - } - IrType::RefMut(existing_inner) => { - IrType::RefMut(Box::new(Self::merge_inferred_ir_type(existing_inner, inferred))) - } - IrType::StaticStr => IrType::StaticStr, - IrType::StaticBytes => IrType::StaticBytes, - existing => Self::merge_inferred_ir_type(existing, inferred), - }; - } - if let Some(kind) = info.ident_kind(expr.span) { - match (&expr.node, &mut lowered.kind) { - (ast::Expr::Ident(name), _) if matches!(kind, IdentKind::Static) => { - lowered.kind = IrExprKind::StaticRead { name: name.clone() }; - } - (_, IrExprKind::Var { ref_kind, .. }) => { - *ref_kind = match kind { - IdentKind::Value => *ref_kind, - IdentKind::Static => *ref_kind, - IdentKind::TypeName => VarRefKind::TypeName, - IdentKind::Variant => VarRefKind::TypeName, - IdentKind::Module => VarRefKind::ExternalName, - IdentKind::RustImport => VarRefKind::ExternalRustName, - IdentKind::RustValue => VarRefKind::Value, - IdentKind::Trait => VarRefKind::TypeName, - }; + if let Some(info) = &self.type_info + && let Some(res_ty) = info.expr_type(expr.span) + { + // Preserve reference wrappers introduced by lowering (e.g. mutable parameters are tracked as + // `RefMut(T)` in IR), while still benefiting from the typechecker's inner type information. + // + // The frontend type system does not model references, so `expr_type` typically returns `T` where + // lowering may have already marked the same binding as `Ref(T)`/`RefMut(T)`. + // + // Likewise, RFC-008 const lowering may have already refined `str`/`bytes` to their static IR forms. + // Keep those backend-specific const representations intact so later emission can materialize owned + // values only when required. + let inferred = self.lower_resolved_type(res_ty); + lowered.ty = match &lowered.ty { + IrType::Ref(existing_inner) => { + IrType::Ref(Box::new(Self::merge_inferred_ir_type(existing_inner, inferred))) + } + IrType::RefMut(existing_inner) => { + IrType::RefMut(Box::new(Self::merge_inferred_ir_type(existing_inner, inferred))) + } + IrType::StaticStr => IrType::StaticStr, + IrType::StaticBytes => IrType::StaticBytes, + existing => Self::merge_inferred_ir_type(existing, inferred), + }; + } + if let Some(kind) = self.ident_kind_for_lowering(expr) { + match (&expr.node, &mut lowered.kind) { + (ast::Expr::Ident(name), _) if matches!(kind, IdentKind::Static) => { + lowered.kind = IrExprKind::StaticRead { name: name.clone() }; + } + (ast::Expr::Ident(name), IrExprKind::Var { ref_kind, .. }) => { + *ref_kind = match kind { + IdentKind::Value => *ref_kind, + IdentKind::Static => *ref_kind, + IdentKind::TypeName => VarRefKind::TypeName, + IdentKind::Variant => VarRefKind::TypeName, + IdentKind::Module => VarRefKind::ExternalName, + IdentKind::RustImport => VarRefKind::ExternalRustName, + IdentKind::RustValue => VarRefKind::Value, + IdentKind::Trait => VarRefKind::TypeName, + }; + if matches!(kind, IdentKind::TypeName | IdentKind::Variant | IdentKind::Trait) + && matches!(lowered.ty, IrType::Unknown) + && let Some(ty) = self.synthetic_type_ident_ir_type(name) + { + lowered.ty = ty; } - _ => {} } + (_, IrExprKind::Var { ref_kind, .. }) => { + *ref_kind = match kind { + IdentKind::Value => *ref_kind, + IdentKind::Static => *ref_kind, + IdentKind::TypeName => VarRefKind::TypeName, + IdentKind::Variant => VarRefKind::TypeName, + IdentKind::Module => VarRefKind::ExternalName, + IdentKind::RustImport => VarRefKind::ExternalRustName, + IdentKind::RustValue => VarRefKind::Value, + IdentKind::Trait => VarRefKind::TypeName, + }; + } + _ => {} } } // Apply any rusttype method return coercion recorded by the typechecker (e.g. &str → String). @@ -422,6 +440,53 @@ impl AstLowering { Ok(lowered) } + /// Return the identifier classification that lowering should use for this expression. + /// + /// Most source expressions use span-keyed frontend metadata. Synthetic expressions created by lowering, such as + /// user-defined decorator factory calls, intentionally use the default span so they do not collide with call-site + /// expression types. Those synthetic nodes still need metadata-backed classification for type names and module + /// statics; otherwise they fall back to value-shaped Rust emission. + fn ident_kind_for_lowering(&self, expr: &Spanned) -> Option { + if let Some(kind) = self.type_info.as_ref().and_then(|info| info.ident_kind(expr.span)) { + return Some(kind); + } + if expr.span != ast::Span::default() { + return None; + } + let ast::Expr::Ident(name) = &expr.node else { + return None; + }; + if self + .type_info + .as_ref() + .is_some_and(|info| info.static_binding(name).is_some()) + { + return Some(IdentKind::Static); + } + if self.synthetic_type_ident_ir_type(name).is_some() { + return Some(IdentKind::TypeName); + } + None + } + + /// Return the known IR type for a synthetic type-like identifier. + fn synthetic_type_ident_ir_type(&self, name: &str) -> Option { + self.struct_names + .get(name) + .cloned() + .or_else(|| self.enum_names.get(name).cloned()) + .or_else(|| { + self.class_decls + .contains_key(name) + .then(|| IrType::Struct(name.to_string())) + }) + .or_else(|| { + self.trait_decls + .contains_key(name) + .then(|| IrType::Struct(name.to_string())) + }) + } + /// Lower an expression to IR. /// /// Handles all expression types including: diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c8ccaa896..c124421d9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8576,6 +8576,85 @@ def test_inferred_generic_decorator_factory_signature() -> None: Ok(()) } + #[test] + fn e2e_method_call_decorator_factories_use_checked_receiver_lowering() -> Result<(), Box> { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "method_call_decorator_factories" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + src_dir.join("main.incn"), + r#" +class Registry: + pub names: list[str] + + @staticmethod + def new() -> Self: + return Registry(names=[]) + + @staticmethod + def add_static[F](name: str) -> (F) -> F: + FUNCTIONS.names.append(name) + return (func) => func + + def add[F](mut self, name: str) -> (F) -> F: + self.names.append(name) + return (func) => func + + +static FUNCTIONS: Registry = Registry.new() + + +@Registry::add_static("static") +def static_col(name: str) -> str: + return name + + +@FUNCTIONS.add("instance") +def instance_col(name: str) -> str: + return name + + +def main() -> None: + println(static_col("amount")) + println(instance_col("price")) + println(len(FUNCTIONS.names)) +"#, + )?; + + let out_dir = dir.join("out"); + let output = run_incan_build(&src_dir.join("main.incn"), &out_dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected method-call decorator factories to build.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + + let generated = std::fs::read_to_string(out_dir.join("src/main.rs"))?; + assert!( + generated.contains("Registry :: add_static") + || generated.contains("Registry::add_static") + || generated.contains("Registry :: add_static ::"), + "class static method decorator should lower as associated function syntax:\n{}", + generated, + ); + assert!( + generated.contains(".with_mut(|__incan_static_value|") + && generated.contains("__incan_static_value.add(__incan_static_arg_0.to_string())"), + "static registry receiver should lower through static storage access:\n{}", + generated, + ); + Ok(()) + } + #[test] fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { let dir = write_test_project( diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index adbbc94f7..9c913e3db 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, and typed failure lowering for `assert false` in non-`None` return paths (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, and method-call decorator factories on class/static registry receivers (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From ae7c9bd351d4af0a82cee19323d7ad97b92c2200 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 11:02:38 +0200 Subject: [PATCH 22/58] chore - align release roadmap and inspection RFC docs (#618) --- .../docs-site/docs/RFCs/066_std_http.md | 168 +++++++- ...incan_semantic_layer_inspection_surface.md | 376 ++++++++++++++++++ workspaces/docs-site/docs/roadmap.md | 155 ++++++-- 3 files changed, 655 insertions(+), 44 deletions(-) create mode 100644 workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md diff --git a/workspaces/docs-site/docs/RFCs/066_std_http.md b/workspaces/docs-site/docs/RFCs/066_std_http.md index e0779ba8f..e581a532d 100644 --- a/workspaces/docs-site/docs/RFCs/066_std_http.md +++ b/workspaces/docs-site/docs/RFCs/066_std_http.md @@ -10,6 +10,8 @@ - RFC 051 (`JsonValue` for `std.json`) - RFC 055 (`std.fs` path-centric filesystem APIs) - RFC 063 (`std.process` process spawning and command execution) + - RFC 078 (tool execution and typed workflow actions) + - RFC 103 (`std.secrets` secret strings and bytes) - **Issue:** https://github.com/dannys-code-corner/incan/issues/84 - **RFC PR:** — - **Written against:** v0.2 @@ -17,16 +19,18 @@ ## Summary -This RFC proposes `std.http` as Incan's standard library module for explicit HTTP client work. The module standardizes a request or response model, one-shot and client-based request APIs, timeout and retry policy, structured errors, and JSON convenience surfaces so ordinary programs, tools, and automation workflows do not need to fall through to `rust::reqwest`-shaped APIs or ad hoc wrappers. +This RFC proposes `std.http` as Incan's standard library module for explicit HTTP client work. The module standardizes a request or response model, one-shot and client-based request APIs, client lifecycle, protocol negotiation, timeout and retry policy, structured errors, and JSON convenience surfaces so ordinary programs, tools, and automation workflows do not need to fall through to `rust::reqwest`-shaped APIs or ad hoc wrappers. ## Core model -Read this RFC as one foundation plus three mechanisms: +Read this RFC as one foundation plus five mechanisms: 1. **Foundation:** HTTP is a general-purpose stdlib capability, not a CI-only or framework-only helper surface. 2. **Mechanism A:** `std.http` provides explicit `Request`, `Response`, `Body`, `Method`, and `HttpError` types with predictable behavior and no panic-driven network contract. 3. **Mechanism B:** the module supports both one-shot convenience helpers and a reusable `Client` surface so simple scripts and heavier integrations share one coherent model. -4. **Mechanism C:** JSON, timeout, redirect, and retry behavior remain explicit policy surfaces rather than ambient magic. +4. **Mechanism C:** client lifecycle and pooling are explicit enough that repeated calls do not depend on hidden global connection state. +5. **Mechanism D:** JSON, timeout, redirect, and retry behavior remain explicit policy surfaces rather than ambient magic. +6. **Mechanism E:** HTTP protocol negotiation, streaming, and test transports remain inspectable seams instead of backend-specific escape hatches. ## Motivation @@ -36,6 +40,55 @@ This matters for more than ergonomics. HTTP boundaries are policy-heavy: timeout `std.http` should therefore do for network requests what `std.fs`, `std.process`, and the newer stdlib RFCs are doing in their domains: define an Incan-first contract while still allowing the runtime to map onto Rust-native implementations underneath. +## HTTP client prior art + +### Requests baseline + +Python's `requests` is useful as the ergonomic baseline. Its quickstart frames ordinary HTTP verbs as obvious one-line calls, while still returning a response object the caller can inspect. Incan should keep that floor: a health check, webhook call, artifact fetch, or small API client should not require building a full framework object graph. + +Source: [Requests quickstart](https://docs.python-requests.org/en/latest/user/quickstart/). + +The Incan lesson is: + +- method-specific helpers such as `get`, `post`, `put`, and `delete` are worth keeping +- helpers should return the same response model as explicit requests +- simple does not mean ambient: timeouts, errors, redaction, and policy still need defined behavior +- the public API should be obvious before it is powerful + +### HTTPX lessons + +HTTPX is useful prior art because it modernizes the `requests` shape without reducing the design to convenience helpers. Its documentation presents a fully featured client with sync and async APIs, HTTP/1.1 and HTTP/2 support, strict timeouts, async clients for async frameworks, and opt-in HTTP/2 with response-level protocol visibility. + +Sources: [HTTPX introduction](https://www.python-httpx.org/), [HTTPX async support](https://www.python-httpx.org/async/), and [HTTPX HTTP/2 support](https://www.python-httpx.org/http2/). + +The Incan lesson is not to copy Python's split between `Client` and `AsyncClient` literally. The useful design pressure is: + +- a reusable client is a real resource, not just a namespace for functions +- connection pooling and cleanup should be visible in the API contract +- one-shot helpers are useful, but repeated requests should have an obvious client-owned path +- timeout policy should be present by default and refinable later into connect/read/write/overall timeout fields +- HTTP/2 should be an explicit protocol policy, not an accidental backend behavior +- responses should expose the negotiated protocol version +- streaming and test transports should fit the same `Request` / `Response` / `HttpError` vocabulary + +Incan should go further than HTTPX where the language gives it leverage: typed errors instead of exception families, model-aware JSON decoding, capability-gated network access, and policy-visible remote data flow for tools, CI, and AI-backed actions. + +### Koheesio lessons + +Koheesio is useful prior art because it treats HTTP as a pipeline step concern, not only as an ad hoc client call. Its HTTP step surface includes method-specific steps, a shared request configuration shape, timeout options, retry behavior, response outputs such as raw payload, JSON payload, and status code, paginated HTTP GET support, and explicit masking for sensitive authorization headers. Its async HTTP step also makes session, retry, and connector state visible. + +Sources: [Koheesio HTTP steps](https://engineering.nike.com/koheesio/0.10.0/api_reference/steps/http.html) and [Koheesio async HTTP steps](https://engineering.nike.com/koheesio/0.10.0/api_reference/asyncio/http.html). + +The Incan lesson is: + +- `std.http` should be general-purpose, but its request and response types must compose cleanly with step, pipeline, and typed-action systems +- retry, timeout, pagination, and authorization are operational concerns, not just transport knobs +- response projections should be stable enough for workflow outputs, logs, quality checks, and tests +- sensitive header handling belongs in the core design, not only in logging docs +- async execution should make session and connector ownership visible without forcing backend-specific types into user code + +Incan should not copy Koheesio's Python/Pydantic runtime boundary literally. The stdlib contract should preserve the step-friendly shape while using `Result[..., HttpError]`, typed request/response models, compile-time metadata, and Rust-native execution underneath. + ## Goals - Provide a first-class `std.http` module for client-side HTTP work. @@ -44,8 +97,13 @@ This matters for more than ergonomics. HTTP boundaries are policy-heavy: timeout - Define a structured `HttpError` model so network failures, status failures, timeout failures, decoding failures, and policy failures are distinguishable. - Provide JSON convenience helpers that compose cleanly with RFC 051 `JsonValue`. - Support both one-shot request helpers and a reusable `Client` surface. +- Make `Client` lifecycle, cleanup, and reuse explicit enough to support connection pooling without hidden globals. +- Make negotiated HTTP protocol information visible on responses, while avoiding a v1 requirement that every backend support HTTP/2. +- Keep request and response models structured enough to compose with typed workflow actions, pipeline steps, logs, tests, and generated reports. - Make retry behavior explicit and policy-shaped rather than automatic and invisible. +- Leave room for streaming bodies and test transports without leaking backend-specific transport types. - Require safe default treatment of sensitive headers in diagnostics and debug-facing representations. +- Accept secret value types for authentication and header-building APIs so callers do not need to reveal tokens into plain strings before sending requests. ## Non-Goals @@ -54,6 +112,7 @@ This matters for more than ergonomics. HTTP boundaries are policy-heavy: timeout - Making HTTP a language intrinsic or keyword surface. - Introducing a GitHub- or cloud-specific SDK into the standard library. - Standardizing cookies, OAuth flows, multipart forms, WebSockets, or HTTP/3-specific behavior in the first version. +- Requiring HTTP/2 support from every v1 implementation. ## Guide-level explanation @@ -114,6 +173,27 @@ items = response.json()? This does not change the basic model. It only moves repeated policy into one reusable value. +### Client lifecycle and pooling + +A `Client` should be treated as a resource that owns transport state such as connection pools, default headers, timeout policy, retry policy, redirect policy, and protocol preferences. The exact cleanup spelling is left to the implementation, but the API must make deterministic cleanup possible. + +One-shot helpers are still valuable for scripts and probes. Repeated calls, service-to-service integrations, crawlers, SDKs, and long-running tools should have an obvious client path so code does not create a fresh transport stack in a hot loop. + +### Protocol negotiation + +HTTP/2 support should be explicit without making it mandatory for all implementations. A client or request should be able to declare a protocol policy: + +```incan +from std.http import Client, Protocol + +client = Client(protocol=Protocol.Http2Preferred) +response = client.get("https://api.example.com/items")? + +println(response.protocol) +``` + +The exact names can change, but the shape should support "use the backend default", "HTTP/1 only", "prefer HTTP/2", and "require HTTP/2." If HTTP/2 is required and the implementation cannot provide it, the result should be a structured `HttpError`, not a silent downgrade. + ### Status handling should stay explicit The response model should not hide status behavior behind panics. Users should opt into strict status expectations: @@ -145,6 +225,8 @@ println(request) should not casually dump bearer tokens or secrets into logs. +When the caller uses `SecretStr` or `SecretBytes` from RFC 103, redaction should come from the value type as well as from conservative header-name rules. A header value derived from a secret wrapper must remain redacted even if the header name is custom. + ## Reference-level explanation ### Module surface @@ -158,6 +240,7 @@ should not casually dump bearer tokens or secrets into logs. - `StatusCode` - `HttpError` - `Client` +- protocol policy and negotiated protocol-version metadata, or equivalent types - one-shot request helpers or a functionally equivalent request entry surface - explicit retry-policy types if retry behavior is part of the request contract @@ -174,6 +257,7 @@ A `Request` must carry: - body - timeout policy - redirect policy if separately configurable +- protocol policy if the caller needs to override the client default - retry policy when the caller opts into retries A request must be constructible without requiring a `Client`. @@ -183,10 +267,13 @@ A request must be constructible without requiring a `Client`. A `Response` must expose: - status code +- negotiated protocol version when available - response headers - body bytes - helpers for decoding text and JSON +The response model should also define stable, tool-friendly projections for common workflow outputs, such as status code, raw text or bytes, parsed JSON when requested, and redacted diagnostic summaries. These projections let pipeline steps, typed actions, tests, and reports use HTTP results without scraping backend-specific response objects. + A response must not silently panic on unsuccessful status codes. Status-based failure should remain explicit through helpers such as `require_success()` or equivalent APIs. ### Error model @@ -199,11 +286,23 @@ A response must not silently panic on unsuccessful status codes. Status-based fa - timeout failures - redirect-policy failures - TLS or transport failures +- unsupported or failed protocol negotiation - decode failures - explicit status-policy failures The module may include richer variants, but it must not collapse all failures into one undifferentiated string. +### Client lifecycle + +A `Client` owns reusable transport state. The contract must define: + +- how a client is closed or otherwise released +- whether operations after cleanup fail with a structured error +- which options are client defaults versus per-request overrides +- how one-shot helpers scope any temporary client state + +The API should make client reuse the natural path for repeated requests. One-shot helpers may internally create and dispose of clients, but the docs should not encourage creating new reusable clients inside tight loops. + ### Timeouts Timeouts must be first-class and explicit. The contract must define: @@ -214,6 +313,19 @@ Timeouts must be first-class and explicit. The contract must define: This RFC intentionally does not hardcode one exact default timeout yet; see unresolved questions. +Timeouts may start as one total request timeout, but the API should not block later support for distinct connect, read, write, and overall timeout fields. + +### Protocol negotiation + +The public contract should not assume that HTTP/1.1 is the only possible transport. It should standardize a small protocol-policy vocabulary, exact names pending: + +- backend default / automatic negotiation +- HTTP/1 only +- HTTP/2 preferred +- HTTP/2 required + +Implementations that do not support HTTP/2 may reject HTTP/2-preferred policies up front, or accept them and fall back to HTTP/1.x. HTTP/2-required policies must fail with a structured `HttpError` when the implementation, target, or peer cannot provide HTTP/2. If an implementation accepts a preferred policy and downgrades to HTTP/1.x, the `Response` must expose the protocol that was actually used. + ### Retries Retries must be opt-in and policy-shaped. A retry policy may cover: @@ -225,6 +337,12 @@ Retries must be opt-in and policy-shaped. A retry policy may cover: The module must not silently retry every request by default. +### Pagination and workflow composition + +The base `std.http` module does not need to standardize one pagination framework. It should, however, keep request construction, response decoding, and client reuse composable enough for libraries to build paginated fetchers, polling loops, and API-specific steps on top of the same primitives. + +Pipeline or workflow integrations should depend on `std.http` request/response models, not backend transport objects. A workflow action that fetches remote data should be able to report its URL policy, timeout, retry policy, status code, body shape, and redacted diagnostics through machine-readable action output. + ### JSON integration `Body.json(value)` or an equivalent API may accept `JsonValue` and, where later RFCs standardize model-oriented JSON encoding, other serializable values. @@ -235,7 +353,21 @@ The module must not silently retry every request by default. Implementations should redact sensitive header values such as `Authorization`, `Proxy-Authorization`, and similarly sensitive token-bearing headers in debug-facing request or response displays. -The public contract does not need to prescribe every redacted header name exhaustively in v1, but it must require that sensitive-header treatment is conservative and documented. +Header values constructed from RFC 103 `SecretStr` or `SecretBytes` must be treated as sensitive regardless of header name. Authentication helpers should accept secret value types directly so user code does not need to expose a token as a plain string before constructing a request. + +The public contract does not need to prescribe every redacted header name exhaustively in v1, but it must require that sensitive-header treatment is conservative and documented. Header-name heuristics are a fallback; value-level secret typing is the stronger contract when available. + +### Streaming and transports + +The first implementation does not need to support every streaming body shape, but the request and response model should leave room for: + +- streaming response bodies +- streaming request bodies +- explicit body size limits +- test transports that return synthetic responses without network access +- local application transports for testing `std.web` applications through the same client vocabulary + +Any transport abstraction must preserve `Request`, `Response`, `HttpError`, timeout, protocol, redaction, and policy semantics. Backend-specific transport handles must not become the public API. ## Design details @@ -248,6 +380,8 @@ This RFC does not require new language syntax. It is a namespaced stdlib surface The semantic center is explicit network behavior: - request creation is explicit +- client lifecycle is explicit +- protocol negotiation is visible - timeout policy is explicit - retry policy is explicit - status handling is explicit @@ -261,6 +395,8 @@ The module should not rely on hidden ambient globals for client state, retry beh - **RFC 055 (`std.fs`)**: file uploads or downloads may later compose with path or file surfaces, but this RFC does not require multipart or streaming file-transfer APIs. - **RFC 063 (`std.process`)**: HTTP should remain a direct network API, not a wrapper over shelling out to `curl`. - **RFC 037 (native web stdlib redesign)**: this RFC covers client-side HTTP. Server-side web contracts remain separate even if they eventually share types such as methods or status codes. +- **RFC 078 (tool execution and typed workflow actions)**: HTTP-capable tools and actions should be able to surface network access, protocol policy, and remote data flow through action metadata and policy checks. +- **RFC 103 (`std.secrets`)**: authentication helpers, header builders, diagnostics, retries, telemetry, and workflow output should preserve `SecretStr` and `SecretBytes` redaction semantics. ### Compatibility / migration @@ -276,30 +412,50 @@ This feature is additive. Existing Rust-interop HTTP wrappers remain valid, but - Rejected because real tooling and API clients need reusable policy and shared headers. - **Only `Client`, no one-shot helpers** - Rejected because it makes simple scripts too ceremonious. +- **A pipeline-specific HTTP step as the primary API** + - Rejected because HTTP is a general-purpose stdlib capability. Step and workflow libraries should compose over `std.http`; they should not own the base transport contract. +- **Separate public sync and async client models** + - Rejected for now because Incan should keep one conceptual client contract. Implementations may still provide blocking convenience helpers or async-only methods where the runtime requires them. +- **Mandatory HTTP/2 in v1** + - Rejected because the API should not block on backend coverage or target support. The important v1 contract is that protocol policy and negotiated protocol metadata have a place to live. +- **Hide protocol version entirely** + - Rejected because service-to-service clients, debugging, performance work, and policy checks sometimes need to know whether HTTP/1.x or HTTP/2 was actually used. +- **Expose backend transport types directly** + - Rejected because it would reintroduce the `rust::reqwest`-shaped leakage this RFC is trying to remove. ## Drawbacks - HTTP is a deceptively broad domain, and the API can sprawl if the module tries to cover every advanced transport concern immediately. - Timeout, retry, redirect, and status behavior need very careful wording or users will make conflicting assumptions. +- Protocol negotiation adds visible surface area before every implementation can support every protocol. +- Streaming and transport seams are easy to over-design if they are not tied to concrete tests and `std.web` integration cases. - Redaction rules and debug output need discipline or the module will create accidental secret leakage. ## Implementation architecture -*(Non-normative.)* A practical implementation likely uses a Rust-native HTTP stack underneath, but the public contract should remain request- and response-shaped. A sensible rollout would start with one-shot requests, explicit request objects, reusable clients, structured errors, timeouts, and `JsonValue` helpers before expanding into richer transport features such as multipart, streaming bodies, or cookie persistence. +*(Non-normative.)* A practical implementation likely uses a Rust-native HTTP stack underneath, but the public contract should remain request- and response-shaped. A sensible rollout would start with one-shot requests, explicit request objects, reusable clients, structured errors, timeouts, protocol metadata, and `JsonValue` helpers before expanding into richer transport features such as multipart, streaming bodies, cookie persistence, or HTTP/2 enforcement. ## Layers affected - **Stdlib / runtime**: must provide the request, response, method, body, client, and error surfaces promised by this RFC. - **Language surface**: the module and its helper types must be available as specified. -- **Execution handoff**: implementations must preserve timeout, retry, status, and decoding semantics without leaking backend-specific APIs as the public contract. +- **Execution handoff**: implementations must preserve timeout, retry, protocol, status, and decoding semantics without leaking backend-specific APIs as the public contract. - **Docs / tooling**: examples and documentation must standardize safe defaults, explicit status handling, and redaction expectations. ## Unresolved questions - Should `std.http` expose a default timeout at the module or client level, or should callers be required to choose one explicitly? +- Should timeout policy start as one total timeout, or should v1 expose connect/read/write/overall timeout fields immediately? - Should `Response.json()` standardize only `JsonValue` decoding in this RFC, or should typed model decoding be part of the base contract too? - Which redirect policy should be the default: follow a bounded number of redirects, or require explicit opt-in? - Should retry policies live on `Request`, `Client`, or both? +- Should protocol policy live on `Request`, `Client`, or both? +- Should HTTP/2 support be a v1 implementation feature, a v1 API shape with optional backend support, or a follow-up RFC? +- What is the minimum useful test transport: synthetic responses only, local `std.web` app transport, or a trait-like transport provider surface? +- What streaming body API is small enough for v1 while still compatible with large downloads and uploads later? +- Which response projections should be standardized for typed actions, pipeline steps, logs, and test assertions? +- Should pagination and polling helpers live in `std.http`, in workflow/step libraries, or in API-specific packages? - How much of cookie handling belongs in the initial contract versus a follow-up RFC? +- Which authentication helper shapes should accept `SecretStr` and `SecretBytes` directly in v1? diff --git a/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md b/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md new file mode 100644 index 000000000..c848b969d --- /dev/null +++ b/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md @@ -0,0 +1,376 @@ +# RFC 102: Incan Semantic Layer Inspection Surface + +- **Status:** Draft +- **Created:** 2026-05-23 +- **Author(s):** Danny Meijer (@dannymeijer) +- **Related:** + - RFC 015 (project lifecycle CLI) + - RFC 048 (checked contract metadata, Incan emit, and interrogation tooling) + - RFC 074 (template rendering and boilerplate provenance) + - RFC 075 (starter profiles and capability packs) + - RFC 076 (project mutation policy and recovery) + - RFC 077 (workspace and multi-package projects) + - RFC 078 (tool execution and typed workflow actions) + - RFC 079 (`incan.pub` artifact graph) + - RFC 080 (AI assets, models, prompts, evals, and agent metadata) + - RFC 082 (checked API documentation generation) + - RFC 085 (field metadata and type-shaped constraints) + - RFC 086 (schema descriptors and adapters) + - RFC 087 (reusable field contracts and model composition) + - RFC 092 (interactive runtime stdlib contracts) + - RFC 096 (declaration metadata blocks) + - RFC 097 (Rust-hosted Incan caller) +- **Issue:** — +- **RFC PR:** — +- **Written against:** v0.3 +- **Shipped in:** — + +## Summary + +This RFC defines the Incan Semantic Layer Inspection Surface: a local, versioned, machine-readable project model that joins checked source facts, project lifecycle facts, actions, capabilities, policy outcomes, provenance, artifacts, schema descriptors, AI assets, evals, and agent guidance into one inspectable contract for CLI, LSP, CI, docs tooling, registries, and agents. The goal is not to replace the subsystem RFCs that own those facts; the goal is to make their outputs converge into one semantic layer so tools do not scrape source files, generated Rust, manifests, README conventions, or unrelated command output to understand an Incan project. + +## Core model + +Read this RFC as nine foundations: + +1. **The semantic layer is local first:** the source of truth for project inspection is the local project or workspace, not a remote registry. +2. **Checked source facts and lifecycle facts meet in one model:** compiler-owned facts from RFC 048 and lifecycle-owned facts from RFC 074 through RFC 080 must be joinable through stable identities. +3. **Inspection is a product surface:** `incan inspect` or an equivalent command is a stable interface, not debug output. +4. **LSP is the proving consumer:** editor features should consume the same semantic layer as the CLI, CI, docs tooling, and agents. +5. **Human output is a view:** terminal prose may summarize inspection results, but machine-readable output is the canonical integration contract. +6. **Degraded states are explicit:** incomplete, stale, unsupported, unresolved, blocked, or policy-redacted facts must be represented directly instead of disappearing or being silently guessed. +7. **Agents are not privileged:** agent-facing data is the same data available to IDEs and CI, and agents may propose work but must not approve their own mutations. +8. **Graph explanation is required:** users and tools should be able to ask why a fact, action, artifact, policy outcome, or provenance edge exists. +9. **Subsystem RFCs keep ownership:** this RFC defines the aggregation and inspection contract, not the detailed semantics of templates, capabilities, actions, policy, AI assets, schemas, or registries. + +## Motivation + +Incan already has many of the ingredients of an intent and semantic layer. RFC 048 defines checked API and model metadata. RFC 074 defines template provenance. RFC 075 defines starters, capabilities, mutation plans, file roles, and agent guidance. RFC 076 defines policy outcomes. RFC 077 defines workspace inspection. RFC 078 defines typed actions. RFC 079 defines registry artifact relationships. RFC 080 defines AI assets and eval metadata. RFC 085, RFC 086, RFC 087, and RFC 096 deepen the model and schema contract. Each of those RFCs is useful on its own, but a tool that wants to understand a real project should not have to compose them through ad hoc command calls and local interpretation. + +The strategic risk is fragmentation. Incan can land every subsystem RFC and still fail to expose a coherent semantic layer if the facts remain scattered across separate commands, separate JSON shapes, separate sidecar files, and editor-specific glue. That would weaken the strongest product claim: Incan should be a language and toolchain where humans, compilers, IDEs, CI, documentation generators, registries, and agents can reason from the same project model. + +The practical problem appears first in the editor. A useful LSP should be able to show a checked declaration, the schema descriptor behind a model, the capability that created a file, the action that validates it, the policy that blocks a mutation, the generated artifact that depends on it, and the agent guidance that applies. If each of those answers comes from a different subsystem with different identity rules, editor tooling becomes a pile of partial integrations. The same is true for CI checks, documentation tooling, package browsers, and agent workflows. + +This RFC therefore makes the integration surface explicit. Incan should provide a local semantic inspection model that lets tools ask: what exists, what does it mean, what can run, what can mutate, what verifies it, what generated it, what depends on it, what policy applies, and what should an agent know before touching it? + +## Goals + +- Define a canonical local semantic inspection surface for Incan projects and workspaces. +- Define a versioned machine-readable semantic package format that can join compiler facts, project facts, lifecycle facts, and artifact facts. +- Define required stable identity classes for declarations, fields, modules, files, actions, capabilities, policies, generated artifacts, AI assets, evals, and graph edges. +- Define high-level command surfaces such as `incan inspect`, `incan graph explain`, and machine-readable LSP-facing equivalents without requiring exact final flag spelling. +- Define the relationship between RFC 048 checked metadata, RFC 074 template provenance, RFC 075 capabilities, RFC 076 policy, RFC 077 workspaces, RFC 078 actions, RFC 079 artifact graph data, and RFC 080 AI assets. +- Define how degraded, incomplete, unsupported, stale, blocked, and redacted facts are represented. +- Require CLI, LSP, CI, docs tooling, registry tooling, and agents to consume the same semantic facts where their needs overlap. +- Make agent-facing inspection an explicit stable integration target while preserving receiver-owned policy and approval boundaries. + +## Non-Goals + +- This RFC does not define a new source syntax. +- This RFC does not replace RFC 048 checked metadata, RFC 074 templates, RFC 075 capabilities, RFC 076 policy, RFC 077 workspaces, RFC 078 actions, RFC 079 registry graph semantics, or RFC 080 AI asset semantics. +- This RFC does not require a public `incan.pub` registry to exist before local inspection works. +- This RFC does not require every current or future artifact kind to be implemented before the first inspection surface ships. +- This RFC does not define the full LSP protocol mapping for every editor feature. +- This RFC does not allow agents to bypass policy, approval, sandboxing, or user review. +- This RFC does not require inspection commands to execute project code, run tools, fetch remote schemas, download models, or contact external services. +- This RFC does not make generated artifacts authoritative over checked source or checked metadata. +- This RFC does not standardize an on-disk semantic database format for compiler internals. + +## Guide-level explanation + +Users should be able to inspect an Incan project as a semantic object, not only as a folder of source files and manifests. + +```text +incan inspect --format json +``` + +The human-readable view might summarize the same model: + +```text +Project: checkout-console +Members: 3 +Capabilities: cli, testing.basic, schema.adapters +Actions: run, test, validate-schema, docs +Policy: source changes require review; remote AI execution blocked +Generated files: 4 tracked, 1 edited +AI assets: 1 prompt template, 2 eval suites +Warnings: schema adapter output is stale for model OrderSummary +``` + +The JSON output is the integration contract. A CI check, editor plugin, docs generator, or agent can consume the same data without scraping the terminal text. + +An editor can use the same model to power richer project affordances. Hovering a model field may show its checked type, field metadata, reusable field contract provenance, schema overlay facts, generated-doc status, and downstream adapter projections. Selecting a generated file may show which template or capability created it, whether it is bootstrap-owned or managed, and which update policy applies. Opening the command palette may show typed actions with risk and policy labels instead of generic shell scripts. + +Users and tools should also be able to ask why a relationship exists: + +```text +incan graph explain model:OrderSummary.status +incan graph explain action:validate-schema +incan graph explain artifact:target/schema/order_summary.json +``` + +Example human-readable explanation: + +```text +model:OrderSummary.status + declared by source model OrderSummary + imports reusable field contract order_status + appears in schema overlay WarehouseOrder + validates generated artifact target/schema/order_summary.json + affected actions: validate-schema, docs + policy: source metadata changes require review +``` + +The same explanation should be available as structured data so LSP, CI, docs tooling, and agents can present it in their own UI. + +For agents, the model is a bounded context source. An agent can discover relevant files, capabilities, actions, tests, evals, policy restrictions, and generated artifact provenance before proposing a patch. The agent still cannot approve its own mutation, execute hidden lifecycle hooks, or infer permissions from guidance text. + +## Reference-level explanation + +### Semantic package + +The semantic inspection surface must expose a versioned semantic package. The exact JSON field names are not normative in this Draft, but the package must identify: + +- semantic package schema version; +- Incan toolchain version; +- project or workspace root identity; +- selected workspace scope when applicable; +- source snapshot identity when available; +- project manifest facts; +- lockfile and dependency facts when available; +- checked source declarations from RFC 048; +- contract-backed model facts from RFC 048; +- field metadata, reusable field provenance, and schema descriptor facts from RFC 085, RFC 086, RFC 087, and RFC 096 where available; +- file roles, capability status, capability provenance, template provenance, and generated-file ownership from RFC 074 and RFC 075; +- typed actions from RFC 078; +- policy outcomes from RFC 076; +- workspace topology from RFC 077; +- artifact graph and registry relationship facts from RFC 079 when available locally; +- AI asset, prompt, eval, and agent guidance facts from RFC 080 when available; +- diagnostics, warnings, degraded states, and redactions. + +The semantic package must not require remote registry access for basic local inspection. Remote or registry-backed facts may appear when they are already available in project state, package artifacts, lockfiles, cached descriptors, or explicitly requested registry queries. + +### Command surface + +The CLI must provide a project inspection command. The recommended spelling is: + +```text +incan inspect --format json +``` + +The exact final spelling may change, but the command must expose the semantic package in a documented machine-readable format. + +The CLI should provide a graph explanation command. The recommended spelling is: + +```text +incan graph explain --format json +``` + +Selectors should support at least declarations, model fields, files, actions, capabilities, generated artifacts, policy decisions, and AI assets when those objects are present in the semantic package. + +Existing subsystem commands such as action listing, capability status, policy checks, workspace inspection, metadata extraction, and template status may continue to exist. Their machine-readable output should either embed compatible semantic package fragments or reference the same stable identities used by the semantic package. + +### Stable identities + +The semantic package must represent stable identities for objects that other tools need to join. This RFC requires stable identities for at least: + +- project and workspace members; +- modules and public declarations; +- model fields and reusable field contracts; +- schema descriptors and overlays; +- source files and generated files; +- templates and template provenance records; +- capabilities and applied capability records; +- actions and action providers; +- policy decisions and risk categories; +- package artifacts and generated artifacts; +- AI assets, prompt templates, evals, datasets, and agent guidance records. + +Stable identities must be deterministic for a given source and project state. They must not depend on process memory addresses, nondeterministic traversal order, or human-formatted output. + +When an identity cannot be made stable, the semantic package must mark it as unstable or local-only. Tools must not treat unstable identities as durable cross-run anchors. + +### Edges + +The semantic package must represent relationships as first-class edges where possible. This RFC requires support for these relationship kinds: + +- `declares`: source or artifact declares a semantic object; +- `materializes`: contract metadata materializes a model or declaration; +- `generates`: template, capability, action, or adapter generates a file or artifact; +- `validates`: action, test, eval, or policy validates an object; +- `depends-on`: object depends on another object; +- `provided-by`: package, capability, or artifact provides an object; +- `applies-policy`: policy decision applies to an action, mutation, artifact, or source; +- `created-by-capability`: file, action, or metadata originated from a capability; +- `projects-from`: generated schema, docs, or adapter output projects from checked descriptors; +- `guided-by`: agent guidance applies to a file role, capability, action, or project shape. + +Implementations may add extension edge kinds. Unknown edge kinds must remain visible in machine-readable output and must not be silently dropped by generic consumers. + +### Degraded and partial facts + +The semantic package must represent degraded states explicitly. Useful states include: + +- `complete`: the fact is fully checked and current; +- `partial`: the fact is present but incomplete; +- `unsupported`: the toolchain knows the object exists but cannot inspect it fully; +- `stale`: the fact was derived from an older source state; +- `blocked`: policy or configuration prevents resolving the fact; +- `redacted`: the fact exists but sensitive content is intentionally hidden; +- `unknown`: the toolchain cannot determine whether the fact exists. + +For degraded facts, the package should include a reason code and a human-readable diagnostic where possible. Consumers must not infer absence from a missing optional field when a degraded state is available. + +### Policy and approval + +Policy outcomes from RFC 076 must be represented in the semantic package when policy is evaluated. Inspection may report policy status without applying mutations or running actions. + +Agent guidance, AI assets, action descriptors, template provenance, and capability metadata must not grant approval. The semantic package may help an agent propose a patch or select a workflow, but approval remains governed by RFC 076 and the receiving project. + +Sensitive values must follow the redaction rules of the owning subsystem. For example, template parameters marked sensitive must not appear as raw values in inspection output, and remote AI configuration must not expose secrets. + +### LSP consumption + +The LSP should treat the semantic package as the editor-facing project model where practical. It may cache or request focused views, but it should not reimplement independent logic for capability status, action discovery, policy outcomes, generated-file provenance, schema descriptors, or agent guidance. + +Editor features that should consume this surface include: + +- project tree grouping by file role and generated-file ownership; +- hover and go-to-definition for checked declarations, aliases, partials, fields, reusable field contracts, schema overlays, and generated artifacts; +- action buttons for typed actions with risk and policy labels; +- diagnostics for stale generated files, blocked policy, unsupported actions, invalid capability state, and stale schema projections; +- code actions for reviewable capability, template, or generated artifact updates; +- agent guidance discovery without executing agents or hidden prompts. + +The LSP may expose focused protocol-specific requests rather than returning the full semantic package on every editor operation. Those focused responses must preserve the same identities and degraded-state semantics as the CLI inspection surface. + +### CI, docs, registry, and agent consumption + +CI tools should be able to consume the semantic package to select typed actions, enforce policy checks, verify generated artifact freshness, run relevant evals, and fail on stale or unsupported project states. + +Documentation tooling should be able to consume checked declarations, schema descriptors, contract metadata, capability docs links, generated-file provenance, and artifact relationships from the semantic package instead of parsing source or generated Rust. + +Registry and package tooling may consume exported semantic package fragments when publishing packages or building artifact cards, but remote registries must not become the local authority for project mutation. + +Agentic tooling may consume the semantic package to identify relevant files, tests, evals, actions, capabilities, and constraints. It must treat policy outcomes, risk categories, and degraded states as binding context for proposal generation. + +## Design details + +### Relationship to RFC 048 + +RFC 048 remains the owner of checked API metadata and contract-backed model metadata. This RFC treats RFC 048 facts as compiler-owned source facts inside the larger semantic package. + +The semantic package must not weaken RFC 048 by falling back to source-text scraping or generated Rust inspection when checked metadata is available. If checked metadata cannot be produced because the source has parse or type errors, the semantic package must report degraded source facts and diagnostics. + +### Relationship to RFC 074 and RFC 075 + +RFC 074 owns template rendering and provenance. RFC 075 owns starter and capability descriptors, application, mutation planning, file roles, tooling metadata, and agent guidance metadata. This RFC joins their records into the local semantic graph. + +Capability and template state must remain explicit project tooling state. The semantic package must not infer that a file is generated merely because it resembles a known template. + +### Relationship to RFC 076 + +RFC 076 owns policy evaluation and approval semantics. This RFC requires policy results to be surfaced through the semantic package, but does not define policy rules. + +When policy has not been evaluated for an object, the semantic package must distinguish `not-evaluated` from `allow`. Lack of a policy result must not be treated as permission. + +### Relationship to RFC 077 + +RFC 077 owns workspace topology and scoped mutation planning. This RFC requires semantic inspection to include selected workspace scope and member identity so tools do not accidentally treat whole-workspace facts as single-member facts. + +### Relationship to RFC 078 + +RFC 078 owns typed action semantics, source resolution, execution modes, risk labels, dry-run behavior, and invocation. This RFC requires actions to appear as semantic objects with stable identities and graph edges to inputs, outputs, providers, policy outcomes, evals, and generated artifacts where available. + +### Relationship to RFC 079 + +RFC 079 owns the registry artifact graph. This RFC owns the local project semantic graph. The two graphs should share compatible artifact kinds, relationship vocabulary, and identity references where practical, but the local semantic graph must work without a public registry. + +Registry metadata may enrich local inspection, but it must not replace receiver-owned planning, policy, or mutation authority. + +### Relationship to RFC 080 + +RFC 080 owns AI asset metadata, prompt templates, datasets, evals, agent guidance, and local/cloud execution constraints. This RFC requires those facts to appear in inspection output when they are project-relevant and available. + +Prompt templates and system messages that affect project behavior must be inspectable as artifacts. Agent guidance must remain descriptive and must not cause implicit agent execution. + +### Relationship to RFC 085, RFC 086, RFC 087, and RFC 096 + +Those RFCs own field metadata, schema descriptors, reusable field contracts, model composition, and declaration metadata blocks. This RFC requires their normalized checked facts and provenance edges to be visible through the semantic package where supported. + +Adapter outputs must remain projections of checked descriptors, not source truth. The semantic package should preserve edges from adapter outputs back to descriptor identities when available. + +### Relationship to RFC 092 and RFC 097 + +RFC 092 owns interactive runtime target manifests and host capability contracts. RFC 097 owns the Rust-hosted caller boundary. This RFC allows those emitted manifests, host capability facts, generated Rust-facing artifacts, and caller metadata to appear in the semantic package when available, especially for LSP, docs, CI, and registry inspection. + +## Alternatives considered + +### Keep subsystem JSON outputs independent + +Rejected because it preserves fragmentation. Independent outputs can be useful, but they must share identities and be joinable through a canonical project model. + +### Make the LSP the only integration owner + +Rejected because CI, docs tooling, registry tooling, and agents need the same facts outside an editor. LSP is the proving consumer, not the source of truth. + +### Put the semantic layer in `incan.pub` + +Rejected because local projects must remain inspectable without registry access, and local tooling owns receiver-side mutation plans and policy. Registry graph metadata can enrich inspection but must not be required for it. + +### Use generated Rust as the inspection source + +Rejected because Incan semantics include source-level facts, metadata, provenance, policy, capabilities, and actions that generated Rust either cannot represent or should not be authoritative for. + +### Treat agent guidance as separate from normal tooling + +Rejected because giving agents a special path would create drift and privilege confusion. Agents should consume the same semantic facts as IDEs and CI, subject to the same policy boundaries. + +## Drawbacks + +This RFC adds an integration obligation across many subsystems. Each subsystem must preserve identities and enough structured data for the semantic package, which can slow early implementation. + +A broad semantic package can become too large or too slow if every command eagerly computes every fact. Implementations will need focused views, lazy computation, or scope selection while preserving the same identity and degraded-state contract. + +Versioning the inspection schema creates compatibility work. Once tools and agents depend on the JSON shape, changes need migration discipline. + +There is a risk of overpromising if implementation work tries to expose every artifact kind at once. Implementation sequencing should prove the local compiler and lifecycle join while preserving the full 1.0 contract described by this RFC. + +## Implementation architecture + +This section is non-normative. + +A practical implementation shape is to treat the semantic inspection surface as a join over two fact domains: + +- compiler facts: modules, declarations, types, contracts, diagnostics, checked metadata, schema descriptors, and stable source identities; +- project facts: manifests, workspaces, lock state, capabilities, actions, templates, generated-file provenance, policy, artifacts, AI assets, and registry-derived local metadata. + +The join should happen through stable identities and graph edges rather than by embedding subsystem-specific blobs that consumers must reinterpret. Subsystems may still own their specialized payloads, but the semantic package should expose enough shared fields for generic tooling to navigate the project. + +Implementations should support focused queries so LSP and CI can request only the facts they need. Focused query output should remain a semantic package fragment with the same schema version, identity rules, degraded-state model, and edge vocabulary as full inspection output. + +## Layers affected + +- **Compiler semantic analysis**: must expose checked source facts, diagnostics, stable identities, and degraded states in a form that the semantic package can consume. +- **Project model / lifecycle tooling**: must expose manifest, workspace, lock, capability, action, template, policy, provenance, and AI asset facts through shared identities. +- **CLI / tooling**: must provide machine-readable inspection and graph explanation commands, plus focused views where needed. +- **LSP / IDE tooling**: should consume semantic package facts for project views, hovers, definitions, diagnostics, run actions, generated-file status, policy status, and agent guidance discovery. +- **Docs tooling**: should consume checked declarations, schema descriptors, provenance, and artifact edges from the semantic package where useful. +- **CI / automation**: should consume action, policy, stale-artifact, eval, and degraded-state facts without parsing human output. +- **Registry / package integration**: should map local artifact identities and relationship edges to registry artifact graph metadata when publishing or inspecting packages. +- **Agentic tooling**: may consume the semantic package for context selection and proposal generation, but must respect policy outcomes and approval boundaries. + +## Unresolved questions + +- Should the canonical command be `incan inspect`, `incan project inspect`, `incan graph inspect`, or another spelling? +- Should graph explanation be a subcommand of inspection, such as `incan inspect explain`, or a separate `incan graph explain` command? +- Which semantic package schema fields are mandatory for the 1.0 north-star contract, and which unsupported domains should appear as explicit degraded facts until their owning RFCs land? +- Which identity formats should be stable across machines, packages, and versions, and which should be explicitly local-only? +- Should focused LSP queries use the same JSON schema directly or a protocol-specific projection that preserves semantic package identities? +- How should semantic package fragments be cached and invalidated without standardizing compiler-internal storage? +- Should exported package artifacts embed a semantic package fragment, or should they embed only RFC 048 metadata plus artifact graph metadata until a later publishing RFC? +- What compatibility policy should apply when an older tool consumes a newer semantic package with unknown object or edge kinds? + + diff --git a/workspaces/docs-site/docs/roadmap.md b/workspaces/docs-site/docs/roadmap.md index 52f4a08b9..ea52040e5 100644 --- a/workspaces/docs-site/docs/roadmap.md +++ b/workspaces/docs-site/docs/roadmap.md @@ -1,11 +1,12 @@ -# Incan Roadmap (Status-Focused) +# Incan Roadmap -This page tracks the implementation status and near-term planning (without being prescriptive about timelines). +This page tracks implementation status, release scope, and sequencing. Incan development is driven by RFCs (Request for Comments). - An RFC captures a design proposal for a feature, including syntax, semantics, and implementation details. - RFCs are not necessarily implemented in the order they are written. +- Milestones track release posture and sequencing. They define scope, not urgency. See the [RFCs](RFCs/index.md) page for more information about RFCs. @@ -15,55 +16,133 @@ This table is autogenerated from the RFC files (it reads each RFC’s `**Status: --8<-- "_snippets/tables/rfcs_index.md" -## Core Phases (overview) +## Strategic Direction -- Core language + runtime -- Stdlib + tooling (fmt, test, LSP, VS Code extensions) -- Web backend (Axum) -- Interactive runtime stdlib contracts (target manifests, host capabilities, artifacts, optional GPU surfaces) — [RFC 092](RFCs/092_interactive_runtime_stdlib_contracts.md) -- Rust interop +Incan's current direction is: -## Current Focus +> Python-readable at the base, domain-native at the edges, compiler-inspectable all the way down. -- Language stability/feature freeze (core semantics + test surface): - - [RFC 000] (core semantics) *Done* - - [RFC 008] (const bindings) *Done* - - Tests surface: - - [RFC 001] (test fixtures) *In Progress* - - [RFC 002] (parametrized tests) *Draft* - - [RFC 004] (async fixtures) *Done* -- Interactive runtime stdlib contracts ([RFC 092]): **Draft** — target manifests, host capability declarations, execution regions, artifact metadata, diagnostics, input/accessibility hooks, and optional GPU capability surfaces for downstream runtime consumers +That means Incan should not compete as a small systems language or as a generic Python clone. The compiler, standard library, and tooling should make domain packages, capability metadata, policy, generated artifacts, diagnostics, and backend facts inspectable by humans and agents. -## Ecosystem keystones (planned) +The near-term roadmap is therefore split into four release lanes: -These are the cross-cutting capabilities that make Incan feel “capable” for real engineering work. This list is intentionally kept high-level and status-oriented (RFCs will be added over time). +- Tooling and first-contact inspection. +- Backend replacement foundation. +- Backend cutover. +- Broader feature reopening after the compiler architecture is no longer split between old and new semantic paths. -- Standard library contracts for real programs (HTTP, filesystem/paths, process, env, time, logging, config) -- Capability-based access model for IO/process/env/network (secure-by-default for tools) -- Interactive execution engine: `incan run -i` (expression-first) → eventual Jupyter/kernel interop → richer workspace UX -- Packaging/distribution story for tools and projects (reproducible builds, artifact creation) -- Rust-hosted Incan caller boundary for native Rust applications consuming Incan-authored libraries ([RFC 097](RFCs/097_rust_hosted_incan_caller.md)) +## Release Milestones -## Status by Area (high-level) +### 0.4 Release: tooling and inspection -- Core language: see [RFC 000] / [RFC 008] -- Tooling (build/run/fmt/test): see the CLI docs and [RFC 001]/[RFC 002]/[RFC 004]/[RFC 007] for the planned testing surface -- Rust interop: see [RFC 005] / [RFC 013] and the [Rust Interop guide](language/how-to/rust_interop.md) -- Rust-hosted Incan consumption: see [RFC 097](RFCs/097_rust_hosted_incan_caller.md) for the proposed caller boundary between native Rust applications and Incan-authored libraries -- Web: see [Web Framework guide](language/tutorials/web_framework.md) (stabilization ongoing); interactive runtime stdlib contracts in [RFC 092](RFCs/092_interactive_runtime_stdlib_contracts.md) +The 0.4 milestone is the tooling and inspection release. It focuses on: -## Upcoming (next) +- canonical SDK install path; +- zero-clone starter flow; +- first-contact docs and positioning; +- stable machine-readable diagnostics; +- diagnostic explain catalog; +- codegraph export for agent/maintainer code intelligence; +- generated Rust and emitted artifact inspection; +- build reports. -- Interactive runtime stdlib contracts per [RFC 092](RFCs/092_interactive_runtime_stdlib_contracts.md) (target manifests, host capabilities, execution regions, artifact metadata, diagnostics, input/accessibility hooks, optional GPU surfaces) -- Test runner fixture execution (setup/teardown lifecycle) -- Dev server + prod build pipeline for WASM target -- Python-style generators ([RFC 006]) — `yield` + `Generator[T]` satisfying the iteration protocol -- Inline tests ([RFC 007]) — `@test` in source files, Rust-style proximity -- **Later / superseded by narrower RFCs:** WASM/JSX-native parser & codegen, `--target wasm`, dev server + prod pipeline tuned for WASM, WebGPU-style 3D, and broader non-browser/native-class runtime targets should advance only through focused RFCs after the stdlib contracts in RFC 092 are validated +New language/runtime feature work is out of scope unless it directly supports that tooling path. + +Core tracking issues: + +- [#223](https://github.com/dannys-code-corner/incan/issues/223): 0.4 tooling, inspection, and first-contact umbrella. +- [#428](https://github.com/dannys-code-corner/incan/issues/428): canonical SDK installer and release manifest. +- [#553](https://github.com/dannys-code-corner/incan/issues/553): zero-clone starter project flow. +- [#551](https://github.com/dannys-code-corner/incan/issues/551): first-contact quickstart and positioning docs. +- [#554](https://github.com/dannys-code-corner/incan/issues/554): release direction notes and scope guard. +- [#573](https://github.com/dannys-code-corner/incan/issues/573): codegraph export. +- [#589](https://github.com/dannys-code-corner/incan/issues/589): stable JSON diagnostics. +- [#590](https://github.com/dannys-code-corner/incan/issues/590): diagnostic explain catalog. +- [#591](https://github.com/dannys-code-corner/incan/issues/591): build artifact report. +- [#567](https://github.com/dannys-code-corner/incan/issues/567): generated Rust inspection tooling and quality gates. +- [#592](https://github.com/dannys-code-corner/incan/issues/592): RFC template inspectability prompts, if tiny and opportunistic. + +### 0.5 Release: backend foundation and Hees.ai proof lane + +The 0.5 milestone begins deprecating the Rust-source backend as the semantic path. It introduces the compiler foundations needed for a backend-neutral middle end: + +- stable compiler IDs; +- backend-neutral semantic facts; +- `IncanType` and semantic type modeling; +- ABI v0 design hooks; +- HIR v0; +- behavior inventory; +- backend migration scaffolding. + +Stdlib RFC/work is allowed in this lane. Hees.ai is also allowed, but only as a constrained commercial and dogfood proof path that validates compiler, stdlib, runtime, and tooling direction. Hees.ai work should consume general Incan surfaces, not quietly become broad product scope inside the language milestone. + +Core tracking issues: + +- [#634](https://github.com/dannys-code-corner/incan/issues/634): v1.0 middle-end foundation umbrella. +- [#646](https://github.com/dannys-code-corner/incan/issues/646): current compiler behavior inventory. +- [#647](https://github.com/dannys-code-corner/incan/issues/647): deprecate Rust-source backend as semantic path. +- [#648](https://github.com/dannys-code-corner/incan/issues/648): stable compiler IDs and semantic facts database. +- [#649](https://github.com/dannys-code-corner/incan/issues/649): `IncanType` semantic type model and ABI v0 hooks. +- [#650](https://github.com/dannys-code-corner/incan/issues/650): HIR v0 and snapshot tests. +- [#282](https://github.com/dannys-code-corner/incan/issues/282): backend orchestration migration scaffolding. +- [#224](https://github.com/dannys-code-corner/incan/issues/224): `CompilationSession` semantic database transition. +- [#549](https://github.com/dannys-code-corner/incan/issues/549): Hees.ai governed workbench demo. +- [#651](https://github.com/dannys-code-corner/incan/issues/651): Hees.ai dependency inventory and guardrails. + +Allowed stdlib work includes `std.http`, `std.ci`, CLI framework, `std.archive`, `std.process`, `std.web` lifecycle, `std.environ`, package-level timezones, fallible reader chunk streams, and selected stdlib compilation/source-authored behavior work. + +### 0.6 Release: backend cutover + +The 0.6 milestone removes the Rust-source backend from the normal compiler path. The replacement backend should preserve supported behavior, report compatibility/migration details, and retire generated Rust as the semantic handoff. + +Only runtime/DSL RFC scope that stress-tests or supports the new backend belongs here. + +Core tracking issues: + +- [#652](https://github.com/dannys-code-corner/incan/issues/652): replacement backend parity cutover. +- [#653](https://github.com/dannys-code-corner/incan/issues/653): Body IR v0 and backend-owned lowering. +- [#654](https://github.com/dannys-code-corner/incan/issues/654): remove Rust-source backend and generated-Rust semantic handoff. +- [#655](https://github.com/dannys-code-corner/incan/issues/655): backend compatibility report and migration notes. +- [#225](https://github.com/dannys-code-corner/incan/issues/225): semantic facts adoption on backend cutover paths. +- [#656](https://github.com/dannys-code-corner/incan/issues/656): Rust-facing ABI and Cargo-native Incan package direction. +- [RFC 092](RFCs/092_interactive_runtime_stdlib_contracts.md): interactive runtime stdlib contracts. +- [RFC 093](RFCs/093_std_telemetry_opentelemetry_observability.md): `std.telemetry`. +- [RFC 094](RFCs/094_context_managers.md): context managers. +- [RFC 095](RFCs/095_span_vocabulary_blocks.md): span vocabulary blocks. + +### 0.7 Release: feature reopening + +The 0.7 milestone is the broader feature reopening lane after the backend replacement is complete. This is where deferred language, package, registry, lifecycle, interop, docs-generation, editor, and product-surface work can resume. + +Examples of deferred lanes: + +- incan.pub and package registry/product identity. +- InQL and Pallay SDK dogfood. +- source-local feature metadata. +- Python interop research. +- checked API docs generation. +- Windows/package-manager/self-upgrade convenience work. +- trait/newtype language features not required by backend cutover. +- broader editor and package lifecycle work. + +### 1.0 Release: stabilization and public contracts + +The 1.0 milestone consolidates the post-cutover compiler architecture, ABI/package direction, tooling contracts, stdlib maturity, ecosystem workflows, and documentation into a coherent public surface. + +1.0 should describe what Incan is, what it guarantees, how packages and generated artifacts are consumed, and where Rust-facing interop boundaries are stable. + +## Status by Area + +- Core language: see [RFC 000] / [RFC 008]. +- Testing surface: see [RFC 001] / [RFC 002] / [RFC 004] / [RFC 007]. +- Tooling and first-contact: install, starter, diagnostics, explain, codegraph, artifact inspection, and build reports are the immediate release surface. +- Rust interop: see [RFC 005] / [RFC 013] and the [Rust Interop guide](language/how-to/rust_interop.md). Rust-hosted consumption should be reframed through ABI and Cargo-native package direction instead of generated Rust as the public semantic path. +- Web and interactive runtime: see the [Web Framework guide](language/tutorials/web_framework.md), [RFC 092](RFCs/092_interactive_runtime_stdlib_contracts.md), and related runtime/DSL RFCs. +- Standard library: stdlib work is allowed in the backend-foundation lane where it helps real programs and dogfood paths validate compiler/runtime direction. ## Deferred / Later -The following items are intentionally deferred to later, and might be revisited in the future: +The following items remain intentionally deferred until they have a focused RFC or implementation lane: - SSR/SSG for frontend: Server-Side Rendering / Static Site Generation for the WASM/UI stack (render pages ahead of time or on the server, then hydrate). From cf5191cec291ea341781b344b45d502984d16218 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 11:20:18 +0200 Subject: [PATCH 23/58] chore - align RFC package artifacts with backend direction (#618) --- .../docs/RFCs/034_incan_pub_registry.md | 78 ++++++++++-------- .../docs/RFCs/079_incan_pub_artifact_graph.md | 2 + .../docs/RFCs/097_rust_hosted_incan_caller.md | 79 ++++++++++--------- ...incan_semantic_layer_inspection_surface.md | 2 +- 4 files changed, 87 insertions(+), 74 deletions(-) diff --git a/workspaces/docs-site/docs/RFCs/034_incan_pub_registry.md b/workspaces/docs-site/docs/RFCs/034_incan_pub_registry.md index efa76f6e8..d4b75e5f4 100644 --- a/workspaces/docs-site/docs/RFCs/034_incan_pub_registry.md +++ b/workspaces/docs-site/docs/RFCs/034_incan_pub_registry.md @@ -13,6 +13,8 @@ Define the `incan.pub` package registry: the protocols, guarantees, and CLI commands that allow Incan library authors to publish packages and consumers to resolve them. The registry must be EU-hosted, integrity-verified, signature-aware, and operationally cheap enough to run with predictable capped spend. Exact vendor choice and launch-era cost numbers are implementation details, not the core contract. +This Draft was originally written against RFC 031's generated-Rust library artifact shape. The package format and resolution model below are now amended to align with the backend replacement direction: generated Rust may remain an internal/debug artifact, but it is not the public package compatibility path. The registry stores Incan package artifacts with semantic manifests, ABI/package metadata, and optional backend artifacts; consumers resolve Incan semantics first, not downloaded generated Rust source. + ## Constraints Two non-negotiable requirements drive every decision in this RFC: @@ -139,21 +141,22 @@ This is a static site generated from the index — no dynamic server needed for ### The package format -A `.crate` file is a gzipped tarball containing the Rust crate output from `incan build --lib` plus the `.incnlib` type manifest: +An Incan package artifact is a compressed archive, conventionally `.incanpkg`, containing package metadata, semantic manifests, ABI/package metadata, and optional emitted artifacts: ```text -mylib-0.1.0.crate (tar.gz): +mylib-0.1.0.incanpkg (tar.gz): └── mylib-0.1.0/ - ├── Cargo.toml # Generated Rust crate metadata - ├── src/ - │ ├── lib.rs # Generated Rust source - │ └── widgets.rs - └── .incnlib # Type manifest (JSON, from RFC 031) + ├── incan-package.json # Package identity, dependencies, ABI/schema versions + ├── .incnlib # Checked type/API manifest + ├── semantic/ # Optional semantic package fragments + ├── abi/ # Optional Rust-facing ABI/package metadata + ├── src/ # Optional source snapshot, when publishing policy allows it + └── artifacts/ # Optional target artifacts and inspection reports ``` -The `.incnlib` file is invisible to Cargo (which ignores unknown files in the tarball). The `incan` CLI extracts it for typechecking; `cargo build` only sees the Rust source. +Generated Rust source is not required to be present and must not be the public compatibility contract. If an implementation includes generated Rust for inspection, debugging, or migration, that output is an artifact with provenance metadata, not the semantic source of truth for package consumers. -This is a single artifact — the type manifest and compiled Rust source are never stored or transferred separately. This simplifies every part of the pipeline: publish uploads one file, download retrieves one file, cache stores one file. +This is still one immutable package artifact for the registry: publish uploads one archive, download retrieves one archive, cache stores one archive, and checksums/signatures cover the archive as a whole. The compiler and backend decide how to consume package semantics and emit target-specific code for the current build. ### Index format @@ -174,9 +177,10 @@ index/my/li/mylib |---|---|---| | `name` | string | Package name | | `vers` | string | SemVer version | -| `cksum` | string | SHA256 of the `.crate` tarball (prefixed with `sha256:`) | +| `cksum` | string | SHA256 of the package archive (prefixed with `sha256:`) | | `deps` | array | Incan library dependencies (`name` + `req` version range) | -| `rust_deps` | array | Rust crate dependencies (merged into consumer's Cargo.toml) | +| `rust_deps` | array | Rust crate dependencies required by package backend/ABI metadata, resolved by the compiler backend rather than blindly merged into user-authored manifests | +| `artifact_kind` | string | Package artifact format, such as `incanpkg` | | `incan_version` | string | Minimum compiler version required | | `yanked` | bool | If true, existing lockfiles still resolve but new resolves skip | | `publisher` | string | Publisher identity (username) | @@ -207,7 +211,7 @@ Headers: X-Signature: MEUC... (base64, optional in Phase 1) X-Certificate: MIIB... (base64, optional in Phase 1) -Body: .crate tarball (binary) +Body: Incan package archive (binary) ``` **Server-side validation:** @@ -217,11 +221,13 @@ Body: .crate tarball (binary) 3. Verify `(name, version)` does not already exist → 409 Conflict 4. Verify `X-Checksum` matches SHA256 of request body 5. If signature provided: verify Sigstore signature is valid, signer matches publisher -6. Extract `.incnlib` from tarball → verify it parses (basic structural validation) -7. Store `.crate` in object storage: `crates//.crate` -8. Store signature artifacts: `crates//.crate.sig`, `.cert` -9. Update index: append version line to `index//` -10. Invalidate CDN cache for the index entry 11. Return 200 +6. Extract `incan-package.json` and `.incnlib` from the archive and verify they parse +7. Reject archives that require generated Rust source as the package compatibility path +8. Store package archive in object storage: `packages//.incanpkg` +9. Store signature artifacts: `packages//.incanpkg.sig`, `.cert` +10. Update index: append version line to `index//` +11. Invalidate CDN cache for the index entry +12. Return 200 **Response:** `{ "published": "mylib", "version": "0.1.0" }` @@ -233,15 +239,15 @@ Headers: Body: { "name": "mylib", "version": "0.1.0" } ``` -Sets `yanked: true` in the index entry. Does not delete the `.crate` file (existing lockfiles and builds that reference this exact version still work). +Sets `yanked: true` in the index entry. Does not delete the package archive (existing lockfiles and builds that reference this exact version still work). #### `GET /index//` Returns the JSON-lines index file for the named package. Served from object storage, cached at CDN edge. -#### `GET /crates//.crate` +#### `GET /packages//.incanpkg` -Returns the `.crate` tarball. Served from object storage, cached at CDN edge. Immutable forever — cache headers set to maximum TTL. +Returns the package archive. Served from object storage, cached at CDN edge. Immutable forever, with cache headers set to maximum TTL. ### Authentication @@ -270,22 +276,22 @@ $ incan login ### Package signing with Sigstore -Every `incan publish` signs the `.crate` tarball using [Sigstore](https://sigstore.dev) keyless signing: +Every `incan publish` signs the package archive using [Sigstore](https://sigstore.dev) keyless signing: **Publish side:** 1. `incan publish` initiates an OIDC flow (opens browser → GitHub/GitLab/Google login) 2. Sigstore's Fulcio CA issues a short-lived signing certificate tied to the OIDC identity -3. The `.crate` file's SHA256 digest is signed with the ephemeral private key +3. The package archive's SHA256 digest is signed with the ephemeral private key 4. The signature + certificate + checksum are recorded in Sigstore's Rekor transparency log -5. The signature and certificate are sent to the registry alongside the `.crate` +5. The signature and certificate are sent to the registry alongside the package archive **Verification side (`incan build`):** -1. Download `.crate` + `.sig` + `.cert` from registry -2. Verify SHA256 of `.crate` matches the index checksum +1. Download package archive + `.sig` + `.cert` from registry +2. Verify SHA256 of the archive matches the index checksum 3. Verify the certificate was issued by Sigstore Fulcio CA -4. Verify the signature matches the `.crate` digest +4. Verify the signature matches the archive digest 5. Verify the signer identity in the certificate matches the `publisher` field in the index 6. Verify the signature is recorded in Sigstore Rekor (transparency log lookup) @@ -325,12 +331,14 @@ Resolution: 2. For each registry dep: `GET https://incan.pub/index//` 3. Parse JSON lines, filter by version requirement, select newest matching non-yanked version 4. Check local cache `~/.incan/libs/-/` — if cached and checksum matches, skip download -5. `GET https://incan.pub/crates//.crate` +5. `GET https://incan.pub/packages//.incanpkg` 6. Verify SHA256 checksum matches index entry 7. Verify Sigstore signature (if present; warn if absent) 8. Extract to `~/.incan/libs/-/` -9. Load `.incnlib` into typechecker symbol table -10. Wire Rust crate as path dependency in generated `Cargo.toml` +9. Load `.incnlib`, package metadata, and ABI/semantic facts into the compiler package database +10. Let the backend consume those package facts and emit the target build artifacts + +The resolver must not wire downloaded generated Rust source into generated `Cargo.toml` as the package compatibility path. Rust-facing consumption should go through the ABI/Cargo-native package direction rather than treating generated Rust internals as public API. **Lockfile (`incan.lock`):** on first resolution, write resolved versions + checksums to `incan.lock`. Subsequent builds use the lockfile for reproducibility. `incan update` re-resolves. @@ -342,7 +350,7 @@ Resolution: | `incan remove ` | Remove a dependency from `incan.toml` | | `incan update` | Re-resolve all dependencies and update `incan.lock` | | `incan login` | Authenticate with `incan.pub`, save token to `~/.incan/credentials` | -| `incan publish` | Build library, package `.crate`, sign, upload to registry | +| `incan publish` | Build library, package `.incanpkg`, sign, upload to registry | | `incan yank ` | Mark a version as yanked (still downloadable but skipped in new resolves) | | `incan search ` | Search the registry index (client-side text search over cached index) | | `incan owner add ` | Add a co-owner for a package | @@ -432,10 +440,10 @@ The registry service should talk to object storage via an S3-compatible API or e Kellnr is a self-hosted Rust crate registry that implements the Cargo registry protocol. It was considered and rejected because: -- It only speaks the Cargo registry protocol — no awareness of `.incnlib` manifests +- It only speaks the Cargo registry protocol and has no awareness of Incan package manifests, semantic metadata, or ABI metadata - Requires a persistent server (no scale-to-zero) - Written in Rust, not Incan (misses the dogfooding opportunity) -- The `.incnlib`-in-`.crate` trick makes Cargo protocol compatibility free anyway — any tool that can download a `.crate` gets both the Rust source and the type manifest +- Treating generated Rust as a Cargo package artifact would recreate the public-compatibility path the backend direction is moving away from ## Reference service implementation (informative) @@ -449,9 +457,9 @@ The important design constraint is portability: ## Interaction with existing features -- **RFC 031 (library system):** This RFC builds directly on RFC 031. The `.incnlib` manifest format, `pub::` import syntax, and `incan build --lib` command are defined there. This RFC adds the distribution layer on top. -- **RFC 027 (incan-vocab):** Library soft keyword declarations are serialized into the `.incnlib` manifest during `incan build --lib` and included in the `.crate` tarball. The registry is unaware of soft keywords — it just stores and serves packages. -- **`rust::` imports (RFC 005):** `pub::` registry imports and `rust::` Rust crate imports coexist. A package's Rust dependencies (from its generated `Cargo.toml`) are listed in the index entry's `rust_deps` field. +- **RFC 031 (library system):** This RFC builds on RFC 031's `.incnlib` manifest format, `pub::` import syntax, and `incan build --lib` command, but supersedes any assumption that generated Rust source is the registry package contract. +- **RFC 027 (incan-vocab):** Library soft keyword declarations are serialized into checked package metadata during `incan build --lib` and included in the package archive. The registry is unaware of soft keywords; it stores and serves packages. +- **`rust::` imports (RFC 005):** `pub::` registry imports and `rust::` Rust crate imports coexist. A package's Rust dependencies may appear in package metadata, but the compiler backend owns how they are linked into the target build. ## Alternatives considered diff --git a/workspaces/docs-site/docs/RFCs/079_incan_pub_artifact_graph.md b/workspaces/docs-site/docs/RFCs/079_incan_pub_artifact_graph.md index 7a0bc5dc9..927de3eef 100644 --- a/workspaces/docs-site/docs/RFCs/079_incan_pub_artifact_graph.md +++ b/workspaces/docs-site/docs/RFCs/079_incan_pub_artifact_graph.md @@ -207,6 +207,8 @@ The graph should represent advisories and yanking as relationships rather than o RFC 034 owns core package registry semantics. This RFC extends the registry's conceptual model from package versions to related artifact nodes and relationships. +This RFC inherits RFC 034's amended package artifact boundary: generated Rust source is not the public package compatibility path. Artifact graph nodes may describe generated implementation artifacts for inspection, provenance, compatibility reports, or migration, but package semantics must remain grounded in Incan manifests, semantic metadata, ABI/package metadata, and registry artifact relationships. + ### Relationship to RFC 074 and RFC 075 Template, starter, and capability descriptors are local tooling contracts. The graph can distribute and index them, but local lifecycle tooling owns rendering and mutation planning. diff --git a/workspaces/docs-site/docs/RFCs/097_rust_hosted_incan_caller.md b/workspaces/docs-site/docs/RFCs/097_rust_hosted_incan_caller.md index b29df694b..f00f5bc33 100644 --- a/workspaces/docs-site/docs/RFCs/097_rust_hosted_incan_caller.md +++ b/workspaces/docs-site/docs/RFCs/097_rust_hosted_incan_caller.md @@ -20,49 +20,51 @@ ## Summary -This RFC defines a Rust-hosted Incan caller model: a native Rust application should be able to depend on an Incan-authored library through ordinary Cargo mechanics and call a curated, typed Rust-facing API without reverse-engineering generated code layout, manually wiring Incan runtime helpers, or treating every public Incan export as a stable Rust API. The model does not excuse poor generated Rust; the compiler must treat generated Rust as a first-class product surface. Incan should be a way for people and agents to author high-level Incan while producing great, idiomatic, fully-featured, opinionated Rust. The caller boundary is a higher-level host API shape built on top of that output, with generated adapters and a small support crate that own initialization, conversions, async/runtime policy, diagnostics, version checks, and panic/error containment. +This RFC defines a Rust-hosted Incan caller model: a native Rust application should be able to depend on an Incan-authored library through ordinary Cargo mechanics and call a curated, typed Rust-facing API without reverse-engineering compiler output, manually wiring Incan runtime helpers, or treating every public Incan export as a stable Rust API. + +This Draft is now framed around a Rust-facing caller ABI and Cargo-usable Incan package artifact. Generated Rust source may remain useful for inspection, debugging, migration, or an implementation backend, but it must not be the public package compatibility path. The caller boundary is the stable host API shape; it is backed by checked Incan metadata, ABI/package metadata, generated adapters where needed, and a small support crate that owns initialization, conversions, async/runtime policy, diagnostics, version checks, and panic/error containment. ## Core model 1. **Rust-hosted consumption is a first-class direction:** Incan already lets Incan code call Rust; this RFC defines the reverse direction where Rust code deliberately calls Incan-authored behavior. -2. **The generated Rust crate remains the compilation artifact:** RFC 031's generated library crate is still the concrete object Cargo builds and links. -3. **Generated Rust is a first-class product surface:** Rust-hosted consumption must not depend on a cleanup wrapper that hides bad emission. The emitted crate should be inspectable, idiomatic, documented, testable, debuggable, and useful to Rust users and tools. -4. **The caller boundary is the stable host-facing shape:** Rust consumers should target generated caller helpers and support traits that make calls feel natural from Rust while preserving Incan semantics. +2. **The Cargo-usable artifact is not generated Rust source as contract:** Rust hosts need a Cargo-native dependency shape, but the public compatibility promise is the caller ABI/package metadata, not compiler-emitted Rust internals. +3. **Implementation artifacts remain inspectable:** generated Rust, object code, IR snapshots, or other backend artifacts should be inspectable and debuggable where emitted, but they are not the host-facing semantic contract. +4. **The caller boundary is the stable host-facing shape:** Rust consumers should target caller helpers and support traits that make calls feel natural from Rust while preserving Incan semantics. 5. **The `pub` system should grow rather than be bypassed:** Rust-hosted exports should be modeled as a public export profile or facet, not as an unrelated side channel. 6. **Types cross through reusable helpers:** primitive values, models, newtypes, enums, `Result`, `Option`, collections, and Rust-backed types should cross through explicit, versioned conversion helpers that can also simplify emitter responsibilities. 7. **Runtime policy is explicit:** async execution, logger/telemetry hooks, host capabilities, panic handling, and initialization must be part of the caller contract rather than incidental generated code behavior. -8. **Cargo remains the host integration substrate:** Rust applications should use normal dependency declarations, build scripts, or generated package artifacts instead of a bespoke binary loader. +8. **Cargo remains the host integration substrate:** Rust applications should use normal dependency declarations, build scripts, or Cargo-usable package artifacts instead of a bespoke binary loader. ## Motivation Incan's current interop story is strong in one direction: Incan source imports Rust crates, wraps Rust types, and can implement Rust traits for Incan-owned types. That is necessary, but it does not answer the common embedding question: "how do I integrate Incan-generated code into my native Rust application code?" -That question exposes a deeper product direction. If Incan compiles to Rust, then generated Rust cannot be treated as a temporary compiler byproduct. It is one of the language's core deliverables. At minimum, Incan can become a disciplined way for people and agents to generate excellent Rust with strong opinions, complete runtime wiring, useful derives, reproducible packaging, diagnostics, tests, docs, and integration hooks included by default. +That question exposes a deeper product direction. Incan should produce Rust-native integration artifacts without making generated Rust source the package contract. Generated Rust can still be valuable as an implementation artifact and inspection surface, but the durable promise to Rust hosts should be an explicit caller ABI, metadata, support crate contract, and Cargo-native package shape. -RFC 031 already created the core artifact foundation: an Incan library can build a generated Rust crate plus a semantic manifest. That crate can technically be added as a Cargo path dependency today, and the compiler should make that generated crate good Rust. The missing product-level answer is the shape above the crate: which public exports are intended for Rust hosts, which helper types make calls feel Incan-like from Rust, and which support code owns repeated boundary mechanics. +RFC 031 created the first library artifact foundation: an Incan library can build a semantic manifest and implementation artifacts. The missing product-level answer is the shape above those artifacts: which public exports are intended for Rust hosts, which helper types make calls feel Incan-like from Rust, which support code owns repeated boundary mechanics, and which metadata defines compatibility without exposing generated Rust internals as API. -The missing piece is not only a command. It is a boundary. A Rust application embedding Incan code needs to know which calls are stable, how values convert, how errors surface, whether async calls need a runtime, whether panics are contained, how logs and telemetry are connected, and which compiler/runtime version produced the artifact. Without that boundary, users either treat generated Rust as hand-authored Rust or avoid Rust-hosted Incan entirely. +The missing piece is not only a command. It is a boundary. A Rust application embedding Incan code needs to know which calls are stable, how values convert, how errors surface, whether async calls need a runtime, whether panics are contained, how logs and telemetry are connected, and which compiler/runtime version produced the artifact. Without that boundary, users either treat compiler output as hand-authored Rust or avoid Rust-hosted Incan entirely. The end-state should be simple: an application team writes domain logic, policy, validation, transformations, routing decisions, or workflow steps in Incan, builds or publishes a Rust-facing package, and calls it from Rust as a typed dependency. The Rust app should remain in charge of process lifecycle, threading, deployment, and host resources. The Incan package should remain in charge of Incan language semantics and its exported behavior. ## Goals - Define a Rust-hosted caller model for native Rust applications that call Incan-authored libraries. -- Define a stable generated caller surface that builds on good generated Rust instead of hiding it. -- Make first-class generated Rust quality part of the Rust-hosted integration contract. +- Define a stable Rust-facing caller surface backed by ABI/package metadata. +- Keep implementation artifacts inspectable without making generated Rust source the public compatibility path. - Define how the `pub` system can express Rust-hosted public export profiles or facets. - Define conversion requirements for primitives, collections, models, enums, newtypes, results, options, and Rust-backed values. - Define reusable caller helpers that can reduce bespoke emitter output for common boundary shapes. - Define initialization, version, diagnostics, panic, async, logging, telemetry, and host capability responsibilities at the caller boundary. -- Preserve RFC 031's generated Rust crate as the concrete Cargo artifact. +- Preserve Cargo-native Rust host ergonomics without requiring generated Rust source to be the concrete public artifact. - Leave room for both local path development and published package consumption. - Keep Rust integration Rust-shaped enough to feel natural in Rust applications without making Incan source adopt Rust's full API design model. ## Non-Goals -- This RFC does not accept low-quality generated Rust as an implementation detail. The generated crate should remain readable and debuggable even when Rust hosts use the higher-level caller API. -- This RFC does not require generated Rust to look handwritten in every line. It requires generated Rust to be high-quality, documented where appropriate, idiomatic at its public surfaces, and stable enough for tooling and debugging. -- This RFC does not make every generated Rust module a stable public API. +- This RFC does not make generated Rust source the public package compatibility path. +- This RFC does not require every implementation backend to emit Rust source. +- This RFC does not make every generated Rust module a stable public API where generated Rust is still emitted. - This RFC does not replace `rust::` imports or Rust interop from Incan source. - This RFC does not define a C ABI, dynamic plugin ABI, `extern "C"` boundary, or cross-language FFI story. - This RFC does not require a Rust application to run the Incan compiler at runtime. @@ -106,14 +108,14 @@ The library is built for Rust-hosted consumption: incan build --lib --caller rust ``` -That command emits a normal Rust crate artifact with a generated caller module and metadata. A Rust application can then depend on it through Cargo: +That command emits or materializes a Cargo-usable caller artifact with caller metadata. A Rust application can then depend on it through Cargo: ```toml [dependencies] pricing_rules = { path = "../pricing_rules/target/lib" } ``` -The Rust application calls the generated typed wrapper rather than internal generated implementation details: +The Rust application calls the typed caller wrapper rather than internal implementation details: ```rust use pricing_rules::caller::{Caller, OrderInput}; @@ -129,7 +131,7 @@ fn price() -> Result<(), Box> { } ``` -For async entrypoints, the generated caller surface should make runtime requirements explicit: +For async entrypoints, the caller surface should make runtime requirements explicit: ```rust use pricing_rules::caller::{AsyncCaller, OrderInput}; @@ -145,7 +147,7 @@ async fn price_async() -> Result<(), Box> { } ``` -If an Incan export is not in the Rust-hosted public profile, Rust code may still see generated Rust implementation symbols, but those symbols are not promised as the host-facing API. The distinction is about stability and ergonomics, not about hiding bad Rust. +If an Incan export is not in the Rust-hosted public profile, Rust code must not rely on whatever implementation symbols happen to exist. The distinction is about semantic authority: caller metadata and caller APIs are stable; compiler implementation artifacts are not. The author-facing model is: @@ -153,7 +155,7 @@ The author-facing model is: Incan library source -> checked public Incan API -> Rust-hosted public profile - -> generated Rust crate + caller metadata + -> Rust-facing ABI/package metadata + caller artifact -> native Rust application ``` @@ -173,7 +175,7 @@ The caller boundary must include: The caller boundary must not require Rust consumers to import arbitrary compiler-generated implementation modules as the host API. Internal generated modules may exist and should remain readable, but only the caller namespace is stable for Rust-hosted consumption. -The caller boundary should be generated as part of the same Cargo package that contains the generated library crate unless a package format or registry mode explicitly separates implementation and caller crates. A Rust consumer must be able to depend on the artifact using ordinary Cargo dependency mechanics. +The caller boundary should be generated or materialized as a Cargo-usable artifact. It may live in the same package as implementation artifacts or in a sibling package, but Rust consumers must not need to know the compiler's internal implementation layout. Caller-visible Incan functions must have a representable Rust signature. The compiler must reject a Rust-hosted public export when any parameter, return value, type parameter, effect, or captured dependency cannot be represented by the caller boundary. @@ -201,19 +203,20 @@ Host capabilities used by caller-visible Incan code must be visible through meta ### Caller artifact shape -The caller artifact should be a Cargo-usable package. The simplest local layout is still the generated library crate from RFC 031, extended with a stable `caller` namespace and caller metadata. +The caller artifact should be a Cargo-usable package backed by Incan-owned caller metadata and ABI metadata. A current implementation may materialize that as a generated Rust package, but the normative contract is the Cargo-usable caller artifact and its metadata, not the emitted source layout. Conceptually, the package contains: ```text -generated Rust implementation stable caller namespace caller metadata +ABI/package metadata semantic manifest Cargo metadata +implementation artifact(s) ``` -The exact directory layout is not normative. The normative requirement is that Rust consumers do not need to know which files came from Incan source lowering and which files are support glue. +The exact directory layout is not normative. The normative requirement is that Rust consumers do not need to know which files came from Incan source lowering, backend emission, support glue, or ABI materialization. ### Support crate @@ -241,9 +244,9 @@ Caller type projection should prefer ordinary Rust types where doing so preserve | `Result[T, E]` | `Result` for domain result values | | `List[T]` | `Vec` | | `Dict[K, V]` | map type with documented ordering/hash requirements | -| `model` | generated Rust struct | -| `enum` | generated Rust enum | -| `newtype` | generated Rust newtype with checked construction | +| `model` | Rust caller struct | +| `enum` | Rust caller enum | +| `newtype` | Rust caller newtype with checked construction | Borrowed Rust signatures may be generated as an optimization, but the semantic contract must first be expressible with owned values. Borrowed projections must not expose Incan lifetime or ownership details as user-authored Incan concepts. @@ -258,23 +261,23 @@ For a function whose Incan signature returns `Result[Quote, PricingError]`, the ### Async and runtime policy -Async caller exports must not assume that the generated package owns the process runtime. The Rust host should either provide an async context by calling async functions or explicitly opt into a blocking wrapper that documents runtime behavior. +Async caller exports must not assume that the caller package owns the process runtime. The Rust host should either provide an async context by calling async functions or explicitly opt into a blocking wrapper that documents runtime behavior. Caller metadata should state whether an export is synchronous, async, blocking, or requires host-provided runtime services. This should compose with RFC 092 target and host capability metadata when those contracts mature. ### Diagnostics and observability -Caller failures should identify the caller export name, the Incan function name, and source-span metadata when available. Logging and telemetry should route through host-provided hooks where configured, rather than unconditionally initializing global logging from the generated package. +Caller failures should identify the caller export name, the Incan function name, and source-span metadata when available. Logging and telemetry should route through host-provided hooks where configured, rather than unconditionally initializing global logging from the caller package. ### Compatibility and migration -This RFC is additive. Existing `incan build --lib` consumers may continue depending directly on generated crates, but that should be documented as a lower-level artifact consumption path rather than the recommended Rust-hosted integration path. +This RFC is additive but reframes older generated-crate consumption as transitional. Existing `incan build --lib` consumers may continue depending directly on generated crates while that path exists, but that should be documented as a lower-level implementation-artifact path rather than the recommended Rust-hosted integration path. -Once caller artifacts exist, docs should steer Rust application authors toward caller APIs and reserve raw generated crate internals for debugging, compiler tests, or advanced toolchain integration. +Once caller artifacts exist, docs should steer Rust application authors toward caller APIs and reserve backend artifacts for debugging, compiler tests, inspection, or advanced toolchain integration. ## Alternatives considered -- **Tell Rust users to depend on the generated crate directly** — Rejected as the sole answer because generated Rust can be good Rust and still lack the right host-facing API profile, repeated boundary helpers, and stability story. +- **Tell Rust users to depend on the generated crate directly** — Rejected because it makes generated Rust internals the compatibility path. Rust hosts need a stable caller ABI/package contract even if the current backend happens to emit Rust. - **Use a dynamic plugin or C ABI boundary** — Rejected for this RFC because Incan already emits Rust, and Rust-hosted applications should get normal Cargo type checking, optimization, and dependency resolution. - **Use only a `build.rs` helper in the Rust application** — Useful for local development, but insufficient as the whole model because published artifacts and registry workflows should not require every consumer to run the Incan compiler. - **Make every public Incan export Rust-callable automatically** — Rejected as the default because Incan's `pub` system should be enriched with host-facing profiles instead of flattening every public Incan symbol into the same Rust-hosted contract. @@ -290,28 +293,28 @@ Once caller artifacts exist, docs should steer Rust application authors toward c ## Implementation architecture -The recommended architecture is to extend library builds with a caller adapter generation pass that consumes checked public API metadata and caller export declarations. The adapter should call into the generated implementation crate through stable internal paths chosen by the compiler, while exposing only the caller namespace to host Rust code. +The recommended architecture is to extend library builds with a caller adapter generation pass that consumes checked public API metadata, semantic facts, ABI metadata, and caller export declarations. The adapter should call into backend-owned implementation artifacts through compiler-owned internal paths or ABI entrypoints, while exposing only the caller namespace to host Rust code. -The support crate should remain narrow and versioned. Generated artifacts should declare the caller ABI version they were emitted against and validate it at initialization. Metadata should be inspectable by docs, LSP, and registry tooling so Rust-hosted integration can be documented and discovered without building the package. +The support crate should remain narrow and versioned. Caller artifacts should declare the caller ABI version they were emitted against and validate it at initialization. Metadata should be inspectable by docs, LSP, and registry tooling so Rust-hosted integration can be documented and discovered without building the package. Local development may later add a build-script helper that invokes the Incan compiler from a Rust workspace, but that helper should produce the same caller boundary as a prebuilt or published package. -Current package-facing characterization shows that ordinary `incan build --lib` artifacts can already expose owned scalar callable parameters through generated package exports, but borrowed non-`Copy` callable parameters are not yet consumable across a `pub::` package boundary. A producer export such as `Callable[Payload, None]` currently emits a Rust signature shaped like `fn(&Payload) -> ()`, while a downstream Incan consumer observer still emits `fn(Payload)`, causing Cargo type checking to fail. The caller adapter work must either generate a compatible borrowed wrapper for that boundary or reject/document the unsupported export before producing a broken consumer build. +Current package-facing characterization shows why generated implementation artifacts are not enough as the public contract. Ordinary `incan build --lib` artifacts can already expose owned scalar callable parameters through package exports, but borrowed non-`Copy` callable parameters are not yet consumable across a `pub::` package boundary. A producer export such as `Callable[Payload, None]` currently emits a Rust signature shaped like `fn(&Payload) -> ()`, while a downstream Incan consumer observer still emits `fn(Payload)`, causing Cargo type checking to fail. The caller adapter work must either generate a compatible borrowed wrapper for that boundary or reject/document the unsupported export before producing a broken consumer build. ## Layers affected -- **Library artifact model**: library builds must be able to include caller metadata and generated caller adapters alongside existing semantic manifests and generated Rust crates. +- **Library artifact model**: library builds must be able to include caller metadata, ABI/package metadata, caller adapters, and semantic manifests alongside backend implementation artifacts. - **Typechecker / API metadata**: caller export validation must prove that selected entrypoints and boundary types are representable for Rust-hosted calls. -- **IR Lowering / Emission**: generated Rust output must preserve a stable caller namespace and avoid making internal generated modules part of the Rust-hosted contract. +- **IR Lowering / Emission**: backend output must preserve a stable caller namespace or ABI entrypoint and avoid making internal generated modules part of the Rust-hosted contract. - **Stdlib / Runtime (`incan_stdlib`)**: host-facing runtime hooks, errors, logging, telemetry, async, and capability surfaces may need caller-compatible contracts. - **CLI / Tooling**: build commands should expose a caller artifact mode and diagnostics for unsupported caller exports. -- **LSP / Docs tooling**: tooling should surface caller-visible exports, generated Rust signatures, compatibility metadata, and unsupported-boundary diagnostics. +- **LSP / Docs tooling**: tooling should surface caller-visible exports, Rust-facing signatures, compatibility metadata, and unsupported-boundary diagnostics. - **Registry / Package metadata**: published packages should advertise whether they provide a Rust-hosted caller surface and which caller ABI version they require. ## Unresolved questions - What is the exact source syntax for marking caller-visible exports? -- Should caller adapters live in the same generated package as the implementation crate or in a sibling generated crate? +- Should caller adapters live in the same Cargo package as the implementation artifact or in a sibling package? - What is the first stable shape of the Rust support crate API? - Should synchronous wrappers around async Incan exports be generated by default, opt-in only, or disallowed? - How should nested domain results and boundary errors be represented ergonomically in Rust signatures? diff --git a/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md b/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md index c848b969d..eb8586cc5 100644 --- a/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md +++ b/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md @@ -303,7 +303,7 @@ Adapter outputs must remain projections of checked descriptors, not source truth ### Relationship to RFC 092 and RFC 097 -RFC 092 owns interactive runtime target manifests and host capability contracts. RFC 097 owns the Rust-hosted caller boundary. This RFC allows those emitted manifests, host capability facts, generated Rust-facing artifacts, and caller metadata to appear in the semantic package when available, especially for LSP, docs, CI, and registry inspection. +RFC 092 owns interactive runtime target manifests and host capability contracts. RFC 097 owns the Rust-hosted caller boundary. This RFC allows those emitted manifests, host capability facts, Rust-facing ABI/caller artifacts, and caller metadata to appear in the semantic package when available, especially for LSP, docs, CI, and registry inspection. ## Alternatives considered From dd627d74206fabde024db9ab8fc35193acf9fa85 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 11:51:54 +0200 Subject: [PATCH 24/58] chore - add secret values RFC (#661) --- .../docs-site/docs/RFCs/103_secret_values.md | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 workspaces/docs-site/docs/RFCs/103_secret_values.md diff --git a/workspaces/docs-site/docs/RFCs/103_secret_values.md b/workspaces/docs-site/docs/RFCs/103_secret_values.md new file mode 100644 index 000000000..8bc5b9a60 --- /dev/null +++ b/workspaces/docs-site/docs/RFCs/103_secret_values.md @@ -0,0 +1,319 @@ +# RFC 103: `std.secrets` — Secret strings, secret bytes, and redaction-safe values + +- **Status:** Draft +- **Created:** 2026-05-24 +- **Author(s):** Danny Meijer (@dannymeijer) +- **Related:** + - RFC 017 (validated newtypes with implicit coercion) + - RFC 033 (`ctx` typed configuration context) + - RFC 066 (`std.http` HTTP client surface) + - RFC 072 (`std.logging` structured logging) + - RFC 078 (tool execution and typed workflow actions) + - RFC 089 (`std.environ` runtime environment access) + - RFC 090 (typed CLI framework) + - RFC 093 (`std.telemetry` observability) + - RFC 102 (semantic layer inspection surface) +- **Issue:** https://github.com/dannys-code-corner/incan/issues/661 +- **RFC PR:** — +- **Written against:** v0.3 +- **Shipped in:** — + +## Summary + +This RFC proposes `std.secrets` as Incan's standard library home for secret value wrappers, beginning with `SecretStr` and `SecretBytes`. Secret values are ordinary typed values that can flow through config, CLI, environment, HTTP, logging, telemetry, workflow actions, and generated reports without revealing their plaintext through unauthorized display, debug, structured logs, diagnostics, default serialization, or inspection surfaces. The goal is not to pretend secrets become impossible to copy or exfiltrate inside a compromised process; the goal is to make plaintext exposure deny-by-default, keep raw access scoped and intentional, and allow stronger protected storage such as encrypted idle memory where the backend can provide it. + +## Core model + +1. **Secrets are values, not logging conventions:** secrecy must travel with the value's type so redaction is not rebuilt separately by every caller. +2. **Plaintext exposure is deny-by-default:** Incan-owned display, debug output, logs, telemetry attributes, diagnostics, semantic inspection, reports, and default serialization must not reveal secret contents. +3. **Reveal is scoped and intentional:** APIs that need raw bytes or strings should consume `SecretStr` or `SecretBytes` directly, or require an intentionally named scoped reveal operation that tooling can recognize. +4. **Protected idle storage is preferred:** implementations should keep secret contents encrypted or otherwise protected while idle when a backend can do so meaningfully, and decrypt only inside a scoped reveal operation. +5. **Memory guarantees are honest:** protected idle storage and zeroization reduce exposure, but the public contract must not promise that every intermediate copy made by encoders, transport backends, operating systems, foreign APIs, crash handlers, or the process itself is erased. +6. **Specific types come first:** `SecretStr` and `SecretBytes` are the initial stable surface. A generic `Secret[T]` may come later if it does not weaken the concrete-string and concrete-bytes contracts. +7. **Tooling preserves sensitivity metadata:** CLI, LSP, semantic inspection, workflow action output, generated docs, and reports should know that a value exists and what type it has without seeing the raw payload. + +## Motivation + +Python ecosystems often represent secrets with wrapper classes, Pydantic field flags, logging filters, and framework-specific conventions. Those mechanisms help, but they remain easy to bypass because Python string interpolation, `repr`, dictionaries, serializers, exception traces, and third-party clients can all treat the wrapped value as just another object unless every boundary cooperates perfectly. + +Incan has a better opportunity because its stdlib, typechecker, generated Rust, structured logging, HTTP surface, CLI framework, environment access, action metadata, and semantic inspection model can agree on one value-level contract. A `SecretStr` used as a CLI option, loaded from an environment variable, passed to an HTTP authorization helper, logged as a structured field, or surfaced in an action report should remain recognizably present but redacted all the way through those boundaries. The core promise should be stronger than "nice `repr`": plaintext must not leave a secret wrapper through an Incan-owned surface unless the code has made an explicit reveal decision or passed the value to a trusted API that owns a scoped reveal internally. + +This RFC also closes a design gap left deliberately open by RFC 017. Validated newtypes can model domain-specific string and byte constraints, but secret handling is more than a validation constraint: it changes display, debug, logging, diagnostic serialization, wire-boundary APIs, equality, cloning, and drop behavior expectations. + +## Goals + +- Add a `std.secrets` module with `SecretStr` and `SecretBytes`. +- Make redaction a property of the value type rather than a per-logger or per-HTTP-client convention. +- Prevent plaintext secret emission through Incan-owned display, debug, diagnostic, logging, telemetry, semantic inspection, generated-report, and default serialization paths. +- Require safe default behavior for display, debug, structured logs, telemetry, diagnostics, semantic inspection, and generated reports. +- Provide intentionally named, tooling-visible APIs for scoped exposure of raw secret material at trusted boundaries. +- Prefer encrypted or otherwise protected idle memory for secret storage where the target backend can provide it meaningfully. +- Let stdlib consumers such as `std.http`, `std.environ`, typed CLI surfaces, `ctx`, workflow actions, logging, and telemetry accept or preserve secret values without converting them to plain `str` or `bytes`. +- Define a conservative serialization contract that prevents accidental JSON, TOML, YAML, CLI, or report emission of raw secret contents. +- Define honest memory-handling expectations, including scoped plaintext lifetimes and best-effort zeroization for plaintext buffers where the backend can support it. +- Leave room for future secret providers, vault integrations, redaction policies, and generic secret wrappers without blocking the concrete `SecretStr` and `SecretBytes` surface. + +## Non-Goals + +- This RFC does not define a password manager, vault, keyring, or secrets backend. +- This RFC does not define encryption at rest for source files, manifests, lockfiles, logs, reports, or generated artifacts. +- This RFC does not provide full information-flow control, taint tracking, or a data-loss-prevention system. +- This RFC does not guarantee that all process memory, operating-system buffers, network buffers, allocator copies, panic payloads, crash dumps, foreign library copies, or compiler temporaries are erased. +- This RFC does not claim that encrypted idle storage protects against arbitrary code execution inside the same process; any implementation must still hold or derive decryption material somewhere. +- This RFC does not make secrets safe to expose to untrusted code. +- This RFC does not define random secret generation; a future `std.random` or expanded `std.secrets` surface may do that separately. +- This RFC does not define identity protocols such as SAML, OAuth, OIDC, JWT validation, service-account exchange, or single sign-on workflows. +- This RFC does not standardize every sensitive-data class such as PII, payment data, access tokens, API keys, passwords, and private keys as distinct semantic categories in the initial surface. +- This RFC does not replace access control, capability checks, sandboxing, policy approval, or runtime permission boundaries. + +## Guide-level explanation + +Users should be able to load a secret value and pass it through normal code without turning it into a plain string just to keep working. + +```incan +from std.environ import env +from std.secrets import SecretStr + +token: SecretStr = env.secret_str("SERVICE_TOKEN")? +println(token) +``` + +The printed value is redacted. The exact placeholder is a design detail, but it must not include the token. + +HTTP clients and other stdlib APIs should accept secret values directly: + +```incan +from std.environ import env +from std.http import Client, bearer +from std.secrets import SecretStr + +token: SecretStr = env.secret_str("SERVICE_TOKEN")? +client = Client(default_headers={"Authorization": bearer(token)}) +response = client.get("https://api.example.com/items")? +``` + +The caller does not reveal the token manually. The HTTP boundary may perform a scoped internal reveal when constructing the wire request, but diagnostics, debug output, retries, telemetry, and action reports must preserve sensitivity. + +When a raw value is genuinely needed, the operation should read as intentional and scoped: + +```incan +from std.secrets import SecretBytes + +def sign_with_key(raw_key: bytes) -> Signature: + return hmac.sign(raw_key, payload) + + +key: SecretBytes = SecretBytes.from_hex(env.secret_str("SIGNING_KEY_HEX")?)? +signature = key.with_exposed_bytes(sign_with_key) +``` + +The exact reveal method names remain open in this Draft. The important property is that code review, search, LSP, and policy tooling can recognize raw-secret exposure sites, and that the preferred shape does not hand an ordinary string or byte buffer back to the caller for uncontrolled storage. + +Secret values should also compose with typed configuration and CLIs: + +```incan +from std.secrets import SecretStr + +ctx Deploy: + api_token: SecretStr = env("API_TOKEN") + endpoint: str = "https://api.example.com" +``` + +An inspection view can show that `api_token` exists, is required, and has type `SecretStr`, without showing the token itself. + +## Reference-level explanation + +### Module surface + +`std.secrets` must expose `SecretStr` and `SecretBytes`. + +`SecretStr` must represent owned UTF-8 secret text. `SecretBytes` must represent owned binary secret material. + +The module may expose helper types such as redaction placeholders, reveal guards, redacted serialization adapters, or sensitivity metadata, but `SecretStr` and `SecretBytes` are the required initial surface. + +### Construction + +`SecretStr` must be constructible from a `str` through an explicit constructor or conversion path. `SecretBytes` must be constructible from `bytes` through an explicit constructor or conversion path. + +Construction APIs should make plain-to-secret conversion visible in source. Implicit conversion from `str` to `SecretStr` or from `bytes` to `SecretBytes` should be avoided unless a surrounding API already declares that an input position is secret, such as a typed CLI option, an environment accessor, or a `ctx` field. + +`SecretStr` should support conversion to `SecretBytes` using an explicit encoding operation. `SecretBytes` should support UTF-8 decoding into `SecretStr` through a fallible operation. + +`std.environ` should provide secret-returning helpers, such as a `secret_str` shape, so callers do not need to load an environment variable as plain text and then wrap it manually. + +### Display and debug behavior + +`SecretStr` and `SecretBytes` must redact their contents in display, debug, assertion failure, panic, diagnostic, and structured-inspection contexts owned by the Incan standard library and toolchain. + +The redacted representation must communicate that the value is secret and present. It must not include the secret contents, prefix, suffix, length, checksum, entropy estimate, or other derived value unless a later RFC defines an explicit policy for such metadata. + +String interpolation and formatting protocols must use the redacted representation by default. Formatting a secret must not implicitly call the reveal operation. + +### Plaintext leakage boundary + +The normative security boundary for this RFC is Incan-owned plaintext emission. `SecretStr` and `SecretBytes` must not reveal raw contents through Incan-owned display, debug, panic formatting, assertion messages, diagnostics, structured logs, telemetry attributes, semantic inspection, generated reports, CLI help, CLI echo, default serialization, or action metadata. + +This boundary also applies to nested structures. A model, list, dict, result, error, request, response, action input, or telemetry event containing a secret value must preserve redaction when formatted or serialized through Incan-owned mechanisms. + +Trusted stdlib APIs may reveal plaintext internally only for the duration of the operation that requires it, such as computing an HMAC or sending an HTTP authorization header. That internal reveal must not become observable through error values, debug payloads, telemetry attributes, retry reports, or generated artifacts. + +### Reveal operations + +`SecretStr` must provide an intentionally named operation for exposing the raw `str` value. `SecretBytes` must provide an intentionally named operation for exposing the raw bytes value. + +Reveal operations must be easy for tooling to identify. They should use names that communicate risk, such as `expose_secret`, `expose_secret_str`, or `expose_secret_bytes`, rather than neutral names like `value`, `get`, or `as_str`. + +The preferred reveal shape is scoped: a callback, guard, or equivalent API that makes plaintext available only for a bounded lexical or dynamic lifetime. Owned plaintext copies should either be unavailable by default or exposed through a more explicit and noisier escape hatch than scoped reveal. + +The reveal operation may return a borrowed view, a scoped guard, a backend-specific safe-access wrapper, or an owned copy only when the API name makes the copying behavior explicit. The accepted design must document the lifetime, copying behavior, and zeroization behavior of every reveal path. + +APIs that genuinely need raw material should prefer accepting `SecretStr` or `SecretBytes` directly instead of forcing user code to reveal the secret first. + +### Serialization + +Default data serialization of `SecretStr` and `SecretBytes` must not emit raw secret contents. + +For diagnostic serialization, generated reports, semantic inspection, logs, telemetry, and CLI output, the value must serialize as a redacted secret marker or an equivalent structured redaction object. + +For data formats that are intended to leave the process as user data, such as JSON request bodies, TOML files, YAML files, or generated artifacts, default serialization should fail unless the caller chooses an explicit redacted adapter or an explicit reveal operation. This avoids accidentally sending placeholder text where a real secret was expected, and avoids accidentally persisting the raw value. + +### Equality, ordering, and hashing + +`SecretStr` and `SecretBytes` should not expose ordering operations by default. + +Equality is an open design question. If equality is exposed, it should avoid timing behavior that is obviously inappropriate for token, password, or key comparison, and the docs must state whether the comparison is constant-time. If the implementation cannot provide a meaningful constant-time guarantee for a given storage representation, it should prefer an explicit comparison helper over ordinary equality. + +Hashing secret values should be avoided by default because hash maps and debug tooling often make key material harder to reason about. If hash support is needed later, it should be introduced deliberately with documented semantics. + +### Cloning and copying + +`SecretStr` and `SecretBytes` must not be trivially copyable value types. + +Cloning may be supported when the language's ownership model requires it for ordinary value flow, but clone operations must preserve secrecy metadata and must not reveal raw contents. The docs must state that cloning creates another copy of the secret material. + +### Protected storage and memory handling + +Implementations should keep secret contents encrypted or otherwise protected while idle when the target backend can provide a meaningful protected-storage implementation. Plaintext should be produced only inside scoped reveal operations or trusted stdlib internals that need raw bytes or text for a bounded operation. + +Any protected-storage implementation must document its threat model. Encrypting a buffer while idle can reduce accidental plaintext retention and may help with some memory disclosure scenarios, but it does not protect against arbitrary code execution in the same process, a compromised runtime, a debugger with full process access, or backend APIs that must receive plaintext. + +Plaintext buffers created during reveal should be zeroized as soon as their scoped use ends when the backend can support that. `SecretBytes` should zeroize owned plaintext memory on drop when generated code can do so without weakening correctness. `SecretStr` may also zeroize owned storage when implemented over a mutable owned buffer, but the public contract must not imply that all UTF-8 string copies are erased. + +Both types must document that redaction is an exposure-control guarantee for standard display, debug, logging, telemetry, diagnostics, and serialization paths. Protected idle storage and zeroization strengthen that guarantee, but they are not full memory-forensics or same-process-compromise guarantees. + +The implementation should avoid unnecessary copies in stdlib APIs that consume or forward secret values, especially HTTP authorization helpers, cryptographic helpers, and secret-provider integrations. + +### Logging, telemetry, diagnostics, and inspection + +`std.logging`, `std.telemetry`, diagnostics, and semantic inspection must treat `SecretStr` and `SecretBytes` as sensitive fields by type. + +Structured outputs should preserve the fact that a field exists, its declared type, and relevant non-sensitive metadata such as source kind when appropriate. They must not include the raw value. + +Tooling should mark explicit reveal operations as searchable and inspectable sites. LSP hover, semantic inspection, and policy checks may use those sites to explain where secret material leaves the protected wrapper. + +### HTTP and wire-boundary APIs + +`std.http` authorization helpers, header builders, request diagnostics, retry reporting, and telemetry should preserve secret sensitivity. Header values constructed from `SecretStr` or `SecretBytes` must be redacted in debug-facing output even if the header name is not in a built-in sensitive-header list. + +`std.http` may expose raw secret material internally when sending a request. That internal exposure must not change the public `Request`, `Response`, `HttpError`, log, telemetry, or action-output redaction contract. + +### Typed actions, CLIs, and configuration + +Typed action inputs, CLI options, and `ctx` fields should be able to declare `SecretStr` and `SecretBytes` directly. + +Machine-readable action metadata should distinguish a required secret input from a plain string input. Action output must not include raw secret values unless a future policy system defines an explicit, user-approved reveal path. + +CLI help may show that an option expects a secret. It must not echo secret defaults or environment-derived values. + +### Higher-level identity protocols + +Identity and federation protocols such as SAML, OAuth, OIDC, JWT validation, service-account exchange, and single sign-on workflows should be built above `std.secrets`, not inside it. Those protocols have their own security models: XML or JSON token formats, signatures, certificates, issuer and audience validation, replay windows, metadata discovery, clock skew, session state, and provider-specific policy. + +`std.secrets` should provide the primitive secret value contract those packages consume. A future identity or platform library may store private keys, bearer tokens, client secrets, SAML assertions, or signed credentials in `SecretStr` or `SecretBytes`, and may use scoped reveal internally when validating or transmitting them. That does not make `std.secrets` responsible for the protocol semantics. + +## Design details + +### Syntax + +This RFC does not introduce new parser syntax. `SecretStr` and `SecretBytes` are stdlib types. + +### Semantics + +Secret values have ordinary type identity and can be passed, returned, stored in models, and used in containers according to the language's normal value rules. Their special behavior is attached to display, debug, formatting, serialization, logging, telemetry, diagnostics, inspection, equality, hashing, cloning, reveal, protected storage, and drop semantics. + +Implicit downcast from `SecretStr` to `str` and from `SecretBytes` to `bytes` must not be allowed. Raw exposure must require either an explicit scoped reveal operation or a trusted stdlib API that accepts a secret type directly and owns the scoped reveal internally. + +### Interaction with existing features + +- **RFC 017 (validated newtypes)**: secret values may use newtype-like machinery internally, but their display, debug, serialization, and memory expectations are a separate contract. +- **RFC 033 (`ctx`)**: typed configuration can declare secret fields and source them from environment or future secret providers without exposing raw values in inspection. +- **RFC 066 (`std.http`)**: HTTP auth helpers and headers should accept secret values and preserve redaction through request diagnostics, retries, telemetry, and workflow output. +- **RFC 072 (`std.logging`)**: structured logging should redact secret-typed fields by default. +- **RFC 078 (typed workflow actions)**: action inputs and outputs should preserve sensitivity metadata so reports can describe secret use without exposing values. +- **RFC 089 (`std.environ`)**: environment access should provide secret-returning helpers that avoid plain-string staging. +- **RFC 090 (typed CLI framework)**: CLI options can use `SecretStr` and `SecretBytes` as declared types. +- **RFC 093 (`std.telemetry`)**: telemetry attributes and events must redact secret-typed values. +- **RFC 102 (semantic layer inspection surface)**: semantic inspection should represent secret facts as redacted facts with stable type and source metadata. + +### Compatibility / migration + +This feature is additive. Existing code that stores tokens in plain strings remains valid, but docs and examples should prefer `SecretStr` and `SecretBytes` at configuration, CLI, environment, HTTP, and action boundaries once the types exist. + +Migration helpers may wrap existing `str` or `bytes` values explicitly. Such helpers should not hide the fact that code still created a plain value before wrapping it. + +## Alternatives considered + +- **Plain `newtype str` and `newtype bytes` only** + - Rejected because newtypes alone do not define formatting, debug, serialization, logging, telemetry, equality, cloning, and memory behavior. +- **Logging-only redaction** + - Rejected because secrets leak through more than logs: debug strings, exception messages, assertions, generated reports, telemetry, HTTP diagnostics, CLI echo, and semantic inspection all matter. +- **HTTP-only secret headers** + - Rejected because the same token often starts in environment or CLI config, flows through `ctx`, enters an HTTP client, appears in telemetry, and may be referenced by typed actions. +- **One generic `Secret[T]` as the first surface** + - Rejected for the initial version because strings and bytes have distinct encoding, display, comparison, and memory concerns. A generic wrapper may still be useful later. +- **Always serialize redacted placeholders** + - Rejected for data serialization because silently writing `` into JSON payloads, config files, or generated artifacts can create corrupt data and hide bugs. +- **Unscoped raw getters** + - Rejected because a method that returns an ordinary `str` or `bytes` as the primary reveal path makes it too easy to store, log, serialize, or return plaintext accidentally. +- **Always require manual reveal before wire use** + - Rejected because it pushes raw exposure into user code and makes the safe path noisier than the risky path. + +## Drawbacks + +- Secret wrappers add friction when code genuinely needs raw strings or bytes. +- Redaction can create a false sense of security if users interpret it as encryption, access control, or memory-forensics protection. +- Encrypted idle storage has key-management and performance costs, and it cannot protect against every same-process threat. +- Equality, hashing, and serialization need conservative choices that may surprise users expecting string-like behavior. +- Stdlib modules and tooling must consistently honor the secret contract or the abstraction becomes unreliable. +- The exact reveal API needs careful design because it becomes the standard searchable marker for sensitive exposure. + +## Implementation architecture + +*(Non-normative.)* The Rust-backed implementation should use owned storage with redacting display and debug implementations. Where practical, secret payloads should be stored encrypted while idle with process-local key material and decrypted only inside scoped reveal guards. Plaintext buffers created by reveal guards should be zeroized when the guard closes. `SecretBytes` should use a zeroizing buffer where available. `SecretStr` may store UTF-8 in a protected byte buffer with fallible UTF-8 views, or use another representation that preserves the public contract. Stdlib consumers should pass secret wrappers through typed APIs and reveal internally only at the final trusted boundary. + +## Layers affected + +- **Stdlib / Runtime (`incan_stdlib`)**: must provide `std.secrets`, `SecretStr`, `SecretBytes`, redaction behavior, construction helpers, scoped reveal operations, protected-storage behavior where supported, and integration hooks for stdlib consumers. +- **Typechecker / Symbol resolution**: must preserve the distinct types and reject implicit conversion from secret wrappers to plain `str` or `bytes`. +- **Emission**: generated Rust must preserve redacting display/debug behavior and best-effort zeroization where promised. +- **Formatter**: no syntax changes are required, but examples and generated code should preserve readable secret-type annotations. +- **LSP / Tooling**: hover, completion, diagnostics, semantic inspection, action metadata, generated docs, and policy checks should preserve sensitivity metadata and make reveal operations discoverable. +- **Docs / Examples**: environment, CLI, HTTP, logging, telemetry, and workflow examples should demonstrate secret values instead of plain string tokens. + +## Unresolved questions + +- What are the exact reveal method names for `SecretStr` and `SecretBytes`? +- Should reveal operations return borrowed views, owned copies, scoped guards, or multiple variants? +- Should scoped reveal be the only stable v1 reveal surface, with owned plaintext extraction left for a later explicit escape hatch? +- Should encrypted idle storage be mandatory for all v1 targets, or a documented target capability with redaction and zeroization as the portable floor? +- How should process-local encryption keys be generated, stored, rotated, and destroyed? +- Should ordinary equality be available, or should secret comparison require explicit constant-time helper functions? +- Should `SecretStr` attempt to provide the same zeroization behavior as `SecretBytes`, or should the docs make `SecretStr` strictly a redaction-first wrapper? +- What exact redaction placeholder should display, debug, and diagnostic serialization use? +- Should default data serialization of secrets fail everywhere, or should some stdlib-owned formats serialize structured redaction objects by default? +- Should `std.secrets` eventually expose a generic `Secret[T]`, and if so, what protocol must `T` satisfy? +- Should secret provenance metadata distinguish environment variables, CLI input, config files, secret providers, and generated values in the initial surface? +- How should reveal sites interact with future policy approval, sandboxing, and capability checks? +- Should secret values participate in model field metadata automatically, or should fields still require an explicit `secret=true` marker for generated schema and docs? + + From 027bd5a5053a64bb636fdfdd177a3b3b43e5775c Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 11:52:27 +0200 Subject: [PATCH 25/58] chore - add ambient runtime capabilities RFC (#618) --- ...bient_runtime_capabilities_and_receipts.md | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md diff --git a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md new file mode 100644 index 000000000..a9f2aa3dc --- /dev/null +++ b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md @@ -0,0 +1,444 @@ +# RFC 104: Ambient Runtime Capabilities and Receipts + +- **Status:** Draft +- **Created:** 2026-05-24 +- **Author(s):** Danny Meijer (@dannymeijer) +- **Related:** + - RFC 033 (`ctx` typed configuration context) + - RFC 055 (`std.fs` path-centric filesystem APIs) + - RFC 063 (`std.process` process spawning and command execution) + - RFC 066 (`std.http` HTTP client surface) + - RFC 075 (starter profiles and capability packs) + - RFC 076 (project mutation policy and recovery) + - RFC 078 (tool execution and typed workflow actions) + - RFC 089 (`std.environ` runtime environment access) + - RFC 090 (typed CLI framework) + - RFC 092 (interactive runtime stdlib contracts) + - RFC 093 (`std.telemetry`) + - RFC 094 (context managers) + - RFC 095 (`span` vocabulary blocks) + - RFC 102 (semantic layer inspection surface) + - RFC 103 (secret values and redaction-safe values) +- **Issue:** — +- **RFC PR:** — +- **Written against:** v0.3 +- **Shipped in:** — + +## Summary + +This RFC defines ambient runtime capabilities and receipts for Incan. Importing a module remains Python-readable and low ceremony, but using authority-bearing operations such as filesystem, environment, process, HTTP, clock, random, model, tool, or package-defined domain operations produces structured receipts and may be denied by a governed runtime. The stdlib is the first capability publisher, not the only one: library authors can define domain capabilities, attach receipt schemas, and participate in the same audit and policy system without reimplementing tracing or reaching for stdlib internals. The goal is ambient observation with explicit authority. + +## Core model + +Read this RFC as ten foundations: + +1. **Import is not authority:** source code may import `std.fs`, `std.process`, `std.environ`, `std.http`, or a capability-aware package without automatically receiving permission to perform those operations. +2. **Observation is ambient:** ordinary stdlib and library calls can emit structured receipts without requiring users to annotate every function with effect types. +3. **Authority is granted at boundaries:** runs, actions, tests, packages, and hosts grant capabilities; library code may request or declare capabilities, but cannot grant itself authority. +4. **Stdlib capabilities are built in:** host authority such as filesystem, environment, process, network, clock, random, model invocation, and tool invocation has reserved capability identities. +5. **Library capabilities are first-class:** packages may publish domain capabilities such as `example.policy.evaluate` or `example.index.query` that describe domain authority and receipt semantics. +6. **Receipts are not logs:** receipts are structured runtime facts with stable kinds, source spans where available, redaction state, status, and replay information; terminal logs are only one possible view. +7. **Strict enforcement is optional:** ordinary runs should remain simple, while governed runs can deny operations not covered by granted capabilities. +8. **Redaction is mandatory:** receipts must preserve sensitivity metadata and must not expose raw secret or policy-sensitive values by default. +9. **Replay claims must be honest:** the runtime should describe what can be replayed exactly, what requires fixtures, and what cannot be replayed. +10. **Policy consumes receipts:** policy systems, CI, editors, docs tooling, and agents consume the same capability declarations and receipt facts; they do not infer authority from prose or hidden conventions. + +## Motivation + +Python-shaped source is a major Incan strength, but Python's module model also hides authority. If Python code can import `os`, it can generally attempt to read environment variables, inspect and mutate files, spawn processes, or discover host state. External sandboxing can restrict that, but the source/module surface does not make authority visible or explainable. + +Incan should preserve the ergonomic part and reject the hidden-authority part. A user should be able to write ordinary readable code, import the modules they need, and run the program normally. When the same code is run in a governed context, the runtime should be able to say that a filesystem read, environment read, process spawn, HTTP request, model invocation, or package-defined domain operation was allowed, denied, redacted, or replay-limited. + +This matters most for real tools, automation, generated artifacts, policy-bound workflows, and agent-assisted maintenance. A failed or suspicious run should produce receipts that answer what authority was requested, what authority was granted, what actually happened, which values were redacted, which artifacts were touched, and what can be replayed. Without a shared capability and receipt model, every stdlib module and library will invent its own logs, policy hooks, and audit JSON. + +The key design constraint is usability. This RFC must not turn ordinary Incan into an algebraic-effect language where every helper function has capability algebra in its type signature. The default user experience should be: write normal Incan; capability-aware boundaries produce structured receipts; governed entrypoints can restrict and audit those receipts. + +## Goals + +- Split module availability from runtime authority. +- Define reserved host capability identities for common authority-bearing operations. +- Allow library authors to define domain capabilities and receipt schemas. +- Define ambient receipt emission for stdlib and library boundaries. +- Define governed runtime behavior when an operation requires a capability that was not granted. +- Define machine-readable run reports that include requested capabilities, granted capabilities, denied operations, emitted receipts, redaction state, and replay limits. +- Define how domain capabilities may imply or request host capabilities without granting themselves authority. +- Make receipts consumable by RFC 102 semantic inspection, RFC 078 typed actions, RFC 093 telemetry, RFC 076 policy, CI, LSP, docs tooling, and agents. +- Keep ordinary source readable and low ceremony. + +## Non-Goals + +- This RFC does not introduce a full algebraic effect system. +- This RFC does not require every function type to include a capability parameter or effect row. +- This RFC does not make imports fail merely because the current run has not granted a capability. +- This RFC does not define a complete operating-system sandbox. +- This RFC does not guarantee perfect deterministic replay for external systems. +- This RFC does not replace `std.telemetry`, `std.logging`, diagnostics, or semantic inspection. +- This RFC does not require every package to publish capability metadata. +- This RFC does not allow libraries to grant themselves host authority. +- This RFC does not define the final CLI flag spelling for governed runs or reports. +- This RFC does not define a secret-value type; it only requires receipts to preserve sensitivity and redaction metadata from the owning subsystem. + +## Guide-level explanation + +Ordinary code should stay ordinary: + +```incan +from std.environ import env +from std.http import get + +def fetch_status() -> int: + url = env.get("STATUS_URL")? + response = get(url)? + return response.status.code +``` + +A normal run may behave just like a normal program: + +```text +incan run status.incn +``` + +An observed run asks the runtime to emit a machine-readable report: + +```text +incan run status.incn --report json +``` + +The report can show the authority-bearing operations that happened: + +```json +{ + "entrypoint": "status.fetch_status", + "granted_capabilities": [], + "mode": "observe", + "receipts": [ + { + "capability": "host.env.read", + "operation": "env.get", + "status": "observed", + "attributes": {"key": "STATUS_URL"}, + "redacted": false + }, + { + "capability": "host.http.request", + "operation": "http.request", + "status": "observed", + "attributes": {"method": "GET", "url_policy": "external", "status_code": 200}, + "redacted": false + } + ] +} +``` + +A governed run grants only selected authority: + +```text +incan run status.incn --allow host.env.read,host.http.request --report json +``` + +If the program later tries to spawn a process, the runtime should fail with a useful diagnostic: + +```text +status.incn:8 used std.process.Command.run(...) +This requires capability: host.process.spawn + +Granted capabilities: + host.env.read + host.http.request +``` + +Library authors should be able to participate without depending on stdlib-private hooks. A package can define a domain capability: + +```incan +capability example.policy.evaluate: + description = "Evaluate an input against a policy" + emits = "policy.evaluation" +``` + +The exact declaration syntax is unresolved. The important contract is that packages can publish stable capability identities, descriptions, receipt schemas, and relationships to host capabilities. + +Library code can then emit a receipt through a low-ceremony boundary: + +```incan +from std.runtime import receipts + +def evaluate(policy: Policy, input: Input) -> Decision: + with receipts.event("example.policy.evaluate", subject=policy.id): + return policy.evaluate(input) +``` + +For common entrypoints, typed actions can declare the capabilities they require: + +```incan +@action(caps=["example.policy.evaluate", "host.model.invoke"]) +def review(input: ReviewInput) -> ReviewReport: + ... +``` + +Granting a domain capability does not automatically let a package bypass host policy. If `example.policy.evaluate` needs `host.fs.read` to load a policy file, that relationship must be visible in metadata and accepted by the runtime or host policy. Libraries can name and explain authority; the runtime grants authority. + +## Reference-level explanation + +### Capability identities + +A capability identity must be a stable string. The exact naming grammar is unresolved, but this RFC reserves the `host.*` namespace for host authority capabilities owned by the Incan toolchain and runtime. + +Initial host capability families should include: + +- `host.env.read` +- `host.fs.read` +- `host.fs.write` +- `host.process.spawn` +- `host.http.request` +- `host.clock.read` +- `host.random` +- `host.model.invoke` +- `host.tool.invoke` + +Implementations may define narrower capabilities such as scoped filesystem paths, hostnames, methods, or model families, but the broad families must remain understandable in diagnostics and reports. + +Package-defined capabilities must be namespaced so two packages cannot accidentally define the same authority name. Package-defined capabilities may describe domain operations, typed actions, generated artifacts, policy checks, workflow steps, or library-specific effects. + +### Import, request, grant, and use + +Importing a module must not grant authority. Importing `std.process` is allowed even in a run that has not granted `host.process.spawn`. Authority is checked when an authority-bearing operation is invoked. + +A package, action, function, descriptor, or runtime operation may request capabilities. A run, host, action invoker, test harness, package manager, CI environment, or policy system may grant capabilities. Only the runtime or host authority boundary may decide whether a request is granted. + +When an operation requiring a capability is invoked in governed mode and the capability is not granted, the operation must fail before performing the authority-bearing behavior. The diagnostic must identify the required capability and should include the source span, import/module/function path, and a suggested grant spelling when available. + +### Runtime modes + +The runtime should support at least these conceptual modes: + +- `permissive`: operations run normally and receipts may be disabled. +- `observe`: operations run normally and receipts are emitted. +- `governed`: operations require granted capabilities and receipts are emitted. + +The exact CLI spelling is not normative. A natural user-facing shape is: + +```text +incan run app.incn --report json +incan run app.incn --allow host.env.read,host.http.request --report json +``` + +The default mode for ordinary local development is unresolved. The default must not surprise users by silently exporting data or sending reports to remote services. + +### Capability declarations + +A capability declaration should include: + +- stable identity; +- human-readable description; +- owning package or toolchain component; +- capability kind, such as host, library, action, artifact, or policy; +- optional implied or requested capabilities; +- optional scope schema, such as path, hostname, method, model, artifact kind, or action id; +- receipt event kinds emitted by the capability; +- redaction and sensitivity rules for receipt attributes; +- docs and diagnostic labels. + +Capability declarations may live in source, package metadata, manifest metadata, generated descriptors, or capability packs. Wherever they live, RFC 102 semantic inspection must be able to expose them as project facts. + +Package-defined capabilities must not grant host authority by implication alone. If a domain capability requests or implies `host.fs.read`, the runtime must resolve that relationship through host policy before allowing filesystem reads. + +### Receipts + +A receipt is a structured runtime fact emitted by a capability-aware operation. A receipt must include: + +- event id or sequence id; +- capability identity; +- operation kind; +- status, such as observed, allowed, denied, failed, redacted, or skipped; +- source location or semantic identity when available; +- package/module/function identity when available; +- parent span or context id when available; +- redacted attributes; +- sensitivity metadata; +- replay classification. + +A receipt should include operation-specific attributes such as environment variable key, filesystem path policy, HTTP method, URL policy, process command policy, model id policy, artifact id, action id, or policy id. Sensitive values must be redacted by default. + +Receipts must be machine-readable. Human output may summarize receipts, but human output must not be the integration contract. + +### Run reports + +A run report is a machine-readable summary of a run, action, test, or governed entrypoint. A report must include: + +- toolchain version; +- run mode; +- entrypoint identity; +- requested capabilities when available; +- granted capabilities; +- denied capability requests; +- emitted receipts; +- diagnostics; +- redaction summary; +- replay manifest or replay limitations. + +Reports may include artifact references, span trees, telemetry correlation ids, package versions, lockfile identity, source snapshot identity, and semantic package references. + +Reports must not include raw secret values or sensitive payloads unless a separate, explicit reveal policy approves that exposure. + +### Replay classification + +Each receipt and run report should classify replayability. Initial replay classifications should include: + +- `deterministic`: the operation can be replayed from recorded local inputs. +- `fixture-required`: replay requires recorded fixtures or test doubles. +- `external`: replay depends on external systems and cannot be exact without a recording. +- `unavailable`: replay is not supported for this operation. +- `redacted`: replay data exists but is intentionally hidden or incomplete. + +This RFC does not require the runtime to implement full replay. It requires the runtime to avoid dishonest replay claims. + +### Budgets + +Capability grants may include budgets. Budgets are optional constraints over granted authority, such as maximum request count, maximum bytes written, allowed path roots, allowed hosts, allowed process names, timeout limits, model-token limits, or artifact count. + +If a budget is exhausted in governed mode, the runtime must deny the operation before performing it where practical and must emit a denial receipt. If the operation cannot be prevented before partial work occurs, the receipt must describe the partial state honestly. + +### Library participation + +Library authors may define capabilities and receipt schemas. Libraries should not need to import stdlib-private modules or manually construct the full run report. + +The stdlib should provide a small public runtime receipt surface for library authors. The exact spelling is unresolved, but it should support scoped events, one-shot events, status updates, redacted attributes, and parent span/context attachment. + +Library-defined receipts must flow into the same run report as stdlib receipts. A package manager, LSP, CI job, or agent must not need separate integration logic for each library's audit output. + +### Relationship to telemetry + +Receipts and telemetry are related but distinct. Receipts are capability and authority facts. Telemetry is observability data. A receipt may be exported as a telemetry event or span attribute when telemetry is configured, but receipt generation must not require telemetry export. + +Receipts must remain available to local reports and policy systems even when `std.telemetry` is not configured. + +### Relationship to semantic inspection + +RFC 102 semantic inspection should expose declared capabilities, receipt schemas, action capability requirements, policy relationships, and report artifacts. Semantic inspection should not need to execute a program to discover static capability declarations. + +Runtime receipts may reference semantic identities from RFC 102 so tools can connect a run event back to source declarations, actions, generated artifacts, package metadata, and policy decisions. + +### Relationship to stdlib modules + +Stdlib modules that cross host authority boundaries must emit receipts when reporting is enabled and must enforce grants in governed mode. + +At minimum: + +- `std.environ` reads require `host.env.read`. +- `std.fs` reads require `host.fs.read`. +- `std.fs` writes require `host.fs.write`. +- `std.process` spawning requires `host.process.spawn`. +- `std.http` requests require `host.http.request`. +- clock APIs that read current time require `host.clock.read`. +- random APIs require `host.random`. +- model or tool invocation APIs require `host.model.invoke` or `host.tool.invoke`. + +Pure computation, parsing, formatting, local model construction, and in-memory transformations should not require host capabilities. + +## Design details + +### Syntax + +This RFC intentionally does not require new syntax. Capability declarations may eventually use source syntax, declaration metadata, package metadata, or manifest descriptors. The required contract is capability identity, declaration, grant, enforcement, receipt emission, and inspection. + +Illustrative source syntax such as `capability example.policy.evaluate:` is non-normative. + +### Semantics + +Capability checks occur at authority-bearing operation boundaries. In ordinary source, a helper function that calls `std.http.get` does not need to declare an effect type merely because it may perform HTTP. If the program runs in governed mode without `host.http.request`, the operation fails at the boundary with a capability diagnostic. + +Static capability declarations are still useful for actions, packages, generated artifacts, docs, and policy review. They should describe expected authority before a run happens. Runtime receipts describe actual authority use during a run. + +Static declarations and runtime receipts should be compared where possible. If a run uses a capability not declared by its action or package metadata, the report should mark that mismatch. + +### Interaction with existing features + +- **RFC 033 (`ctx`)**: configuration fields may require environment or secret-provider capabilities when resolved at runtime. +- **RFC 055 (`std.fs`)**: file APIs become standard publishers of filesystem receipts and governed checks. +- **RFC 063 (`std.process`)**: process spawning becomes a governed host capability with structured command-policy receipts. +- **RFC 066 (`std.http`)**: HTTP requests become governed host capabilities with redacted request/response receipts and replay classifications. +- **RFC 075 (capability packs)**: project capability packs may declare expected package and action capabilities, but they must not grant host authority without runtime policy. +- **RFC 076 (policy)**: policy consumes capability declarations and receipts, and may approve, deny, or require review for grants and mutations. +- **RFC 078 (typed actions)**: actions may declare required capabilities and emit action-scoped reports. +- **RFC 089 (`std.environ`)**: environment access becomes a governed and receipted host boundary. +- **RFC 090 (typed CLI framework)**: CLI commands may declare capability requirements and expose helpful denial diagnostics. +- **RFC 092 (interactive runtime contracts)**: target manifests may describe host capabilities supported by a runtime target. +- **RFC 093 (`std.telemetry`)**: telemetry may export receipts, but receipts remain local authority facts when telemetry is disabled. +- **RFC 094 and RFC 095**: context managers and span vocabulary blocks provide convenient scopes for receipt correlation, but receipts do not require span syntax. +- **RFC 102 (semantic inspection)**: capability declarations, receipt schemas, run reports, and replay manifests become inspectable semantic artifacts. +- **RFC 103 (secret values)**: receipt redaction should preserve secret-value sensitivity metadata without requiring receipts to expose raw secret payloads. + +### Compatibility + +This RFC is additive. Existing programs can continue to run in permissive mode. Governed mode may reveal hidden authority assumptions in existing programs, but those failures are the point of governed execution and must be diagnosable. + +Stdlib APIs that already perform authority-bearing operations should be updated to emit receipts and enforce grants in governed mode. Libraries may opt in incrementally by publishing capability descriptors and using the public receipt surface. + +## Alternatives considered + +### Full algebraic effects + +Rejected for now. Algebraic effects or effect rows may become useful later, but they would fight Incan's Python-shaped ergonomics if introduced as the first user-facing authority model. + +### Stdlib-only auditing + +Rejected because it would prevent library authors from defining domain capabilities and would force every serious package to invent its own audit layer. + +### External sandbox only + +Rejected because external sandboxing can restrict behavior but does not provide source-level capability identities, semantic inspection, domain receipts, or useful diagnostics. + +### Logging-only receipts + +Rejected because logs are human-oriented and often unstructured. Receipts must be machine-readable authority facts with stable semantics, redaction, and replay information. + +### Import-time capability checks + +Rejected because it makes code harder to reuse and breaks ordinary Python-shaped authoring. Authority should be checked when authority-bearing operations are invoked, not when modules are imported. + +## Drawbacks + +This RFC adds a cross-cutting runtime contract. Stdlib modules, package metadata, typed actions, policy, LSP, reports, and agents must agree on capability identities and receipt shapes. + +Capability names can sprawl if packages publish overly fine-grained or inconsistent capability vocabularies. Tooling will need naming guidance, validation, and docs support. + +Receipts can create overhead and sensitive metadata risk. Implementations must make reporting configurable, preserve redaction, and avoid accidental remote export. + +Governed mode can frustrate users if diagnostics are vague or if common operations require too many grants. The initial capability set should stay coarse and understandable until real usage proves finer scope is needed. + +## Implementation architecture + +This section is non-normative. + +A practical architecture is to route capability-aware operations through a runtime authority context. That context can hold run mode, grants, budgets, redaction policy, receipt sink, telemetry bridge, and source/semantic identity mapping. + +Stdlib modules should call a small shared runtime authority API before crossing host boundaries and emit receipts through the same API after success, failure, denial, or partial completion. Library authors should get a public receipt API that creates domain receipts without exposing private stdlib internals. + +Generated build artifacts and run reports should be ordinary artifacts that RFC 102 can inspect. LSP, CI, docs tooling, and agents should consume the report schema rather than parsing logs. + +## Layers affected + +- **Stdlib / Runtime (`incan_stdlib`)**: host-boundary modules need capability checks, receipt emission, redaction handling, and report integration. +- **Tooling / CLI**: run, test, action, and build commands need report output, governed-mode grants, denial diagnostics, and machine-readable schemas. +- **Package metadata**: packages need a way to publish capability declarations and receipt schemas. +- **Typechecker / Semantic metadata**: static capability declarations and action requirements should be exposed as checked metadata where available. +- **IR Lowering / Backend**: source spans and semantic identities should be preserved well enough for receipts to point back to source and semantic objects. +- **LSP / Docs tooling**: editors and docs can surface capability declarations, required grants, denial diagnostics, and report artifacts. +- **Policy / CI / Agents**: policy and automation can consume capability declarations and receipts to decide whether runs, actions, generated artifacts, or proposed changes are acceptable. + +## Unresolved questions + +- What is the exact grammar for capability identities? +- Should capability declarations live in source syntax, declaration metadata, package manifests, or all of them? +- What should the default run mode be for `incan run`, `incan test`, and typed actions? +- What is the minimum stable host capability set? +- How should scoped grants be represented for paths, hosts, methods, models, tools, and artifacts? +- Should package-defined capabilities be allowed to imply host capabilities automatically when a user grants the package capability, or should host grants always be listed separately? +- What is the first stable receipt schema version? +- How should receipt sinks be configured, and where should reports be written by default? +- Which replay classifications are required for the first implementation? +- How should telemetry export represent receipts without making telemetry a dependency of receipt generation? +- How should capability budgets be expressed in CLI, package metadata, and typed action declarations? + + From 1e927205ab9ed043ee7d3f9b985c3fa270279b36 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 11:53:00 +0200 Subject: [PATCH 26/58] chore - link secret values RFC to release PR (#661) --- workspaces/docs-site/docs/RFCs/103_secret_values.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/docs-site/docs/RFCs/103_secret_values.md b/workspaces/docs-site/docs/RFCs/103_secret_values.md index 8bc5b9a60..db8a29473 100644 --- a/workspaces/docs-site/docs/RFCs/103_secret_values.md +++ b/workspaces/docs-site/docs/RFCs/103_secret_values.md @@ -14,7 +14,7 @@ - RFC 093 (`std.telemetry` observability) - RFC 102 (semantic layer inspection surface) - **Issue:** https://github.com/dannys-code-corner/incan/issues/661 -- **RFC PR:** — +- **RFC PR:** https://github.com/dannys-code-corner/incan/pull/618 - **Written against:** v0.3 - **Shipped in:** — From dea180a237a00548c6522081a7598c7490fa483e Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 11:56:00 +0200 Subject: [PATCH 27/58] chore - link ambient runtime RFC to issue (#662) --- .../RFCs/104_ambient_runtime_capabilities_and_receipts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md index a9f2aa3dc..9771cf226 100644 --- a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md +++ b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md @@ -19,8 +19,8 @@ - RFC 095 (`span` vocabulary blocks) - RFC 102 (semantic layer inspection surface) - RFC 103 (secret values and redaction-safe values) -- **Issue:** — -- **RFC PR:** — +- **Issue:** https://github.com/dannys-code-corner/incan/issues/662 +- **RFC PR:** https://github.com/dannys-code-corner/incan/pull/618 - **Written against:** v0.3 - **Shipped in:** — From 5419f97a991e6442c02a1fc05077ab7469103992 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 16:53:49 +0200 Subject: [PATCH 28/58] bugfix - support const model metadata and static imports (#658, #659) (#660) --- Cargo.lock | 18 +-- Cargo.toml | 2 +- src/backend/ir/codegen.rs | 8 + src/backend/ir/decl.rs | 6 + src/backend/ir/emit/consts.rs | 95 +++++++---- src/backend/ir/emit/decls/mod.rs | 62 +++++++- src/backend/ir/lower/decl/imports.rs | 4 + src/frontend/typechecker/const_eval.rs | 147 ++++++++++++++++++ tests/integration_tests.rs | 104 +++++++++++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 10 files changed, 405 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dce337561..44e6cdcce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc10" +version = "0.3.0-rc11" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 035d5fccf..2dee5b76e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc10" +version = "0.3.0-rc11" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 9c15a228f..fd84efddc 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -1426,6 +1426,7 @@ def main() -> None: IrImportItem { name: String::from("Rng"), alias: None, + is_static: false, rust_trait_import: Some(IrRustTraitImport { trait_path: String::from("rand::Rng"), definition_path: None, @@ -1435,6 +1436,7 @@ def main() -> None: IrImportItem { name: String::from("thread_rng"), alias: None, + is_static: false, rust_trait_import: None, }, ], @@ -1539,6 +1541,7 @@ def main() -> None: IrImportItem { name: String::from("AlphaRender"), alias: None, + is_static: false, rust_trait_import: Some(IrRustTraitImport { trait_path: String::from("demo::AlphaRender"), definition_path: None, @@ -1548,6 +1551,7 @@ def main() -> None: IrImportItem { name: String::from("BetaRender"), alias: None, + is_static: false, rust_trait_import: Some(IrRustTraitImport { trait_path: String::from("demo::BetaRender"), definition_path: None, @@ -1644,11 +1648,13 @@ def main() -> None: IrImportItem { name: String::from("Rng"), alias: None, + is_static: false, rust_trait_import: None, }, IrImportItem { name: String::from("thread_rng"), alias: None, + is_static: false, rust_trait_import: None, }, ], @@ -1754,6 +1760,7 @@ def main() -> None: IrImportItem { name: String::from("Digest"), alias: None, + is_static: false, rust_trait_import: Some(IrRustTraitImport { trait_path: String::from("sha2::Digest"), definition_path: Some(String::from("digest::digest::Digest")), @@ -1763,6 +1770,7 @@ def main() -> None: IrImportItem { name: String::from("Sha256"), alias: None, + is_static: false, rust_trait_import: None, }, ], diff --git a/src/backend/ir/decl.rs b/src/backend/ir/decl.rs index f342ce8f7..be9f80276 100644 --- a/src/backend/ir/decl.rs +++ b/src/backend/ir/decl.rs @@ -173,6 +173,12 @@ pub struct IrRustTraitImport { pub struct IrImportItem { pub name: String, pub alias: Option, + /// Whether this import item binds an Incan `static` storage cell. + /// + /// Static declarations use Rust global naming in generated code, so imported static items must emit the provider's + /// static identifier and, when aliased, the local static identifier instead of treating the source spelling as an + /// ordinary Rust value binding. + pub is_static: bool, /// Metadata provided when this item is a Rust trait import. /// /// Extension-trait imports can be used by Rust method lookup without appearing as identifiers in emitted tokens. diff --git a/src/backend/ir/emit/consts.rs b/src/backend/ir/emit/consts.rs index aa356e0cd..5710b7fb3 100644 --- a/src/backend/ir/emit/consts.rs +++ b/src/backend/ir/emit/consts.rs @@ -37,37 +37,11 @@ impl<'a> IrEmitter<'a> { /// /// Everything else is rejected with an actionable error. pub(super) fn validate_const_emittable(&self, name: &str, ty: &IrType, value: &TypedExpr) -> Result<(), EmitError> { - /// Return whether an IR type can appear in a Rust `const` initializer emitted by RFC 008. - fn ok_ty(ty: &IrType) -> bool { - match ty { - IrType::Int - | IrType::Numeric(_) - | IrType::Float - | IrType::Bool - | IrType::StaticStr - | IrType::StaticBytes - | IrType::FrozenStr - | IrType::FrozenBytes => true, - IrType::Struct(_) => true, - IrType::Tuple(items) => items.iter().all(ok_ty), - IrType::NamedGeneric(name, args) if name == collections::as_str(CollectionTypeId::FrozenList) => { - args.first().map(ok_ty).unwrap_or(false) - } - IrType::NamedGeneric(name, args) if name == collections::as_str(CollectionTypeId::FrozenSet) => { - args.first().map(ok_ty).unwrap_or(false) - } - IrType::NamedGeneric(name, args) if name == collections::as_str(CollectionTypeId::FrozenDict) => { - args.first().map(ok_ty).unwrap_or(false) && args.get(1).map(ok_ty).unwrap_or(false) - } - _ => false, - } - } - - if !ok_ty(ty) { + if !self.const_type_emittable(ty) { let ty_name = ty.rust_name(); return Err(EmitError::Unsupported(format!( "const '{}' of type '{}' is not representable as a Rust const.\n\ - Allowed: int/exact-width numeric/float/bool/&'static str/&'static [u8]/tuples, FrozenList/Set/Dict with allowed element types.\n\ + Allowed: int/exact-width numeric/float/bool/&'static str/&'static [u8]/tuples, Option, const-safe models, FrozenList/Set/Dict with allowed element types.\n\ Consider computing at runtime or simplifying the const.", name, ty_name ))); @@ -76,6 +50,64 @@ impl<'a> IrEmitter<'a> { Self::validate_const_expr_kind(&value.kind) } + /// Return whether an IR type can appear in a Rust `const` initializer emitted by RFC 008. + fn const_type_emittable(&self, ty: &IrType) -> bool { + let mut seen_structs = std::collections::HashSet::new(); + self.const_type_emittable_inner(ty, &mut seen_structs) + } + + fn const_type_emittable_inner(&self, ty: &IrType, seen_structs: &mut std::collections::HashSet) -> bool { + match ty { + IrType::Int + | IrType::Numeric(_) + | IrType::Float + | IrType::Bool + | IrType::StaticStr + | IrType::StaticBytes + | IrType::FrozenStr + | IrType::FrozenBytes => true, + IrType::Option(inner) => self.const_type_emittable_inner(inner, seen_structs), + IrType::Struct(name) => self.const_struct_type_emittable(name, seen_structs), + IrType::Tuple(items) => items + .iter() + .all(|item| self.const_type_emittable_inner(item, seen_structs)), + IrType::NamedGeneric(name, args) if name == collections::as_str(CollectionTypeId::FrozenList) => args + .first() + .map(|arg| self.const_type_emittable_inner(arg, seen_structs)) + .unwrap_or(false), + IrType::NamedGeneric(name, args) if name == collections::as_str(CollectionTypeId::FrozenSet) => args + .first() + .map(|arg| self.const_type_emittable_inner(arg, seen_structs)) + .unwrap_or(false), + IrType::NamedGeneric(name, args) if name == collections::as_str(CollectionTypeId::FrozenDict) => { + args.first() + .map(|arg| self.const_type_emittable_inner(arg, seen_structs)) + .unwrap_or(false) + && args + .get(1) + .map(|arg| self.const_type_emittable_inner(arg, seen_structs)) + .unwrap_or(false) + } + _ => false, + } + } + + fn const_struct_type_emittable(&self, name: &str, seen_structs: &mut std::collections::HashSet) -> bool { + if !seen_structs.insert(name.to_string()) { + return false; + } + let emittable = self.struct_constructor_metadata.get(name).is_some_and(|variants| { + variants.iter().any(|metadata| { + metadata + .field_types + .values() + .all(|field_ty| self.const_type_emittable_inner(field_ty, seen_structs)) + }) + }); + seen_structs.remove(name); + emittable + } + /// RFC 008 const expression shape check (defensive backend guard). /// /// Frontend const-eval should already reject non-const expressions, but this @@ -142,8 +174,11 @@ impl<'a> IrEmitter<'a> { } Ok(()) } - K::Struct { fields, .. } if fields.len() == 1 && fields[0].0.is_empty() => { - Self::validate_const_expr_kind(&fields[0].1.kind) + K::Struct { fields, .. } => { + for (_, field_value) in fields { + Self::validate_const_expr_kind(&field_value.kind)?; + } + Ok(()) } K::Call { diff --git a/src/backend/ir/emit/decls/mod.rs b/src/backend/ir/emit/decls/mod.rs index cd6ea6d18..7db988e9b 100644 --- a/src/backend/ir/emit/decls/mod.rs +++ b/src/backend/ir/emit/decls/mod.rs @@ -217,6 +217,7 @@ impl<'a> IrEmitter<'a> { let elems = elems?; Ok(quote! { (#(#elems),*) }) } + (T::Struct(_), IrExprKind::Struct { name, fields }) => self.emit_const_struct_value(name, fields), (T::FrozenStr, IrExprKind::String(s)) => Ok(quote! { incan_stdlib::frozen::FrozenStr::new(#s) }), (T::FrozenBytes, IrExprKind::Bytes(bytes)) => { let lit = Literal::byte_string(bytes); @@ -226,6 +227,55 @@ impl<'a> IrEmitter<'a> { } } + /// Emit a struct/model literal in a Rust const initializer without applying runtime ownership conversions. + fn emit_const_struct_value( + &self, + name: &str, + fields: &[(String, super::super::TypedExpr)], + ) -> Result { + let n = Self::rust_ident(name); + let Some(metadata) = self.struct_constructor_metadata_for_fields(name, fields) else { + let field_tokens: Result, EmitError> = fields + .iter() + .map(|(field_name, field_value)| { + let field_ident = Self::rust_ident(field_name); + let value = self.emit_const_value_for_type(&field_value.ty, field_value)?; + Ok(quote! { #field_ident: #value }) + }) + .collect(); + let field_tokens = field_tokens?; + return Ok(quote! { #n { #(#field_tokens),* } }); + }; + + let mut provided: std::collections::HashMap<&str, &super::super::TypedExpr> = std::collections::HashMap::new(); + for (field_name, field_value) in fields { + if let Some(canonical) = metadata.canonical_field_name(field_name) { + provided.insert(canonical, field_value); + } + } + + let mut out_fields = Vec::new(); + for field_name in &metadata.fields { + let field_ident = Self::rust_ident(field_name); + let Some(target_ty) = metadata.field_types.get(field_name) else { + return Err(EmitError::Unsupported(format!( + "missing field type metadata for const field '{}.{}'", + name, field_name + ))); + }; + let Some(field_value) = provided.get(field_name.as_str()) else { + return Err(EmitError::Unsupported(format!( + "const model constructor '{}' must provide field '{}' explicitly", + name, field_name + ))); + }; + let value = self.emit_const_value_for_type(target_ty, field_value)?; + out_fields.push(quote! { #field_ident: #value }); + } + + Ok(quote! { #n { #(#out_fields),* } }) + } + // ---- Import emission ---- /// Return whether an import path refers to the source-authored Incan stdlib namespace. @@ -438,12 +488,20 @@ impl<'a> IrEmitter<'a> { && item.name.chars().next().is_some_and(|ch| ch.is_ascii_uppercase())) }) .map(|item| { - let name_ident = Self::rust_ident(&item.name); + let name_ident = if item.is_static { + Self::rust_static_ident(&item.name) + } else { + Self::rust_ident(&item.name) + }; let path_tokens_clone = path_tokens.clone(); let path_ts_clone = join_path_tokens(&path_tokens_clone); let absolute_path = matches!(qualifier, IrImportQualifier::None) && !is_pub_library_import; if let Some(alias) = &item.alias { - let alias_ident = Self::rust_ident(alias); + let alias_ident = if item.is_static { + Self::rust_static_ident(alias) + } else { + Self::rust_ident(alias) + }; if should_reexport_item(item) { if absolute_path { quote! { pub use :: #path_ts_clone :: #name_ident as #alias_ident; } diff --git a/src/backend/ir/lower/decl/imports.rs b/src/backend/ir/lower/decl/imports.rs index cac4cad1f..e59a2cf82 100644 --- a/src/backend/ir/lower/decl/imports.rs +++ b/src/backend/ir/lower/decl/imports.rs @@ -84,6 +84,10 @@ impl AstLowering { super::super::super::decl::IrImportItem { name: item.name.clone(), alias: item.alias.clone(), + is_static: self + .type_info + .as_ref() + .is_some_and(|info| info.static_binding(binding_name).is_some()), rust_trait_import, } }) diff --git a/src/frontend/typechecker/const_eval.rs b/src/frontend/typechecker/const_eval.rs index 80c4841a2..799727cc1 100644 --- a/src/frontend/typechecker/const_eval.rs +++ b/src/frontend/typechecker/const_eval.rs @@ -793,6 +793,12 @@ impl TypeChecker { } Expr::Call(callee, type_args, args) if type_args.is_empty() => { + if let Expr::Ident(callee_name) = &callee.node + && self.is_const_model_constructor_name(callee_name) + { + return self.eval_const_model_constructor(callee_name, args, expected, stack, decl_span, expr.span); + } + let Some(ResolvedType::Named(expected_name)) = expected else { self.errors.push(errors::const_expression_not_allowed(expr.span)); return None; @@ -834,6 +840,9 @@ impl TypeChecker { value: None, }) } + Expr::Constructor(name, args) if self.is_const_model_constructor_name(name) => { + self.eval_const_model_constructor(name, args, expected, stack, decl_span, expr.span) + } // Disallowed constructs for RFC 008 phase 1. Expr::Call(_, _, _) @@ -864,6 +873,144 @@ impl TypeChecker { } } + /// Return whether a name resolves to a model constructor that can be considered for const literal validation. + fn is_const_model_constructor_name(&self, name: &str) -> bool { + self.lookup_type_info(name) + .is_some_and(|info| matches!(info, TypeInfo::Model(_))) + } + + /// Evaluate a model constructor in a const initializer. + /// + /// This keeps `const` model literals declaration-safe: every provided field must itself be const-evaluable, all + /// required fields must be explicit, and omitted defaults are rejected because model defaults are ordinary runtime + /// expressions rather than const metadata. + fn eval_const_model_constructor( + &mut self, + type_name: &str, + args: &[CallArg], + expected: Option<&ResolvedType>, + stack: &mut Vec, + decl_span: Span, + call_span: Span, + ) -> Option { + if let Some(expected_ty) = expected + && !matches!(expected_ty, ResolvedType::Named(name) if name == type_name) + && !matches!(expected_ty, ResolvedType::Unknown) + { + return Some(ConstEvalResult { + ty: ResolvedType::Named(type_name.to_string()), + kind: ConstKind::RustNative, + value: None, + }); + } + + let Some(TypeInfo::Model(model)) = self.lookup_type_info(type_name).cloned() else { + self.errors.push(errors::const_expression_not_allowed(call_span)); + return None; + }; + + let mut provided = std::collections::HashSet::new(); + let mut result_kind = ConstKind::RustNative; + let mut had_error = false; + + for arg in args { + let CallArg::Named(field_name, value) = arg else { + self.errors + .push(errors::positional_constructor_args_not_supported(type_name, call_span)); + had_error = true; + continue; + }; + + let Some((canonical_name, field_info)) = Self::resolve_const_model_field(&model.fields, field_name) else { + self.eval_const_expr(value, None, stack, decl_span); + self.errors + .push(errors::missing_field(type_name, field_name, value.span)); + had_error = true; + continue; + }; + + if !provided.insert(canonical_name.clone()) { + self.errors.push(errors::duplicate_field_in_call( + type_name, + canonical_name.as_str(), + value.span, + )); + had_error = true; + continue; + } + + let Some(field_result) = self.eval_const_expr(value, Some(&field_info.ty), stack, decl_span) else { + had_error = true; + continue; + }; + if field_result.kind == ConstKind::Frozen { + result_kind = ConstKind::Frozen; + } + + if field_result.ty != field_info.ty { + match self.const_int_value_checked_against_numeric_expected(&field_result, &field_info.ty, value.span) { + Some(true) => {} + Some(false) => had_error = true, + None => { + self.errors.push(errors::field_type_mismatch( + field_name, + &field_info.ty.to_string(), + &field_result.ty.to_string(), + value.span, + )); + had_error = true; + } + } + } + } + + for (field_name, field_info) in &model.fields { + if provided.contains(field_name) { + continue; + } + if field_info.has_default { + self.errors.push(CompileError::type_error( + format!( + "Const model constructor '{}' must provide field '{}' explicitly; model defaults are not evaluated in const initializers", + type_name, field_name + ), + call_span, + )); + } else { + self.errors.push(errors::missing_required_constructor_field( + type_name, field_name, call_span, + )); + } + had_error = true; + } + + if had_error { + return None; + } + + Some(ConstEvalResult { + ty: ResolvedType::Named(type_name.to_string()), + kind: result_kind, + value: None, + }) + } + + /// Resolve a model constructor field by canonical source name or model alias. + fn resolve_const_model_field<'a>( + fields: &'a std::collections::HashMap, + field_name: &str, + ) -> Option<(String, &'a crate::frontend::symbols::FieldInfo)> { + fields + .get(field_name) + .map(|info| (field_name.to_string(), info)) + .or_else(|| { + fields + .iter() + .find(|(_, info)| info.alias.as_deref() == Some(field_name)) + .map(|(name, info)| (name.clone(), info)) + }) + } + /// Evaluate a literal in a const context, optionally checking it against an expected type. fn eval_const_literal( &mut self, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c124421d9..52ed60b55 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1995,6 +1995,110 @@ def main() -> None: ); } +#[test] +fn test_const_model_constructor_compile_and_run_issue658() -> Result<(), Box> { + let source = r#" +model Version: + pub major: int + pub minor: int + +model Change: + pub version: Version + note [alias="message"]: FrozenStr + +model Lifecycle: + pub since: Version + pub changed: FrozenList[Change] + pub deprecated: Option[Version] + +pub const V0_1: Version = Version(major=0, minor=1) +pub const V0_3: Version = Version(major=0, minor=3) +pub const LIFECYCLE: Lifecycle = Lifecycle( + since=V0_1, + changed=[Change(version=V0_3, message="metadata")], + deprecated=None, +) + +def main() -> None: + println(f"{V0_1.major}.{V0_1.minor}") + println(f"{LIFECYCLE.changed[0].version.major}.{LIFECYCLE.changed[0].version.minor}") + println(LIFECYCLE.changed[0].note) + match LIFECYCLE.deprecated: + None => println("active") + Some(version) => println(f"{version.major}.{version.minor}") +"#; + let output = incan_command() + .args(["run", "-c", source]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + + assert!( + output.status.success(), + "expected const model constructor program to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.lines().any(|line| line.trim() == "0.1"), + "expected const model constructor output 0.1.\nstdout:\n{stdout}" + ); + assert!( + stdout.lines().any(|line| line.trim() == "0.3"), + "expected nested const model constructor output 0.3.\nstdout:\n{stdout}" + ); + assert!( + stdout.lines().any(|line| line.trim() == "metadata"), + "expected nested const model constructor output metadata.\nstdout:\n{stdout}" + ); + assert!( + stdout.lines().any(|line| line.trim() == "active"), + "expected const model option metadata output active.\nstdout:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn test_lowercase_imported_pub_static_compile_and_run_issue659() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let versions = dir.join("versions.incn"); + let main = dir.join("main.incn"); + std::fs::write( + &versions, + r#" +pub static v0_1: int = 1 +pub static v0_2: int = 2 +"#, + )?; + std::fs::write( + &main, + r#" +from versions import v0_1 +from versions import v0_2 as current_version + +def main() -> None: + println(v0_1) + println(current_version) +"#, + )?; + + let output = incan_command() + .args(["run", main.to_string_lossy().as_ref()]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + + assert!( + output.status.success(), + "expected lowercase imported pub static program to run.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, ["1", "2"], "unexpected lowercase static output"); + Ok(()) +} + #[test] fn test_static_list_index_assignment_and_remove_compile_and_run() -> Result<(), Box> { let source = r#" diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 9c913e3db..bebbaa7b8 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, and method-call decorator factories on class/static registry receivers (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, and lowercase exported static imports (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From e9012787432397dddef0135f3a6658e8b7445d2d Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 17:42:48 +0200 Subject: [PATCH 29/58] docs - add architect rule engine RFC (#663) --- .../docs/RFCs/105_architect_rule_engine.md | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md diff --git a/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md b/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md new file mode 100644 index 000000000..fd9e0aa2f --- /dev/null +++ b/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md @@ -0,0 +1,373 @@ +# RFC 105: `incan architect` rule engine for design, safety, idiom, and smell findings + +- **Status:** Draft +- **Created:** 2026-05-24 +- **Author(s):** Danny Meijer (@dannymeijer) +- **Related:** + - RFC 006 (generators) + - RFC 048 (contract-backed models emit and tooling) + - RFC 070 (Result combinators) + - RFC 088 (iterator adapter surface) + - RFC 096 (declaration metadata blocks) +- **Issue:** https://github.com/dannys-code-corner/incan/issues/663 +- **RFC PR:** https://github.com/dannys-code-corner/incan/pull/618 +- **Written against:** v0.3 +- **Shipped in:** — + +## Summary + +This RFC proposes `incan architect` as a deterministic code-advice command for Incan projects. The command reports evidence-backed findings across architecture, safety, idiom usage, and maintainability smells by running maintainable rules over compiler-backed codegraph facts. The central goal is not to create a broad subjective linter, but to create a durable rule authoring surface where new advice can be added cheaply, tested precisely, calibrated against real projects, and consumed by humans, agents, editors, and CI without relying on model inference for core detection. + +## Core model + +1. **Compiler-backed facts first:** `incan architect` consumes source facts produced by Incan's parser, module/import resolver, typechecker, metadata pipeline, and codegraph exporter rather than independently scraping text. +2. **Rules interpret facts:** Each rule consumes typed fact views and emits findings with stable codes, priorities, categories, confidence, evidence, suggestions, and risks. +3. **Findings are advisory:** Architect findings are not compiler errors. They describe design pressure or code-shape opportunities with enough evidence for a human or agent to decide whether to act. +4. **Categories are explicit:** Architecture findings, safety findings, idiom findings, and code-smell findings remain separate in rule codes and profiles even when they share one command. +5. **Conservative detection is preferred:** The command should under-report ambiguous style opportunities rather than produce noisy, low-trust advice. +6. **Rule authoring is a product surface:** The feature is only maintainable if adding a rule means using stable typed facts and reusable queries, not hand-parsing raw graph nodes or reimplementing AST walks. + +## Motivation + +Incan already has syntax checks, semantic checks, formatter behavior, tests, and generated-Rust validation. Those tools answer whether a program parses, typechecks, formats, and runs. They do not answer whether a project is accumulating design pressure: repeated dispatch over the same domain, public boundaries that can panic on recoverable input, old-shaped control flow that should now use language features, or small helper functions that add indirection without carrying domain meaning. + +The first experiments with an architecture-advice command showed that deterministic rules can surface useful pressure when they report concrete source evidence and stay cautious about severity. Repeated match dispatch can reveal a growing operation boundary. Fail-fast calls inside public APIs can reveal recoverability problems. Body-shape facts can also support smaller maintainability smells such as compound-assignment candidates, single-use trivial helpers, append-only list builders that could become comprehensions, or `Result` matches that could use RFC 070 combinators. + +Without a formal rule engine, each new check risks becoming a one-off command-private AST walk with custom parsing, inconsistent output, and ad hoc severity. That path does not scale. The value is in a shared substrate: one project-wide codegraph, one typed query layer, one finding model, one de-duplication path, and many small rules that are easy to review and calibrate. + +This feature also matters for agent workflows. Agents can already make broad refactoring suggestions, but those suggestions are often expensive to verify and easy to overfit. `incan architect` should provide deterministic evidence that an agent can use as grounding: exact files, lines, matched domains, shared patterns, call sites, usage counts, and counterexample risks. A model may later summarize or prioritize findings, but the core detection should remain inspectable and reproducible. + +## Goals + +- Define `incan architect` as the umbrella command for deterministic design, safety, idiom, and maintainability-smell advice. +- Provide a stable finding model with rule code, category, priority, confidence, evidence, pressure, suggestions, risks, and machine-readable output. +- Provide project-wide directory scanning over `.incn` source trees with deterministic module de-duplication and finding de-duplication. +- Establish rule categories and profiles so users can run architecture-only, safety-only, idiom-only, smell-only, or all-rule scans. +- Establish a maintainable rule authoring surface based on typed facts and reusable queries over codegraph data. +- Extend codegraph body facts as needed for rule families such as match dispatch, call sites, references, assignment/update shapes, helper usage, loop-builder shapes, and result-match shapes. +- Include code smells in scope when they can be detected conservatively with clear evidence and useful counterexamples. +- Keep detection deterministic for the first version; no language model is required for core finding generation. +- Support text output for humans and stable JSON output for tools, agents, editors, and CI. +- Make suppression and baselining part of the product model so mature codebases can adopt the command incrementally. + +## Non-Goals + +- This RFC does not make architect findings compiler errors. +- This RFC does not replace formatter rules, typechecker diagnostics, Clippy-style generated-Rust checks, or project tests. +- This RFC does not require a small language model or remote AI service for rule detection. +- This RFC does not attempt to infer developer intent from names alone. +- This RFC does not require every possible code smell to ship in the first version. +- This RFC does not define automatic rewrites or apply fixes. +- This RFC does not define a public plugin ABI for third-party binary rule packages. +- This RFC does not require every codegraph fact to be part of a permanently stable external schema in the first release; only the JSON findings format and documented command behavior need v0.5 stability. + +## Guide-level explanation + +Users run `incan architect` on a file or project directory. + +```bash +incan architect . +incan architect src/lib.incn --format json +incan architect . --profile architecture +incan architect . --profile smells +``` + +The command prints findings grouped by priority and grounded in source evidence. + +```text +[P3] Repeated match dispatch over `source_kind` +Pressure: 2 match expressions dispatch over `source_kind` and share 3/3 explicit arms: SourceKind.Arrow(...), SourceKind.Csv(...), SourceKind.Parquet(...) +Suggestions: + - Decide whether this is intentionally exhaustive local logic or a growing operation boundary. + - If it is a growing operation boundary, prefer an adapter or registry outside the domain type when the operation belongs to another subsystem. +Risks: + - Keep local exhaustive matches when they are clearer than an abstraction and the case set changes rarely. +Evidence: + - src/backend.incn:160:5 in register_one (explicit arms: 3/3; fallback: no) + - src/schema.incn:322:5 in schema_columns_for_source (explicit arms: 3/3; fallback: no) +``` + +The architecture value is not merely that two matches are textually similar. The useful signal is that separate subsystems are making parallel decisions over the same closed domain. For example, an ingestion package might register execution backends in one module and infer schemas in another module, with both operations matching every `SourceKind` variant. + +```incan +def register_backend(kind: SourceKind, registry: BackendRegistry) -> None: + match kind: + SourceKind.Csv(_) => registry.add("csv", csv_backend()) + SourceKind.Json(_) => registry.add("json", json_backend()) + SourceKind.Parquet(_) => registry.add("parquet", parquet_backend()) + + +def infer_columns(source: Source) -> Result[list[Column], SchemaError]: + match source.kind: + SourceKind.Csv(_) => return infer_csv_columns(source) + SourceKind.Json(_) => return infer_json_columns(source) + SourceKind.Parquet(_) => return infer_parquet_columns(source) +``` + +The recommendation should not be "put backend registration and schema inference methods on `SourceKind`." That would move subsystem responsibilities onto the enum. The more architectural advice is to ask whether this is a growing operation boundary. If every new source format requires coordinated edits to backend registration, schema inference, validation, documentation, and test fixtures, the code may want a format-handler registry or adapter table where each format owns its related operations. + +```text +[P3] Repeated match dispatch over `source.kind` +Pressure: backend registration and schema inference both dispatch over all source formats. +Suggestion: Consider a format-handler registry if adding one format requires shotgun edits across subsystems. +Risk: Keep exhaustive local matches if the format set is closed, the operations are genuinely local, and cross-format registration would obscure control flow. +``` + +Architect findings use categories. Architecture findings describe design pressure. Safety findings describe failure or recoverability risk. Idiom findings describe opportunities to use Incan features more directly. Smell findings describe local maintainability pressure. + +```text +safety.fail_fast_boundary_call +idiom.result_combinator_candidate +smell.single_use_trivial_helper +arch.repeated_match_dispatch +``` + +Small smells are allowed when they are precise and humble. A trivial helper rule can identify a private helper that is used once and only returns a pure expression. + +```incan +def add(left: int, right: int) -> int: + return left + right +``` + +The finding should not say that the helper is definitely wrong. It should say that the helper may be unnecessary unless its name carries useful domain meaning. + +```text +[P3] Private helper only wraps one expression +Pressure: `add` is private, used once, and only returns `left + right`. +Suggestion: Inline the expression if the helper does not name a useful domain concept. +Risk: Keep the helper if it documents intent, preserves API shape, acts as a callback, or is expected to grow. +``` + +A comprehension candidate should likewise report a specific body shape, not a broad preference. + +```incan +def positive_scores(scores: list[int]) -> list[int]: + out = [] + for score in scores: + if score > 0: + out.append(score) + return out +``` + +The corresponding advice is useful only because the shape is append-only, the accumulator is returned, and no other mutation or side effect participates in the loop. + +```text +[P3] Append-only list builder can be a comprehension +Pressure: `positive_scores` builds and returns a list with one append-only loop. +Suggestion: Use `[score for score in scores if score > 0]` if the eager list is the intended result. +Risk: Keep the loop if additional statements, logging, early exits, or mutation are part of the real workflow. +``` + +For RFC 070 `Result` combinators, architect can identify obvious match shapes and suggest the equivalent method only when the transformation is mechanically recognizable. + +```incan +match parsed: + Ok(value) => Ok(clean(value)) + Err(err) => Err(err) +``` + +The finding can suggest `parsed.map(clean)` because one branch transforms the `Ok` payload and the `Err` branch passes through unchanged. + +## Reference-level explanation + +### Command behavior + +`incan architect [PATH] [OPTIONS]` must accept a source file or directory. When `PATH` is omitted, the command should scan the current directory. + +When `PATH` is a file, the command must scan the file and the modules needed to resolve its imports according to ordinary Incan module rules. + +When `PATH` is a directory, the command must scan `.incn` files under that directory recursively. The scan must be deterministic. The scan must de-duplicate modules by source path so a file imported by multiple roots contributes facts once. + +The command must provide `--format text` and `--format json`. Text output is for humans. JSON output is the integration surface for agents, editors, CI, dashboards, and future baselining tools. + +The command should provide `--profile` with at least `architecture`, `safety`, `idioms`, `smells`, and `all`. The default profile is unresolved by this draft. + +### Finding model + +Every finding must have a stable rule code. Rule codes must be namespaced by category. + +```text +arch.repeated_match_dispatch +safety.fail_fast_boundary_call +idiom.result_combinator_candidate +smell.single_use_trivial_helper +``` + +Every finding must include a category, priority, confidence, title, pressure, evidence, suggestions, and risks. + +Priority must describe expected action pressure, not proof certainty. + +```text +P1: likely correctness, reliability, or public-boundary risk that should be reviewed before release +P2: meaningful design or maintainability pressure that should be tracked or scheduled +P3: watchlist, idiom, or local smell that may be worth cleanup when nearby work touches the code +Info: low-pressure educational or style-level advice +``` + +Confidence must describe how mechanically strong the rule match is. + +```text +High: the rule found a narrow, mechanically recognizable shape +Medium: the rule found a useful pattern with plausible counterexamples +Low: the rule is exploratory and should normally be hidden outside explicit profiles +``` + +Evidence must identify source file, line, column, owner declaration when available, and rule-specific context. Rule-specific context may include matched arms, overlap counts, fallback/default-arm presence, callee labels, usage counts, body-shape summaries, or suggested replacement text. + +Suggestions must be phrased as advice, not certainty. Risks must name the common counterexamples that would make the suggestion wrong. + +Findings must be de-duplicated before output. Identical findings produced through multiple import roots must appear once. + +### Rule categories + +Architecture rules describe design pressure across declarations, modules, domains, or boundaries. Repeated match dispatch, growing literal domains, and operation-boundary pressure belong here. + +Safety rules describe recoverability, fail-fast behavior, partial handling, unchecked assumptions, or public-boundary hazards. A public function that can panic on caller-provided data belongs here. + +Idiom rules describe opportunities to use Incan language or stdlib features more directly. Result combinator candidates, iterator adapter candidates, generator/comprehension candidates, and compound assignment candidates belong here. + +Smell rules describe local maintainability pressure. Single-use trivial helpers, repeated literals, unnecessary wrappers, long branch-heavy functions, and append-only builders belong here when detected conservatively. + +Rules must not be categorized as architecture findings merely because they are emitted by `incan architect`. + +### Rule authoring contract + +Rules must declare metadata: code, category, default priority, default confidence, profile membership, required fact kinds, and a short explanation. + +Rules must consume typed fact views rather than raw serialized facts. A rule that needs match dispatch sites, call sites, assignment shapes, helper usage counts, or loop-builder shapes should ask for those views directly. + +Rules should be small and independently testable. Each rule should have positive and negative fixtures. Negative fixtures are required for common counterexamples named in the rule's risk text. + +Rules must not require typechecked metadata when a syntactic fact is sufficient. Rules may use type facts when precision depends on type information, such as recognizing `Result[T, E]` match shapes. + +Rules should prefer narrow body-shape facts over broad textual heuristics. For example, a comprehension candidate should be based on an append-only list-builder shape, not the mere presence of a `for` loop and `append`. + +Rules must not emit findings for generated stdlib internals or known external code unless the user explicitly scans those sources. + +### Codegraph fact requirements + +The codegraph exporter must provide enough source facts for rules to avoid command-private AST walks. The first useful fact families are declarations, imports, public API metadata, match dispatches, call sites, references, assignment/update shapes, function body summaries, usage counts, loop-builder shapes, and result-match shapes. + +Match dispatch facts must include the matched domain, explicit pattern labels, explicit pattern count, source arm count, and wildcard/default-arm context. + +Call-site facts must include callee key, callee label, receiver shape when available, source location, and owner declaration. + +Reference facts must support usage counting for private declarations and helper functions. + +Assignment/update facts must make compound-assignment candidates expressible without string matching. + +Function body summary facts should identify simple shapes such as single-return expression, pure expression wrapper, append-only list builder, and short result-match transform. These summaries must be conservative. + +Result-match facts should identify branch-preserving transformations only when the matched expression is known to be a `Result[T, E]` or the syntactic shape is unambiguous enough for an idiom finding with appropriate confidence. + +### Suppression and baselining + +The command should support local suppression of a specific rule at a specific source location. Suppression syntax is unresolved by this draft. + +The command should support project baselines so existing findings can be recorded and new findings can fail CI or be highlighted separately. Baseline storage is unresolved by this draft. + +Suppressions and baselines must preserve rule code and evidence identity. A future change that moves or changes the evidence should not silently suppress an unrelated finding. + +## Design details + +### Profiles + +Profiles let users choose the kind of advice they want. `architecture` should include cross-cutting design pressure. `safety` should include fail-fast and recoverability risk. `idioms` should include feature-usage opportunities. `smells` should include local maintainability findings. `all` should include every non-experimental rule. + +Rules may belong to more than one profile only when that does not blur the category. For example, a public fail-fast boundary call is a safety finding even if it also has architecture implications. + +Exploratory rules may exist behind an explicit experimental profile, but they must not be enabled by default. + +### Severity calibration + +Severity should be calibrated against evidence strength, public surface impact, and likely cost of ignoring the finding. Public API failures are generally higher priority than private helper smells. Repeated design pressure across files is generally higher priority than a local expression-level cleanup. Idiom suggestions are generally P3 or Info unless the shape creates repeated complexity or risk. + +Rules should downrank or suppress known low-action cases. For example, fail-fast calls around trusted constants may be lower priority than fail-fast calls around caller-provided input. Exhaustive matches over a closed domain may be preferable to abstraction when the matched operation is local and the domain changes rarely. + +### Examples of initial rules + +`arch.repeated_match_dispatch` reports repeated match expressions that dispatch over the same domain and share multiple explicit arms. The rule should report overlap counts and wildcard/default context. + +`safety.fail_fast_boundary_call` reports `unwrap`, `expect`, `panic`, `todo`, and `unreachable` inside public or internal boundaries. Public API boundaries should generally be P1. Internal boundaries should generally be P2 unless evidence shows trusted constants or invariant setup. + +`idiom.result_combinator_candidate` reports obvious RFC 070 match shapes that can be expressed with `map`, `map_err`, `and_then`, `or_else`, `inspect`, or `inspect_err`. + +`idiom.compound_assignment_candidate` reports assignments such as `i = i + 1` when the target and left operand are the same simple storage place and `i += 1` is equivalent. + +`idiom.comprehension_candidate` reports append-only list builders that can be represented as eager list comprehensions. + +`smell.single_use_trivial_helper` reports private, undocumented, undecorated helpers that are used once and only return a simple pure expression. The rule must mention that domain vocabulary can justify keeping the helper. + +`smell.repeated_literal_domain` reports repeated raw string or scalar literal domains used as branch keys or dispatch keys across multiple sites. + +## Alternatives considered + +### Keep architect as architecture-only + +This would preserve a narrow name, but it would force closely related idiom and smell findings into a separate command even though they need the same project-wide codegraph, evidence model, de-duplication, profiles, suppressions, and JSON output. The better boundary is category namespace, not separate infrastructure. + +### Build a general linter instead + +A general linter would fit small syntax-level advice, but it would understate the project-wide design-pressure use case. The command should remain broader than a linter while still identifying local smells as one category. + +### Use a language model for rule detection + +Model-based detection may be useful later for summarization, clustering, or explaining findings in pull requests. It is not the right foundation for v0.5 rule detection because findings need to be reproducible, testable, source-grounded, and suitable for CI. + +### Let every rule walk the AST directly + +This is the fastest way to add a first rule and the worst way to maintain many rules. It duplicates traversal logic, fragments fact extraction, and makes rule behavior harder to share with agents, editors, and other code-intelligence tools. + +### Make findings auto-fixable from the start + +Some findings will eventually support safe rewrites, such as compound assignment candidates. Making fixes part of the first version would expand the scope into formatter, semantic preservation, and edit application. The first version should focus on reliable findings and stable output. + +## Drawbacks + +This feature adds a new advisory surface that can become noisy if rule quality is poor. The command must earn trust by being conservative, showing evidence, and naming counterexamples. + +The codegraph fact model will grow. If facts are added without a typed query layer, rules will become stringly and brittle. If facts are over-designed too early, implementation will slow down before the rule set proves itself. + +Some code smells are subjective. A helper that looks unnecessary may carry important domain meaning. A loop that could be a comprehension may be clearer as a loop when side effects are about to be added. The finding model must make room for this uncertainty through confidence and risk text. + +Project-wide scanning may be slower than entry-point scanning. The implementation should keep scans deterministic and should leave room for caching, but v0.5 should prioritize correctness and evidence over premature optimization. + +## Implementation architecture + +This section is non-normative. + +The recommended internal shape is a layered pipeline: source collection, compiler-backed codegraph extraction, typed fact views, query indexes, independent rule modules, finding normalization, de-duplication, profile filtering, and text/JSON rendering. + +The codegraph layer should remain the producer of source facts. The architect layer should not own parsing or typechecking behavior. Architect rules should operate over typed views such as match dispatch sites, call sites, references, assignment/update candidates, usage counts, loop-builder shapes, and result-match shapes. + +The rule engine should provide a small metadata contract for rule authors. A rule should declare its code, category, default priority, confidence, profiles, required facts, and explanation. A rule should receive a query context and emit findings. + +The report layer should be shared by all rules. Sorting, de-duplication, JSON serialization, text formatting, suppression matching, and baseline matching should not be implemented per rule. + +The first version should ship with a small calibrated rule set rather than a large catalogue. New rules should be added only when they have clear positive fixtures, negative fixtures, and calibration evidence from real source. + +## Layers affected + +- **Parser / AST**: No new user syntax is required, but source traversal must expose enough body shapes for codegraph facts. +- **Typechecker / Symbol resolution**: Rules may need checked public API metadata, resolved imports, type facts for `Result` shapes, and symbol usage information. +- **IR Lowering**: No required impact. +- **Emission**: No required impact. +- **Stdlib / Runtime (`incan_stdlib`)**: No required runtime impact, though stdlib feature surfaces such as Result combinators and iterator adapters inform idiom rules. +- **Formatter**: No required impact unless future auto-fix support is added. +- **LSP / Tooling**: The JSON findings format should be usable by editors, agents, CI, and future diagnostics-style surfaces. +- **CLI / Project tooling**: `incan architect` needs project-wide scanning, profiles, stable text/JSON output, suppression support, and baseline support. +- **Documentation**: The CLI reference must document command behavior, profiles, categories, priorities, confidence, suppressions, and examples. + +## Unresolved questions + +- What is the default profile for `incan architect .`: architecture-only, architecture plus safety, or all stable rules? +- What suppression syntax should Incan use for architect findings, and should it share vocabulary with compiler diagnostic suppressions? +- Should baselines live in `incan.toml`, a separate lock-like file, or a generated artifact under project tooling state? +- Which finding fields are stable enough to commit as v0.5 JSON output, and which should remain experimental? +- Should code-smell findings use the namespace `smell.*` or `maintainability.*`? +- Should project-wide directory scanning include tests by default, and should findings from tests use a separate priority calibration? +- How should architect distinguish trusted-constant fail-fast calls from caller-input fail-fast calls in a deterministic, maintainable way? +- Should third-party rule packages be considered after v0.5, or should v0.5 explicitly restrict rule authoring to the Incan repository? + + From 0348b01e32b8eba59b24768734a4d72581ba1e1d Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 17:55:35 +0200 Subject: [PATCH 30/58] docs - link semantic layer inspection RFC (#666) --- .../docs/RFCs/102_incan_semantic_layer_inspection_surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md b/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md index eb8586cc5..73c6c40e0 100644 --- a/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md +++ b/workspaces/docs-site/docs/RFCs/102_incan_semantic_layer_inspection_surface.md @@ -20,7 +20,7 @@ - RFC 092 (interactive runtime stdlib contracts) - RFC 096 (declaration metadata blocks) - RFC 097 (Rust-hosted Incan caller) -- **Issue:** — +- **Issue:** https://github.com/dannys-code-corner/incan/issues/666 - **RFC PR:** — - **Written against:** v0.3 - **Shipped in:** — From 5cde16da92aabc1f1eb78e2e4b17ea6e7206ea65 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 18:05:54 +0200 Subject: [PATCH 31/58] added RFCs --- .agents/learnings.md | 1 + .../docs-site/docs/RFCs/096_declaration_metadata_blocks.md | 2 +- workspaces/docs-site/docs/RFCs/100_std_re_pythonic_regex.md | 2 +- workspaces/docs-site/docs/RFCs/103_secret_values.md | 2 +- .../docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md | 2 +- workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.agents/learnings.md b/.agents/learnings.md index 8f7d1400c..e07133105 100644 --- a/.agents/learnings.md +++ b/.agents/learnings.md @@ -79,6 +79,7 @@ Reference document for AI agents. These are hard-won insights from past RFC impl - **Implementation docs must be user-facing**: RFCs and release notes do not satisfy user documentation for a new language/compiler feature; when behavior is user-visible, update the authored explanation/how-to/tutorial/reference docs where users actually learn the surface, not just the RFC or changelog. (RFC 049 / issue #333) - **Markdown prose should not be short-wrapped**: when generating authored Markdown documents, do not manually wrap prose to artificial line lengths; use natural paragraph lines unless the structure itself requires line breaks, because short-wrapped prose reads fragmented and creates noisy diffs for whitepapers, RFCs, and research docs. (Pallay research docs, April 2026) - **RFC phase before code**: when using `ralph-loop` for an RFC implementation, move the RFC to `In Progress` and confirm the implementation plan/checklist before writing code; do not treat lifecycle edits and phase confirmation as a post-implementation cleanup step. (RFC 016 / issue #327) +- **RFC PR means implementation PR**: in RFC headers, `RFC PR` is the PR where the RFC was implemented or shipped, not the proposal issue or the PR that first added the Draft RFC document. Leave it unset for Draft or otherwise unimplemented RFCs even when a proposal issue exists. - **North-star first for RFCs**: when a maintainer asks for an RFC, start from the desired end-state contract and only discuss incremental slices after that north-star is explicit; do not reflexively shrink RFC scope into the smallest implementable change unless the user asks for rollout planning. - **Pre-RFC research lives in root `__research__`**: capture exploratory north-star notes, spikes, and design parking lots under the repository root `__research__/` directory, not `.agents/`; `.agents/` is for reusable agent workflows/learnings rather than project research artifacts. (Android/Incan rustc bridge ideation, May 2026) - **RFCs are decision records, not diaries**: keep RFCs as moment-in-time intent/status documents, and move implementation details, drift notes, and current behavior into regular docs or release notes with issue links instead of rewriting RFC narrative in flight. diff --git a/workspaces/docs-site/docs/RFCs/096_declaration_metadata_blocks.md b/workspaces/docs-site/docs/RFCs/096_declaration_metadata_blocks.md index 8764d4911..fa13a688b 100644 --- a/workspaces/docs-site/docs/RFCs/096_declaration_metadata_blocks.md +++ b/workspaces/docs-site/docs/RFCs/096_declaration_metadata_blocks.md @@ -12,7 +12,7 @@ - RFC 085 (field metadata and type-shaped constraints) - RFC 086 (schema descriptors and adapters) - RFC 091 (constrained integer newtype storage carriers) -- **Issue:** — +- **Issue:** https://github.com/dannys-code-corner/incan/issues/667 - **RFC PR:** — - **Written against:** v0.3 - **Shipped in:** — diff --git a/workspaces/docs-site/docs/RFCs/100_std_re_pythonic_regex.md b/workspaces/docs-site/docs/RFCs/100_std_re_pythonic_regex.md index 8a32181e8..794c5ba92 100644 --- a/workspaces/docs-site/docs/RFCs/100_std_re_pythonic_regex.md +++ b/workspaces/docs-site/docs/RFCs/100_std_re_pythonic_regex.md @@ -8,7 +8,7 @@ - RFC 023 (compilable stdlib and Rust module binding) - RFC 059 (`std.regex`) - RFC 070 (Result combinators) -- **Issue:** — +- **Issue:** https://github.com/dannys-code-corner/incan/issues/668 - **RFC PR:** — - **Written against:** v0.3 - **Shipped in:** — diff --git a/workspaces/docs-site/docs/RFCs/103_secret_values.md b/workspaces/docs-site/docs/RFCs/103_secret_values.md index db8a29473..2c74b2ccf 100644 --- a/workspaces/docs-site/docs/RFCs/103_secret_values.md +++ b/workspaces/docs-site/docs/RFCs/103_secret_values.md @@ -14,7 +14,7 @@ - RFC 093 (`std.telemetry` observability) - RFC 102 (semantic layer inspection surface) - **Issue:** https://github.com/dannys-code-corner/incan/issues/661 -- **RFC PR:** https://github.com/dannys-code-corner/incan/pull/618 +- **RFC PR:** - - **Written against:** v0.3 - **Shipped in:** — diff --git a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md index 9771cf226..2a2981b1b 100644 --- a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md +++ b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md @@ -20,7 +20,7 @@ - RFC 102 (semantic layer inspection surface) - RFC 103 (secret values and redaction-safe values) - **Issue:** https://github.com/dannys-code-corner/incan/issues/662 -- **RFC PR:** https://github.com/dannys-code-corner/incan/pull/618 +- **RFC PR:** - - **Written against:** v0.3 - **Shipped in:** — diff --git a/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md b/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md index fd9e0aa2f..b52b59486 100644 --- a/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md +++ b/workspaces/docs-site/docs/RFCs/105_architect_rule_engine.md @@ -10,7 +10,7 @@ - RFC 088 (iterator adapter surface) - RFC 096 (declaration metadata blocks) - **Issue:** https://github.com/dannys-code-corner/incan/issues/663 -- **RFC PR:** https://github.com/dannys-code-corner/incan/pull/618 +- **RFC PR:** - - **Written against:** v0.3 - **Shipped in:** — From 33a6e415355e3676db7388247163b7051c3a7712 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 19:40:36 +0200 Subject: [PATCH 32/58] bugfix - scope generated script dependencies to reachable uses (#665) (#670) --- Cargo.lock | 18 +- Cargo.toml | 2 +- crates/incan_core/src/lang/stdlib.rs | 8 + src/cli/commands/build.rs | 71 ++++- src/cli/commands/common.rs | 274 +++++++++++++++++- src/cli/commands/lock.rs | 30 +- src/cli/test_runner/execution.rs | 77 ++--- src/dependency_resolver.rs | 132 ++++++++- tests/cli_integration.rs | 16 + tests/integration_tests.rs | 35 +-- .../docs-site/docs/release_notes/0_3.md | 2 +- 11 files changed, 543 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44e6cdcce..1cda1d6a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc11" +version = "0.3.0-rc12" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 2dee5b76e..8e18e9474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc11" +version = "0.3.0-rc12" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/lang/stdlib.rs b/crates/incan_core/src/lang/stdlib.rs index 6353179f0..8bb1717a0 100644 --- a/crates/incan_core/src/lang/stdlib.rs +++ b/crates/incan_core/src/lang/stdlib.rs @@ -593,6 +593,12 @@ pub fn find_extra_crate_dep(crate_name: &str) -> Option<&'static StdlibExtraCrat extra_crate_deps().find(|dep| dep.crate_name == crate_name) } +/// Return whether a crate is supplied by the workspace as a stdlib-managed path dependency. +#[must_use] +pub fn is_path_extra_crate_dep(crate_name: &str) -> bool { + find_extra_crate_dep(crate_name).is_some_and(|dep| matches!(dep.source, StdlibExtraCrateSource::Path(_))) +} + /// Return the published Cargo package name when a stdlib-managed Rust crate imports under a different crate key. #[must_use] pub fn extra_crate_package_alias(crate_name: &str) -> Option<&'static str> { @@ -1044,6 +1050,8 @@ mod tests { macros.map(|dep| dep.source), Some(StdlibExtraCrateSource::Path("crates/incan_web_macros")) ); + assert!(is_path_extra_crate_dep("incan_web_macros")); + assert!(!is_path_extra_crate_dep("axum")); assert!(find_extra_crate_dep("not_a_stdlib_dependency").is_none()); } diff --git a/src/cli/commands/build.rs b/src/cli/commands/build.rs index 9495b51e0..941d9b50c 100644 --- a/src/cli/commands/build.rs +++ b/src/cli/commands/build.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use crate::backend::{IrCodegen, ProjectGenerator, RunProfile}; use crate::cli::{CliError, CliResult, ExitCode}; -use crate::dependency_resolver::resolve_dependencies; +use crate::dependency_resolver::{resolve_dependencies, resolve_reachable_dependencies}; use crate::frontend::api_metadata::{ CHECKED_API_METADATA_SCHEMA_VERSION, CheckedApiMetadataPackage, CheckedApiPackageIdentity, collect_checked_api_metadata, validate_checked_api_docstrings, @@ -28,8 +28,8 @@ use crate::lockfile::CargoFeatureSelection; use crate::manifest::ProjectManifest; use super::common::{ - CargoPolicy, build_source_map, cargo_command_flags, collect_inline_rust_imports, collect_modules, - collect_project_requirements, enforce_project_toolchain_constraint, format_dependency_error, + CargoPolicy, build_source_map, cargo_command_flags, collect_modules, collect_project_requirements, + collect_rust_dependency_uses, enforce_project_toolchain_constraint, format_dependency_error, imported_module_deps_for_with_index, merge_project_requirement_dependencies, module_key_index, resolve_project_root, typecheck_modules_with_import_graph, validate_output_dir, }; @@ -544,9 +544,9 @@ fn prepare_project( .and_then(|m| m.build.as_ref().and_then(|b| b.rust_edition.clone())), ); - let mut inline_imports = collect_inline_rust_imports(main_module, false); + let mut inline_imports = collect_rust_dependency_uses(main_module, false); for module in dep_modules { - inline_imports.extend(collect_inline_rust_imports(module, false)); + inline_imports.extend(collect_rust_dependency_uses(module, false)); } // RFC 023: Stdlib modules should not have inline rust imports (they use rust.module() + @rust.extern instead), // so we skip collecting from them. @@ -558,7 +558,7 @@ fn prepare_project( } .normalized(); - let mut resolved = match resolve_dependencies(manifest.as_ref(), &inline_imports, true, &cargo_features) { + let mut resolved = match resolve_reachable_dependencies(manifest.as_ref(), &inline_imports, true, &cargo_features) { Ok(resolved) => resolved, Err(errors) => { let mut msg = String::new(); @@ -720,9 +720,9 @@ pub fn build_library( let rust_extern_contexts = collect_rust_extern_contexts(&modules); let dep_modules = &modules[..modules.len() - 1]; - let mut inline_imports = collect_inline_rust_imports(lib_module, false); + let mut inline_imports = collect_rust_dependency_uses(lib_module, false); for module in dep_modules { - inline_imports.extend(collect_inline_rust_imports(module, false)); + inline_imports.extend(collect_rust_dependency_uses(module, false)); } let project_name = manifest .project @@ -771,10 +771,8 @@ pub fn build_library( rust_inspect_query_paths: &metadata_query_paths, })?; #[cfg(feature = "rust_inspect")] - let rust_inspect_manifest_dir = project_root.join("target").join("incan_lock"); - #[cfg(feature = "rust_inspect")] - { - ensure_rust_inspect_workspace( + let rust_inspect_manifest_dir = { + let rust_inspect_manifest_dir = ensure_rust_inspect_workspace( &project_root, project_name.as_str(), manifest.build.as_ref().and_then(|build| build.rust_edition.clone()), @@ -783,7 +781,8 @@ pub fn build_library( lock_payload_for_typecheck.clone(), )?; prewarm_rust_inspect_workspace(&rust_inspect_manifest_dir, &metadata_query_paths)?; - } + rust_inspect_manifest_dir + }; let mut all_errors = String::new(); let mut checked_exports_by_module: HashMap> = HashMap::new(); @@ -1116,6 +1115,52 @@ mod tests { assert!(rendered.contains("incan_stdlib::testing::fail")); } + #[test] + fn run_entrypoint_omits_unused_manifest_rust_dependencies() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path(); + let scripts_dir = project_root.join("scripts"); + std::fs::create_dir_all(&scripts_dir)?; + std::fs::write( + project_root.join("incan.toml"), + "[project]\nname = \"unused_rust_dep_run_repro\"\nversion = \"0.1.0\"\n\n[rust-dependencies]\ndatafusion = \"53\"\n", + )?; + std::fs::write( + scripts_dir.join("check.incn"), + "def main() -> None:\n println(\"ok\")\n", + )?; + + let cargo_lock_payload = std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock"))?; + let fingerprint = compute_deps_fingerprint(&[], &[], &CargoFeatureSelection::default(), Some(project_root)); + let incan_lock = IncanLock::new(fingerprint, CargoFeatureSelection::default(), cargo_lock_payload); + incan_lock.write(&project_root.join("incan.lock"))?; + + let entry_path = scripts_dir.join("check.incn"); + let output_dir = project_root.join("target").join("incan").join("check"); + let entry_arg = entry_path + .to_str() + .ok_or("entry path should be valid utf-8 for prepare_project test")?; + let output_arg = output_dir + .to_str() + .ok_or("output path should be valid utf-8 for prepare_project test")?; + + prepare_project( + entry_arg, + Some(output_arg), + &CargoPolicy::default(), + Vec::new(), + false, + false, + )?; + + let generated_manifest = std::fs::read_to_string(output_dir.join("Cargo.toml"))?; + assert!( + !generated_manifest.contains("datafusion"), + "unused package-level rust dependencies should not be emitted for a script run:\n{generated_manifest}" + ); + Ok(()) + } + #[cfg(feature = "rust_inspect")] #[test] fn library_rust_abi_query_paths_include_rust_extern_backing_items() -> Result<(), Box> { diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs index 21b9137ca..9e19dde2e 100644 --- a/src/cli/commands/common.rs +++ b/src/cli/commands/common.rs @@ -467,6 +467,109 @@ pub(crate) fn merge_project_requirement_dependencies( Ok(()) } +pub(crate) fn merge_project_requirements( + current: &ProjectRequirements, + extra: &ProjectRequirements, +) -> CliResult { + let stdlib_features = current + .stdlib_features + .iter() + .chain(extra.stdlib_features.iter()) + .cloned() + .collect::>() + .into_iter() + .collect(); + + let mut dependencies = current.dependencies.clone(); + for candidate in &extra.dependencies { + if let Some(existing) = dependencies.iter().find(|dep| dep.crate_name == candidate.crate_name) { + if existing != candidate { + return Err(CliError::failure(format!( + "dependency requirement `{}` conflicts between project requirement contexts", + candidate.crate_name + ))); + } + continue; + } + dependencies.push(candidate.clone()); + } + dependencies.sort_by(|left, right| left.crate_name.cmp(&right.crate_name)); + + Ok(ProjectRequirements { + stdlib_features, + dependencies, + }) +} + +pub(crate) fn merge_resolved_dependencies( + current: &ResolvedDependencies, + extra: &ResolvedDependencies, +) -> CliResult { + let mut merged = current.clone(); + for candidate in &extra.dependencies { + merge_resolved_dependency(&mut merged.dependencies, &mut merged.dev_dependencies, candidate, false)?; + } + for candidate in &extra.dev_dependencies { + merge_resolved_dependency(&mut merged.dependencies, &mut merged.dev_dependencies, candidate, true)?; + } + merged + .dependencies + .sort_by(|left, right| left.crate_name.cmp(&right.crate_name)); + merged + .dev_dependencies + .sort_by(|left, right| left.crate_name.cmp(&right.crate_name)); + Ok(merged) +} + +fn merge_resolved_dependency( + dependencies: &mut Vec, + dev_dependencies: &mut Vec, + candidate: &DependencySpec, + dev_only: bool, +) -> CliResult<()> { + if let Some(existing) = dependencies.iter().find(|dep| dep.crate_name == candidate.crate_name) { + if existing != candidate { + return Err(CliError::failure(format!( + "dependency `{}` conflicts between resolved dependency contexts", + candidate.crate_name + ))); + } + return Ok(()); + } + + if dev_only { + if let Some(existing) = dev_dependencies + .iter() + .find(|dep| dep.crate_name == candidate.crate_name) + { + if existing != candidate { + return Err(CliError::failure(format!( + "dev dependency `{}` conflicts between resolved dependency contexts", + candidate.crate_name + ))); + } + return Ok(()); + } + dev_dependencies.push(candidate.clone()); + return Ok(()); + } + + if let Some(existing_idx) = dev_dependencies + .iter() + .position(|dep| dep.crate_name == candidate.crate_name) + { + if dev_dependencies[existing_idx] != *candidate { + return Err(CliError::failure(format!( + "dependency `{}` conflicts between dependency and dev-dependency contexts", + candidate.crate_name + ))); + } + dev_dependencies.remove(existing_idx); + } + dependencies.push(candidate.clone()); + Ok(()) +} + #[cfg(feature = "rust_inspect")] const RUST_INSPECT_WORKSPACE_FINGERPRINT_FILE: &str = ".incan_rust_inspect_fingerprint"; @@ -575,7 +678,7 @@ fn hash_dependency_spec_for_rust_inspect(hasher: &mut Sha256, spec: &DependencyS hasher.update(b"|dep|\0"); } -/// Stable fingerprint for inputs that define the generated rust-inspect Cargo workspace under `target/incan_lock`. +/// Stable fingerprint for inputs that define one generated rust-inspect Cargo workspace. #[cfg(feature = "rust_inspect")] fn rust_inspect_workspace_fingerprint( project_name: &str, @@ -643,14 +746,42 @@ fn rust_inspect_workspace_fingerprint( ) } +#[cfg(feature = "rust_inspect")] +fn rust_inspect_workspace_dir(project_root: &Path, project_name: &str, fingerprint: &str) -> PathBuf { + let mut safe_name = project_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + if safe_name.is_empty() { + safe_name.push_str("project"); + } + let suffix = fingerprint + .rsplit_once(':') + .map(|(_, hash)| hash) + .unwrap_or(fingerprint) + .chars() + .take(16) + .collect::(); + project_root + .join("target") + .join("incan_lock") + .join("rust_inspect") + .join(format!("{safe_name}-{suffix}")) +} + /// Generate the rust-inspect workspace that semantic Rust extraction should query for this project. /// /// The generated workspace intentionally uses the Rust import spelling for dependency keys, while preserving the /// published Cargo package name separately when the two differ. /// /// When the same inputs are seen again (for example across multiple `incan test` cases in one package), regeneration is -/// skipped if `target/incan_lock/.incan_rust_inspect_fingerprint` matches the computed digest and expected artifacts -/// exist. +/// skipped if the namespaced workspace fingerprint matches the computed digest and expected artifacts exist. #[cfg(feature = "rust_inspect")] pub(crate) fn ensure_rust_inspect_workspace( project_root: &Path, @@ -660,16 +791,6 @@ pub(crate) fn ensure_rust_inspect_workspace( project_requirements: &ProjectRequirements, cargo_lock_payload: Option, ) -> CliResult { - let base_rust_inspect_manifest_dir = project_root.join("target").join("incan_lock"); - let rust_inspect_manifest_dir = if project_name.starts_with("incan_cmd_") { - base_rust_inspect_manifest_dir.join(project_name) - } else { - base_rust_inspect_manifest_dir - }; - let fingerprint_path = rust_inspect_manifest_dir.join(RUST_INSPECT_WORKSPACE_FINGERPRINT_FILE); - let cargo_toml_path = rust_inspect_manifest_dir.join("Cargo.toml"); - let main_rs_path = rust_inspect_manifest_dir.join("src").join("main.rs"); - let fingerprint = rust_inspect_workspace_fingerprint( project_name, rust_edition.as_deref(), @@ -677,6 +798,10 @@ pub(crate) fn ensure_rust_inspect_workspace( &project_requirements.stdlib_features, cargo_lock_payload.as_deref(), ); + let rust_inspect_manifest_dir = rust_inspect_workspace_dir(project_root, project_name, &fingerprint); + let fingerprint_path = rust_inspect_manifest_dir.join(RUST_INSPECT_WORKSPACE_FINGERPRINT_FILE); + let cargo_toml_path = rust_inspect_manifest_dir.join("Cargo.toml"); + let main_rs_path = rust_inspect_manifest_dir.join("src").join("main.rs"); let fingerprint_matches = match fs::read_to_string(&fingerprint_path) { Ok(existing) => existing.trim() == fingerprint.as_str(), @@ -1332,6 +1457,31 @@ pub(crate) fn collect_inline_rust_imports(module: &ParsedModule, is_test_context imports } +/// Extract all Rust dependency uses from a parsed module. +pub(crate) fn collect_rust_dependency_uses(module: &ParsedModule, is_test_context: bool) -> Vec { + let mut imports = collect_inline_rust_imports(module, is_test_context); + let Some(rust_module_path) = &module.ast.rust_module_path else { + return imports; + }; + let Some(crate_name) = rust_module_path.node.split("::").next().filter(|name| !name.is_empty()) else { + return imports; + }; + if crate_name == stdlib::STDLIB_ROOT || stdlib::is_path_extra_crate_dep(crate_name) { + return imports; + } + + imports.push(build_inline_rust_import( + crate_name, + format!("rust.module(\"{}\")", rust_module_path.node), + &None, + &[], + rust_module_path.span, + &module.file_path, + is_test_context, + )); + imports +} + /// Build a map of file paths to source contents for error reporting. pub(crate) fn build_source_map(modules: &[ParsedModule]) -> HashMap { let mut sources = HashMap::new(); @@ -1572,6 +1722,18 @@ mod tests { }) } + fn registry_dependency(crate_name: &str, version: &str) -> DependencySpec { + DependencySpec { + crate_name: crate_name.to_string(), + version: Some(version.to_string()), + features: Vec::new(), + default_features: true, + source: DependencySource::Registry, + optional: false, + package: None, + } + } + fn write_minimal_library_artifact( root: &Path, dependency_key: &str, @@ -1589,6 +1751,80 @@ mod tests { Ok(()) } + #[test] + fn collect_rust_dependency_uses_includes_rust_module_root() -> Result<(), Box> { + let module = parsed_module_for_test("rust.module(\"datafusion::prelude\")\n\ndef main() -> None:\n pass\n")?; + + let imports = collect_rust_dependency_uses(&module, false); + + assert!( + imports.iter().any(|import| import.crate_name == "datafusion" + && import.import_path == "rust.module(\"datafusion::prelude\")"), + "rust.module roots should participate in dependency resolution: {imports:?}" + ); + Ok(()) + } + + #[test] + fn collect_rust_dependency_uses_skips_stdlib_path_extra_crate_roots() -> Result<(), Box> { + let module = parsed_module_for_test("rust.module(\"incan_web_macros\")\n\ndef main() -> None:\n pass\n")?; + + let imports = collect_rust_dependency_uses(&module, false); + + assert!( + imports.iter().all(|import| import.crate_name != "incan_web_macros"), + "stdlib-managed path crates should come from project requirements, not rust.module dependency uses: {imports:?}" + ); + Ok(()) + } + + #[test] + fn merge_resolved_dependencies_unions_dependency_contexts() -> Result<(), Box> { + let current = ResolvedDependencies { + dependencies: vec![registry_dependency("serde", "1")], + dev_dependencies: vec![registry_dependency("tokio", "1")], + }; + let extra = ResolvedDependencies { + dependencies: vec![ + registry_dependency("tokio", "1"), + registry_dependency("datafusion", "53"), + ], + dev_dependencies: Vec::new(), + }; + + let merged = merge_resolved_dependencies(¤t, &extra)?; + + assert_eq!( + merged + .dependencies + .iter() + .map(|dependency| dependency.crate_name.as_str()) + .collect::>(), + vec!["datafusion", "serde", "tokio"] + ); + assert!(merged.dev_dependencies.is_empty()); + Ok(()) + } + + #[test] + fn merge_resolved_dependencies_rejects_conflicting_contexts() { + let current = ResolvedDependencies { + dependencies: vec![registry_dependency("serde", "1")], + dev_dependencies: Vec::new(), + }; + let extra = ResolvedDependencies { + dependencies: vec![registry_dependency("serde", "2")], + dev_dependencies: Vec::new(), + }; + + let error = match merge_resolved_dependencies(¤t, &extra) { + Ok(merged) => panic!("expected conflict, got merged dependencies: {merged:?}"), + Err(error) => error, + }; + assert!(error.message.contains("serde")); + assert!(error.message.contains("conflicts")); + } + #[test] fn compilation_session_parses_with_imported_library_vocab() -> Result<(), Box> { let tmp = tempfile::tempdir()?; @@ -2637,6 +2873,18 @@ pub def main() -> int: assert_ne!(fp_one, fp_two); } + #[cfg(feature = "rust_inspect")] + #[test] + fn rust_inspect_workspace_dir_is_namespaced_by_input_fingerprint() { + let root = Path::new("/workspace"); + let first = super::rust_inspect_workspace_dir(root, "demo", "v1:aaaaaaaaaaaaaaaaaaaaaaaa"); + let second = super::rust_inspect_workspace_dir(root, "demo", "v1:bbbbbbbbbbbbbbbbbbbbbbbb"); + + assert_ne!(first, second); + assert!(first.ends_with(Path::new("target/incan_lock/rust_inspect/demo-aaaaaaaaaaaaaaaa"))); + assert!(second.ends_with(Path::new("target/incan_lock/rust_inspect/demo-bbbbbbbbbbbbbbbb"))); + } + #[cfg(feature = "rust_inspect")] #[test] fn ensure_rust_inspect_workspace_uses_rust_safe_dependency_keys() -> Result<(), Box> { diff --git a/src/cli/commands/lock.rs b/src/cli/commands/lock.rs index 65cc81827..fdbf6bda2 100644 --- a/src/cli/commands/lock.rs +++ b/src/cli/commands/lock.rs @@ -16,16 +16,17 @@ use sha2::{Digest, Sha256}; use crate::backend::ProjectGenerator; use crate::cli::prelude::ParsedModule; use crate::cli::{CliError, CliResult, ExitCode}; -use crate::dependency_resolver::{InlineRustImport, ResolvedDependencies, resolve_dependencies}; +use crate::dependency_resolver::{InlineRustImport, ResolvedDependencies, resolve_reachable_dependencies}; use crate::frontend::library_manifest_index::LibraryManifestIndex; use crate::frontend::{diagnostics, lexer, parser}; use crate::lockfile::{CargoFeatureSelection, IncanLock, compute_deps_fingerprint}; use crate::manifest::ProjectManifest; use super::common::{ - CargoPolicy, ProjectRequirements, build_source_map, cargo_command_flags, cargo_lockfile_flags, - collect_inline_rust_imports, collect_modules, collect_project_requirements, enforce_project_toolchain_constraint, - format_dependency_error, merge_project_requirement_dependencies, + CargoPolicy, ProjectRequirements, build_source_map, cargo_command_flags, cargo_lockfile_flags, collect_modules, + collect_project_requirements, collect_rust_dependency_uses, enforce_project_toolchain_constraint, + format_dependency_error, merge_project_requirement_dependencies, merge_project_requirements, + merge_resolved_dependencies, }; #[cfg(feature = "rust_inspect")] use super::common::{collect_rust_inspect_query_paths, ensure_rust_inspect_workspace, prewarm_rust_inspect_workspace}; @@ -135,11 +136,18 @@ pub(crate) fn resolve_lock_payload(request: LockResolutionRequest<'_>) -> CliRes } else { None }; - let (resolved, project_requirements) = if let Some(context) = project_context.as_ref() { - (&context.resolved, &context.project_requirements) + let lock_inputs = if let Some(context) = project_context.as_ref() { + Some(( + merge_resolved_dependencies(resolved, &context.resolved)?, + merge_project_requirements(project_requirements, &context.project_requirements)?, + )) } else { - (resolved, project_requirements) + None }; + let (resolved, project_requirements) = lock_inputs + .as_ref() + .map(|(resolved, requirements)| (resolved, requirements)) + .unwrap_or((resolved, project_requirements)); #[cfg(feature = "rust_inspect")] let rust_inspect_query_paths = project_context .as_ref() @@ -269,12 +277,12 @@ fn collect_project_lock_context( let mut inline_imports = Vec::new(); for module in &modules { - inline_imports.extend(collect_inline_rust_imports(module, false)); + inline_imports.extend(collect_rust_dependency_uses(module, false)); } inline_imports.extend(test_inputs.inline_imports); let mut resolved = - resolve_dependencies(Some(manifest), &inline_imports, true, cargo_features).map_err(|errors| { + resolve_reachable_dependencies(Some(manifest), &inline_imports, true, cargo_features).map_err(|errors| { let mut msg = String::new(); let sources = build_source_map(&project_requirement_modules); for err in errors { @@ -674,7 +682,7 @@ fn collect_test_lock_inputs( source: source.clone(), ast: ast.clone(), }; - inline_imports.extend(collect_inline_rust_imports(&test_module, true)); + inline_imports.extend(collect_rust_dependency_uses(&test_module, true)); project_requirement_modules.push(test_module); let source_modules = crate::cli::test_runner::collect_source_modules_for_test( @@ -686,7 +694,7 @@ fn collect_test_lock_inputs( ) .map_err(CliError::failure)?; for module in &source_modules { - inline_imports.extend(collect_inline_rust_imports(module, false)); + inline_imports.extend(collect_rust_dependency_uses(module, false)); } project_requirement_modules.extend(source_modules); } diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 5bb91af8f..b13650407 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -15,7 +15,7 @@ use crate::cli::commands::common::{ collect_rust_inspect_query_paths, ensure_rust_inspect_workspace, prewarm_rust_inspect_workspace, }; use crate::cli::prelude::ParsedModule; -use crate::dependency_resolver::resolve_dependencies; +use crate::dependency_resolver::resolve_reachable_dependencies; use crate::dependency_resolver::{InlineRustImport, ResolvedDependencies}; use crate::frontend::ast::{ AssertKind, AssertStmt, CallArg, Declaration, DictEntry, Expr, ImportItem, ImportKind, ListEntry, ParamKind, @@ -67,9 +67,9 @@ fn collect_test_dependency_inline_imports( test_module: &ParsedModule, source_modules: &[ParsedModule], ) -> Vec { - let mut inline_imports = common::collect_inline_rust_imports(test_module, true); + let mut inline_imports = common::collect_rust_dependency_uses(test_module, true); for module in source_modules { - inline_imports.extend(common::collect_inline_rust_imports(module, false)); + inline_imports.extend(common::collect_rust_dependency_uses(module, false)); } inline_imports } @@ -1008,9 +1008,9 @@ fn compute_test_prep_cache_key( /// Merge stdlib feature flags from previously prepared files with the current file requirements. /// -/// The rust-inspect workspace lives under one shared `target/incan_lock` directory per package. If files in a single -/// `incan test` session require different stdlib features, a non-monotonic feature set can cause workspace -/// fingerprint churn and expensive mid-run rewrites. Keeping a session-local feature union avoids that churn. +/// Rust-inspect workspaces are keyed by dependency fingerprint under `target/incan_lock`. If files in a single +/// `incan test` session require different stdlib features, a non-monotonic feature set can fan out into extra +/// workspaces. Keeping a session-local feature union avoids that churn. fn merge_rust_inspect_stdlib_features<'a>( existing_feature_sets: impl Iterator, current_features: &[String], @@ -1045,7 +1045,7 @@ fn prepare_lock_entry( let modules = common::collect_modules(&lock_entry_arg).map_err(|err| err.message.clone())?; let mut inline_imports = Vec::new(); for module in &modules { - inline_imports.extend(common::collect_inline_rust_imports(module, false)); + inline_imports.extend(common::collect_rust_dependency_uses(module, false)); } let project_requirements = common::collect_project_requirements(&modules, library_manifest_index).map_err(|err| err.message.clone())?; @@ -1064,34 +1064,7 @@ fn merge_lock_project_requirements( current: &ProjectRequirements, lock_entry: &ProjectRequirements, ) -> Result { - let stdlib_features = current - .stdlib_features - .iter() - .chain(lock_entry.stdlib_features.iter()) - .cloned() - .collect::>() - .into_iter() - .collect(); - - let mut dependencies = current.dependencies.clone(); - for candidate in &lock_entry.dependencies { - if let Some(existing) = dependencies.iter().find(|dep| dep.crate_name == candidate.crate_name) { - if existing != candidate { - return Err(format!( - "dependency requirement `{}` conflicts between test batch and lock entry context", - candidate.crate_name - )); - } - continue; - } - dependencies.push(candidate.clone()); - } - dependencies.sort_by(|left, right| left.crate_name.cmp(&right.crate_name)); - - Ok(ProjectRequirements { - stdlib_features, - dependencies, - }) + common::merge_project_requirements(current, lock_entry).map_err(|err| err.message) } /// Promote project dev dependencies into ordinary dependencies for generated test-runner crates. @@ -2598,7 +2571,7 @@ pub(super) fn run_file_tests_batch( }; let mut resolved = - match resolve_dependencies(manifest.as_ref(), &inline_imports, true, &cargo_feature_selection) { + match resolve_reachable_dependencies(manifest.as_ref(), &inline_imports, true, &cargo_feature_selection) { Ok(resolved) => resolved, Err(errors) => { let mut sources = HashMap::new(); @@ -2649,21 +2622,25 @@ pub(super) fn run_file_tests_batch( .collect(); } }; - lock_resolved = - match resolve_dependencies(manifest.as_ref(), &lock_inline_imports, true, &cargo_feature_selection) { - Ok(resolved) => resolved, - Err(errors) => { - let sources = common::build_source_map(&lock_dependency_modules); - let mut msg = String::new(); - for err in &errors { - msg.push_str(&common::format_dependency_error(err, &sources)); - } - return tests - .iter() - .map(|t| (t.clone(), TestResult::Failed(start.elapsed(), msg.clone()))) - .collect(); + lock_resolved = match resolve_reachable_dependencies( + manifest.as_ref(), + &lock_inline_imports, + true, + &cargo_feature_selection, + ) { + Ok(resolved) => resolved, + Err(errors) => { + let sources = common::build_source_map(&lock_dependency_modules); + let mut msg = String::new(); + for err in &errors { + msg.push_str(&common::format_dependency_error(err, &sources)); } - }; + return tests + .iter() + .map(|t| (t.clone(), TestResult::Failed(start.elapsed(), msg.clone()))) + .collect(); + } + }; if let Err(err) = common::merge_project_requirement_dependencies(&mut lock_resolved, &lock_project_requirements) { diff --git a/src/dependency_resolver.rs b/src/dependency_resolver.rs index 221e42d65..2c44b6e04 100644 --- a/src/dependency_resolver.rs +++ b/src/dependency_resolver.rs @@ -54,6 +54,12 @@ pub struct ResolvedDependencies { pub dev_dependencies: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManifestDependencyScope { + All, + ReachableOnly, +} + fn with_rust_import_context(error: CompileError, import: &InlineRustImport) -> CompileError { error .with_note(format!("import site: `{}`", import.import_path)) @@ -65,6 +71,37 @@ pub fn resolve_dependencies( inline_imports: &[InlineRustImport], include_dev_dependencies: bool, cargo_features: &CargoFeatureSelection, +) -> Result> { + resolve_dependencies_with_scope( + manifest, + inline_imports, + include_dev_dependencies, + cargo_features, + ManifestDependencyScope::All, + ) +} + +pub fn resolve_reachable_dependencies( + manifest: Option<&ProjectManifest>, + inline_imports: &[InlineRustImport], + include_dev_dependencies: bool, + cargo_features: &CargoFeatureSelection, +) -> Result> { + resolve_dependencies_with_scope( + manifest, + inline_imports, + include_dev_dependencies, + cargo_features, + ManifestDependencyScope::ReachableOnly, + ) +} + +fn resolve_dependencies_with_scope( + manifest: Option<&ProjectManifest>, + inline_imports: &[InlineRustImport], + include_dev_dependencies: bool, + cargo_features: &CargoFeatureSelection, + scope: ManifestDependencyScope, ) -> Result> { let mut errors = Vec::new(); @@ -100,14 +137,24 @@ pub fn resolve_dependencies( ); // Combine manifest deps with resolved inline specs. - let mut resolved_deps: HashMap = manifest_deps.clone(); + let mut resolved_deps: HashMap = match scope { + ManifestDependencyScope::All => manifest_deps.clone(), + ManifestDependencyScope::ReachableOnly => { + select_manifest_dependencies(&manifest_deps, &inline_merge.manifest_dependency_keys) + } + }; let mut resolved_dev_deps: HashMap = if include_dev_dependencies { - manifest_dev_deps.clone() + match scope { + ManifestDependencyScope::All => manifest_dev_deps.clone(), + ManifestDependencyScope::ReachableOnly => { + select_manifest_dependencies(&manifest_dev_deps, &inline_merge.manifest_dev_dependency_keys) + } + } } else { HashMap::new() }; - for (crate_name, inline) in inline_merge { + for (crate_name, inline) in inline_merge.inline_specs { if inline.is_test_only { if include_dev_dependencies { resolved_dev_deps.insert(crate_name, inline.spec); @@ -141,6 +188,13 @@ pub fn resolve_dependencies( // Inline merge + validation // ============================================================================ +#[derive(Default)] +struct InlineMergeResult { + inline_specs: HashMap, + manifest_dependency_keys: HashSet, + manifest_dev_dependency_keys: HashSet, +} + struct InlineMergedSpec { spec: DependencySpec, is_test_only: bool, @@ -162,8 +216,10 @@ fn merge_inline_imports( manifest_dev_deps: &HashMap, library_dep_names: &HashSet, errors: &mut Vec, -) -> HashMap { +) -> InlineMergeResult { let mut merged: HashMap = HashMap::new(); + let mut manifest_dependency_keys = HashSet::new(); + let mut manifest_dev_dependency_keys = HashSet::new(); for import in inline_imports { if import.crate_name == stdlib::STDLIB_ROOT { @@ -229,6 +285,13 @@ fn merge_inline_imports( continue; } + if let Some((key, _)) = manifest_dep_match { + manifest_dependency_keys.insert(key.clone()); + } + if let Some((key, _)) = manifest_dev_dep_match { + manifest_dev_dependency_keys.insert(key.clone()); + } + if manifest_dep_match.is_some() || manifest_dev_dep_match.is_some() { if has_inline_spec { errors.push(DependencyError { @@ -335,7 +398,21 @@ fn merge_inline_imports( resolved.insert(crate_name, merged_spec); } - resolved + InlineMergeResult { + inline_specs: resolved, + manifest_dependency_keys, + manifest_dev_dependency_keys, + } +} + +fn select_manifest_dependencies( + deps: &HashMap, + selected_keys: &HashSet, +) -> HashMap { + deps.iter() + .filter(|(key, _)| selected_keys.contains(*key)) + .map(|(key, spec)| (key.clone(), spec.clone())) + .collect() } /// Convert one inline `rust::` import annotation into the dependency spec emitted to generated Cargo manifests. @@ -590,6 +667,16 @@ mod tests { .map_err(|errors| std::io::Error::other(format!("{errors:?}")).into()) } + fn resolve_reachable_ok( + manifest: Option<&ProjectManifest>, + inline_imports: &[InlineRustImport], + include_dev_dependencies: bool, + cargo_features: &CargoFeatureSelection, + ) -> TestResult { + resolve_reachable_dependencies(manifest, inline_imports, include_dev_dependencies, cargo_features) + .map_err(|errors| std::io::Error::other(format!("{errors:?}")).into()) + } + fn dependency<'a>(deps: &'a [DependencySpec], crate_name: &str) -> TestResult<&'a DependencySpec> { deps.iter() .find(|dep| dep.crate_name == crate_name) @@ -710,6 +797,41 @@ serde = "1.0" Ok(()) } + #[test] + fn reachable_resolution_omits_unused_manifest_dependency() -> TestResult { + let toml_str = r#" +[rust-dependencies] +datafusion = "53" +"#; + let manifest = parse_manifest(toml_str)?; + + let resolved = resolve_reachable_ok(Some(&manifest), &[], false, &default_cargo_features())?; + + assert!( + !resolved + .dependencies + .iter() + .any(|dependency| dependency.crate_name == "datafusion"), + "reachable resolution should not emit unused manifest dependencies: {resolved:?}" + ); + Ok(()) + } + + #[test] + fn reachable_resolution_keeps_imported_manifest_dependency() -> TestResult { + let toml_str = r#" +[rust-dependencies] +serde = "1.0" +"#; + let manifest = parse_manifest(toml_str)?; + let imports = vec![inline("serde", None, &[], false)]; + + let resolved = resolve_reachable_ok(Some(&manifest), &imports, false, &default_cargo_features())?; + let serde = dependency(&resolved.dependencies, "serde")?; + assert_eq!(serde.version.as_deref(), Some("1.0")); + Ok(()) + } + // ---- Phase 3: Dev-dep gating (test context only) ---- #[test] diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index e4acb4126..4a85e8f15 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -331,6 +331,14 @@ fn lock_preheats_dependency_graph_for_path_dependencies() -> Result<(), Box None: + println(str(value())) "#, )?; @@ -870,6 +878,14 @@ main = "src/main.incn" [rust-dependencies.serde] version = "1.0" +"#, + )?; + fs::write( + &main_path, + r#"from rust::serde import Serialize + +def main() -> None: + println("cli lifecycle ok") "#, )?; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 52ed60b55..df8d84fed 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -7848,21 +7848,7 @@ def test_sleep_b() -> None: "#, )?; - let sequential_start = std::time::Instant::now(); - let sequential = run_incan_test_with_args(&dir, &["--jobs", "1"]); - let sequential_elapsed = sequential_start.elapsed(); - let sequential_stdout = String::from_utf8_lossy(&sequential.stdout); - let sequential_stderr = String::from_utf8_lossy(&sequential.stderr); - assert!( - sequential.status.success(), - "expected sequential warm-up run to pass.\nstdout:\n{}\nstderr:\n{}", - sequential_stdout, - sequential_stderr, - ); - - let parallel_start = std::time::Instant::now(); let parallel = run_incan_test_with_args(&dir, &["--jobs", "2"]); - let parallel_elapsed = parallel_start.elapsed(); let parallel_stdout = String::from_utf8_lossy(¶llel.stdout); let parallel_stderr = String::from_utf8_lossy(¶llel.stderr); assert!( @@ -7871,11 +7857,22 @@ def test_sleep_b() -> None: parallel_stdout, parallel_stderr, ); - assert!( - parallel_elapsed + std::time::Duration::from_millis(250) < sequential_elapsed, - "expected --jobs 2 to run independent file batches concurrently; sequential={:?}, parallel={:?}\nparallel stdout:\n{}", - sequential_elapsed, - parallel_elapsed, + let running_a = parallel_stdout + .find("test_sleep_a.incn (1 item(s))") + .ok_or("expected parallel output to announce test_sleep_a.incn")?; + let running_b = parallel_stdout + .find("test_sleep_b.incn (1 item(s))") + .ok_or("expected parallel output to announce test_sleep_b.incn")?; + let passed_a = parallel_stdout + .find("test_sleep_a.incn::test_sleep_a PASSED") + .ok_or("expected parallel output to report test_sleep_a passing")?; + let passed_b = parallel_stdout + .find("test_sleep_b.incn::test_sleep_b PASSED") + .ok_or("expected parallel output to report test_sleep_b passing")?; + let first_pass = passed_a.min(passed_b); + assert!( + running_a < first_pass && running_b < first_pass, + "expected --jobs 2 to launch both independent file batches before either completed\nparallel stdout:\n{}", parallel_stdout, ); Ok(()) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index bebbaa7b8..6bf8392d2 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, and lowercase exported static imports (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, lowercase exported static imports, and generated script/test Cargo manifests that omit unreachable package-level Rust dependencies (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659, #665). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From bdb202549bcc094b45723d413b4b74ae4ccc8e14 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 20:53:41 +0200 Subject: [PATCH 33/58] bugfix - raw-ident keyword public aliases (#669) (#672) --- src/backend/ir/codegen.rs | 32 +++++++++++++++++++++++++++++++ src/backend/ir/emit/decls/mod.rs | 12 ++++++------ tests/integration_tests.rs | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index fd84efddc..c85bda2bc 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -1186,6 +1186,38 @@ def main() -> int: assert!(!code.contains("fn mean"), "{code}"); } + #[test] + fn top_level_keyword_named_callable_alias_uses_raw_identifier_reexport() { + let code = generate( + r#" +pub def modulo_value(value: int) -> int: + return value + +pub mod = alias modulo_value + +def main() -> int: + return mod(10) +"#, + ); + assert!(code.contains("pub fn modulo_value(value: i64) -> i64"), "{code}"); + assert!(code.contains("pub use modulo_value as r#mod;"), "{code}"); + assert!(code.contains("return modulo_value(10);"), "{code}"); + } + + #[test] + fn top_level_alias_to_keyword_named_callable_uses_raw_identifier_target_path() { + let code = generate( + r#" +pub def mod(value: int) -> int: + return value + +pub modulo = alias mod +"#, + ); + assert!(code.contains("pub fn r#mod(value: i64) -> i64"), "{code}"); + assert!(code.contains("pub use r#mod as modulo;"), "{code}"); + } + #[test] fn top_level_qualified_alias_preserves_target_path() { let code = generate( diff --git a/src/backend/ir/emit/decls/mod.rs b/src/backend/ir/emit/decls/mod.rs index 7db988e9b..29ff6edc0 100644 --- a/src/backend/ir/emit/decls/mod.rs +++ b/src/backend/ir/emit/decls/mod.rs @@ -21,7 +21,7 @@ mod mutation_scan; mod structures; use proc_macro2::{Literal, TokenStream}; -use quote::{format_ident, quote}; +use quote::quote; use incan_core::lang::stdlib; @@ -61,7 +61,7 @@ impl<'a> IrEmitter<'a> { interop_edges: _, } => { let vis = self.emit_visibility(visibility); - let name_ident = format_ident!("{}", name); + let name_ident = Self::rust_ident(name); let ty_tokens = self.emit_type(ty); let generics = self.emit_type_params(type_params); Ok(quote! { @@ -76,7 +76,7 @@ impl<'a> IrEmitter<'a> { target_qualifier, } => { let vis = self.emit_visibility(visibility); - let name_ident = format_ident!("{}", name); + let name_ident = Self::rust_ident(name); let target = self.emit_symbol_alias_target_path(target_origin.as_ref(), target_qualifier.as_ref(), target_path); Ok(quote! { @@ -145,7 +145,7 @@ impl<'a> IrEmitter<'a> { self.validate_const_emittable(name, ty, value)?; let vis = self.emit_visibility(visibility); - let name_ident = format_ident!("{}", name); + let name_ident = Self::rust_ident(name); let ty_tokens = self.emit_type(ty); let value_tokens = self.emit_const_value_for_type(ty, value)?; @@ -352,7 +352,7 @@ impl<'a> IrEmitter<'a> { let target_segments = target_path .iter() .map(|segment| { - let ident = format_ident!("{}", segment); + let ident = Self::rust_ident(segment); quote! { #ident } }) .collect::>(); @@ -362,7 +362,7 @@ impl<'a> IrEmitter<'a> { let target_segments = target_path .iter() .map(|segment| { - let ident = format_ident!("{}", segment); + let ident = Self::rust_ident(segment); quote! { #ident } }) .collect::>(); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index df8d84fed..81c26e198 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4407,6 +4407,39 @@ pub def main_value() -> int: Ok(()) } + #[test] + fn test_keyword_named_public_alias_compiles_issue669() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path().join("keyword_named_public_alias_repro"); + fs::create_dir_all(&project_root)?; + fs::write( + project_root.join("test_keyword_alias_probe.incn"), + r#" +pub def modulo_value(value: int) -> int: + return value + +pub mod = alias modulo_value + + +def test_keyword_alias_probe__can_call_alias() -> None: + assert mod(7) == 7, "keyword alias should call the implementation" +"#, + )?; + + let output = incan_command() + .args(["test", "test_keyword_alias_probe.incn"]) + .current_dir(&project_root) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "expected keyword-named public alias test project to pass for #669.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn test_issue562_type_alias_dict_and_union_surfaces_compile_and_run() -> Result<(), Box> { let output = incan_command() From bef22b15ae0bc2d8424d727c6f3e921034ad8dce Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 20:54:32 +0200 Subject: [PATCH 34/58] bugfix - materialize static receiver method args (#671) (#673) --- src/backend/ir/emit/expressions/methods.rs | 101 +++++++++++++++++++-- tests/integration_tests.rs | 69 +++++++++++++- 2 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index 0482f1c64..c1de65f03 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -7,9 +7,10 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use super::super::super::FunctionSignature; +use super::super::super::decl::FunctionParam; use super::super::super::expr::{ - CollectionMethodKind, InternalMethodKind, IrCallArg, IrExprKind, IrMethodDispatch, MethodCallArgPolicy, MethodKind, - TypedExpr, VarAccess, VarRefKind, + CollectionMethodKind, InternalMethodKind, IrCallArg, IrCallArgKind, IrExprKind, IrMethodDispatch, + MethodCallArgPolicy, MethodKind, TypedExpr, VarAccess, VarRefKind, }; use super::super::super::ownership::{ ArgumentPassingPlan, RegularMethodArgumentContext, ValueUseSite, regular_method_argument_use_site, @@ -560,24 +561,38 @@ impl<'a> IrEmitter<'a> { /// Materialize method-call arguments before entering a static storage lock. /// /// This prevents lock reentry when argument expressions also read/write static-backed values. - fn materialize_storage_rooted_args( + fn materialize_storage_rooted_args<'site>( &self, args: &[IrCallArg], + callable_signature: Option<&'site FunctionSignature>, + base_use_site: ValueUseSite<'site>, ) -> Result<(Vec, Vec), EmitError> { let mut bindings = Vec::with_capacity(args.len()); let mut rewritten = Vec::with_capacity(args.len()); for (idx, arg) in args.iter().enumerate() { let name = format!("__incan_static_arg_{idx}"); let ident = format_ident!("{}", name); - let emitted = self.emit_expr(&arg.expr)?; - bindings.push(quote! { let #ident = #emitted; }); + let param = Self::signature_param_for_original_call_arg(args, idx, callable_signature); + let materialize_site = Self::storage_arg_materialization_use_site(base_use_site, param); + let emitted = self.emit_expr_for_use(&arg.expr, materialize_site)?; + let mutable = + param.is_some_and(|param| matches!(param.mutability, super::super::super::types::Mutability::Mutable)); + let binding = if mutable { + quote! { let mut #ident = #emitted; } + } else { + quote! { let #ident = #emitted; } + }; + bindings.push(binding); + let rewritten_ty = param + .map(|param| param.ty.clone()) + .unwrap_or_else(|| arg.expr.ty.clone()); let rewritten_expr = TypedExpr::new( IrExprKind::Var { name, - access: VarAccess::Read, + access: VarAccess::Move, ref_kind: VarRefKind::Value, }, - arg.expr.ty.clone(), + rewritten_ty, ) .with_ownership(arg.expr.ownership) .with_span(arg.expr.span); @@ -590,6 +605,48 @@ impl<'a> IrEmitter<'a> { Ok((bindings, rewritten)) } + /// Return the callable parameter matched by one original call argument before storage-lock materialization. + fn signature_param_for_original_call_arg<'sig>( + args: &[IrCallArg], + idx: usize, + callable_signature: Option<&'sig FunctionSignature>, + ) -> Option<&'sig FunctionParam> { + let signature = callable_signature?; + let arg = args.get(idx)?; + if matches!(arg.kind, IrCallArgKind::PositionalUnpack | IrCallArgKind::KeywordUnpack) { + return None; + } + if let Some(name) = arg.name.as_deref() { + return signature.params.iter().find(|param| param.name == name); + } + let positional_idx = args + .iter() + .take(idx) + .filter(|arg| arg.name.is_none() && matches!(arg.kind, IrCallArgKind::Positional)) + .count(); + signature.params.get(positional_idx) + } + + /// Pick the use-site plan used when evaluating one storage-rooted method argument before taking the storage lock. + fn storage_arg_materialization_use_site<'site>( + base_use_site: ValueUseSite<'site>, + param: Option<&'site FunctionParam>, + ) -> ValueUseSite<'site> { + match (base_use_site, param) { + (ValueUseSite::IncanCallArg { in_return, .. }, Some(param)) => ValueUseSite::IncanCallArg { + target_ty: Some(¶m.ty), + callee_param: Some(param), + in_return, + }, + (ValueUseSite::ExternalCallArg { .. }, Some(param)) | (ValueUseSite::MethodArg, Some(param)) => { + ValueUseSite::ExternalCallArg { + target_ty: Some(¶m.ty), + } + } + (site, _) => site, + } + } + /// Strip reference wrappers from a receiver type before builtin-family or ownership-sensitive dispatch. /// /// Method emission cares about the underlying receiver family (`Dict`, `Struct`, `Trait`, ...) rather than whether @@ -682,7 +739,8 @@ impl<'a> IrEmitter<'a> { args: &[IrCallArg], ) -> Result { if Self::expr_is_storage_rooted(receiver) { - let (arg_bindings, rewritten_args) = self.materialize_storage_rooted_args(args)?; + let (arg_bindings, rewritten_args) = + self.materialize_storage_rooted_args(args, None, ValueUseSite::MethodArg)?; if matches!(kind, MethodKind::Collection(CollectionMethodKind::Get)) { let rewritten_receiver = Self::rewrite_storage_root_expr(receiver, "__incan_static_value"); let arg_exprs: Vec = rewritten_args.iter().map(|a| a.expr.clone()).collect(); @@ -819,13 +877,38 @@ impl<'a> IrEmitter<'a> { result_use_site: Option>, ) -> Result { if Self::expr_is_storage_rooted(receiver) { - let (arg_bindings, rewritten_args) = self.materialize_storage_rooted_args(args)?; let use_mut = !matches!(arg_policy, MethodCallArgPolicy::PreserveShape); let rewritten_receiver = if use_mut { Self::rewrite_storage_root_expr_for_mut(receiver, "__incan_static_value") } else { Self::rewrite_storage_root_expr(receiver, "__incan_static_value") }; + let in_return = *self.in_return_context.borrow(); + let receiver_ref_kind = match &rewritten_receiver.kind { + IrExprKind::Var { ref_kind, .. } => Some(*ref_kind), + _ => None, + }; + let has_incan_method_signature = self + .method_signature_for_receiver(&rewritten_receiver.ty, method) + .is_some(); + let preserve_lookup_arg_shape = matches!(arg_policy, MethodCallArgPolicy::PreserveShape) + || rust_collection_family_for_ir_type(&rewritten_receiver.ty) + .is_some_and(|family| family.preserves_lookup_arg_shape(method)); + let rusttype_alias_receiver = self.is_rusttype_alias_receiver(&rewritten_receiver.ty); + let base_use_site = regular_method_argument_use_site( + RegularMethodArgumentContext { + arg_policy, + receiver_ref_kind, + has_incan_method_signature, + is_incan_owned_nominal_receiver: self.is_incan_owned_nominal_receiver(&rewritten_receiver.ty), + is_rusttype_alias_receiver: rusttype_alias_receiver, + preserves_lookup_arg_shape: preserve_lookup_arg_shape, + in_return, + }, + None, + ); + let (arg_bindings, rewritten_args) = + self.materialize_storage_rooted_args(args, callable_signature, base_use_site)?; let inner = self.emit_method_call_expr_with_result_use( &rewritten_receiver, method, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 81c26e198..e566fbe07 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8782,13 +8782,80 @@ def main() -> None: ); assert!( generated.contains(".with_mut(|__incan_static_value|") - && generated.contains("__incan_static_value.add(__incan_static_arg_0.to_string())"), + && (generated.contains("let __incan_static_arg_0 = \"instance\".to_string();") + || generated.contains("let __incan_static_arg_0 = \"instance\".into();")) + && generated.contains("__incan_static_value.add(__incan_static_arg_0)"), "static registry receiver should lower through static storage access:\n{}", generated, ); Ok(()) } + #[test] + fn build_lib_imported_static_decorator_receiver_materializes_string_arg_issue671() + -> Result<(), Box> { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "imported_static_decorator_receiver" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + src_dir.join("probe_registry.incn"), + r#" +@derive(Clone) +pub class ProbeRegistry: + @staticmethod + def new() -> Self: + return ProbeRegistry() + + def add[F](mut self, name: str, value: int) -> (F) -> F: + return (func) => func + + +pub static PROBE_REGISTRY: ProbeRegistry = ProbeRegistry.new() +"#, + )?; + std::fs::write( + src_dir.join("probe_decorated.incn"), + r#" +from probe_registry import PROBE_REGISTRY + +@PROBE_REGISTRY.add("decorated", 1) +pub def decorated(value: int) -> int: + return value +"#, + )?; + std::fs::write(src_dir.join("lib.incn"), "pub from probe_decorated import decorated\n")?; + + let output = incan_command() + .args(["build", "--lib"]) + .current_dir(&*dir) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected imported static decorator receiver project to build for #671.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + + let generated = std::fs::read_to_string(dir.join("target/lib/src/probe_decorated.rs"))?; + assert!( + (generated.contains("let __incan_static_arg_0 = \"decorated\".into();") + || generated.contains("let __incan_static_arg_0 = \"decorated\".to_string();")) + && !generated.contains("__incan_static_arg_0.clone()"), + "imported static decorator string argument should materialize as owned String:\n{}", + generated, + ); + Ok(()) + } + #[test] fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { let dir = write_test_project( From 26ea83afcafeb0de7efb933d67b5234bda042a79 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 21:42:49 +0200 Subject: [PATCH 35/58] bugfix - 674 wrap storage-rooted method calls (#675) --- Cargo.lock | 18 +++--- Cargo.toml | 2 +- src/backend/ir/emit/expressions/methods.rs | 23 ++++---- tests/integration_tests.rs | 59 +++++++++++++++++++ ...t_tests__rfc052_module_static_storage.snap | 30 ++++++---- ...gen_snapshot_tests__rfc052_pub_static.snap | 22 ++++--- .../docs-site/docs/release_notes/0_3.md | 2 +- 7 files changed, 111 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1cda1d6a2..1feda441e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc12" +version = "0.3.0-rc13" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 8e18e9474..e7f950dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc12" +version = "0.3.0-rc13" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index c1de65f03..9548b8773 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -605,6 +605,14 @@ impl<'a> IrEmitter<'a> { Ok((bindings, rewritten)) } + /// Combine pre-lock argument materialization with the storage access expression as one Rust expression block. + fn storage_rooted_method_expr(arg_bindings: Vec, wrapped: TokenStream) -> TokenStream { + quote! {{ + #(#arg_bindings)* + #wrapped + }} + } + /// Return the callable parameter matched by one original call argument before storage-lock materialization. fn signature_param_for_original_call_arg<'sig>( args: &[IrCallArg], @@ -746,10 +754,7 @@ impl<'a> IrEmitter<'a> { let arg_exprs: Vec = rewritten_args.iter().map(|a| a.expr.clone()).collect(); let inner = self.emit_static_collection_get(&rewritten_receiver, &arg_exprs)?; let wrapped = self.emit_storage_with_ref(receiver, inner)?; - return Ok(quote! { - #(#arg_bindings)* - #wrapped - }); + return Ok(Self::storage_rooted_method_expr(arg_bindings, wrapped)); } let use_mut = super::method_kind_uses_mutable_receiver(kind); @@ -764,10 +769,7 @@ impl<'a> IrEmitter<'a> { } else { self.emit_storage_with_ref(receiver, inner) }?; - return Ok(quote! { - #(#arg_bindings)* - #wrapped - }); + return Ok(Self::storage_rooted_method_expr(arg_bindings, wrapped)); } let r0 = self.emit_expr(receiver)?; @@ -924,10 +926,7 @@ impl<'a> IrEmitter<'a> { } else { self.emit_storage_with_ref(receiver, inner) }?; - return Ok(quote! { - #(#arg_bindings)* - #wrapped - }); + return Ok(Self::storage_rooted_method_expr(arg_bindings, wrapped)); } let inferred_receiver = self.receiver_with_known_field_type(receiver); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index e566fbe07..c78e433c8 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8856,6 +8856,65 @@ pub def decorated(value: int) -> int: Ok(()) } + #[test] + fn build_static_receiver_option_model_lookup_issue674() -> Result<(), Box> { + let dir = write_test_project( + "main.incn", + r#" +@derive(Clone) +model Entry: + value: int + + +@derive(Clone) +class Registry: + entries: list[Entry] + + @staticmethod + def new() -> Self: + return Registry(entries=[Entry(value=1)]) + + def entry(self, name: str) -> Option[Entry]: + if len(self.entries) == 0: + return None + return Some(self.entries[0]) + + +static REGISTRY: Registry = Registry.new() + + +pub def lookup() -> int: + match REGISTRY.entry("decorated"): + Some(entry) => return entry.value + None => return 0 + + +def main() -> None: + println(lookup()) +"#, + ); + + let out_dir = dir.join("out"); + let output = run_incan_build(&dir.join("main.incn"), &out_dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected static receiver Option model lookup to build for #674.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + + let generated = std::fs::read_to_string(out_dir.join("src/main.rs"))?; + assert!( + generated.contains("match {\n let __incan_static_arg_0 = \"decorated\".to_string();") + || generated.contains("match {\n let __incan_static_arg_0 = \"decorated\".into();"), + "static receiver match scrutinee should materialize args inside an expression block:\n{}", + generated, + ); + Ok(()) + } + #[test] fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { let dir = write_test_project( diff --git a/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap b/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap index b0381fcc2..0af4680e9 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap @@ -64,25 +64,29 @@ fn main() { .with_mut(|__incan_static_value| { *__incan_static_value = __incan_static_rhs.into(); }); - let __incan_static_arg_0 = { - __incan_init_module_statics(); - COUNTER.get() - }; { - __incan_init_module_statics(); - ITEMS - .with_mut(|__incan_static_value| { - __incan_static_value.push(__incan_static_arg_0) - }) + let __incan_static_arg_0 = { + __incan_init_module_statics(); + COUNTER.get() + }; + { + __incan_init_module_statics(); + ITEMS + .with_mut(|__incan_static_value| { + __incan_static_value.push(__incan_static_arg_0) + }) + } }; let mut live = { __incan_init_module_statics(); incan_stdlib::storage::StaticBinding::from_static(&ITEMS) }; - let __incan_static_arg_0 = 4; - live.with_mut(|__incan_static_value| { - __incan_static_value.push(__incan_static_arg_0) - }); + { + let __incan_static_arg_0 = 4; + live.with_mut(|__incan_static_value| { + __incan_static_value.push(__incan_static_arg_0) + }) + }; println!("{}", { __incan_init_module_statics(); COUNTER.get() }); println!( "{}", ::std::convert::identity({ __incan_init_module_statics(); ITEMS.get() } diff --git a/tests/snapshots/codegen_snapshot_tests__rfc052_pub_static.snap b/tests/snapshots/codegen_snapshot_tests__rfc052_pub_static.snap index 06768e3ba..1a867ebb0 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc052_pub_static.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc052_pub_static.snap @@ -20,17 +20,21 @@ fn main() { } }), ); - let __incan_static_arg_0 = 1; { - SHARED_ITEMS - .with_mut(|__incan_static_value| { - __incan_static_value.push(__incan_static_arg_0) - }) + let __incan_static_arg_0 = 1; + { + SHARED_ITEMS + .with_mut(|__incan_static_value| { + __incan_static_value.push(__incan_static_arg_0) + }) + } }; let mut live = { incan_stdlib::storage::StaticBinding::from_static(&SHARED_ITEMS) }; - let __incan_static_arg_0 = 2; - live.with_mut(|__incan_static_value| { - __incan_static_value.push(__incan_static_arg_0) - }); + { + let __incan_static_arg_0 = 2; + live.with_mut(|__incan_static_value| { + __incan_static_value.push(__incan_static_arg_0) + }) + }; println!("{}", ::std::convert::identity({ SHARED_ITEMS.get() } .len() as i64)); } diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 6bf8392d2..3f7b170b0 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, lowercase exported static imports, and generated script/test Cargo manifests that omit unreachable package-level Rust dependencies (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659, #665). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, lowercase exported static imports, generated script/test Cargo manifests that omit unreachable package-level Rust dependencies, keyword-named public symbols, imported static decorator string arguments, and storage-rooted method calls used as match scrutinees (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659, #665, #669, #671, #674). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 37896346c5cab302e8866fe73aeb8c1f94ef10e0 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 24 May 2026 23:38:11 +0200 Subject: [PATCH 36/58] bugfix - preserve per-file inline test module context (#676) (#678) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/codegen.rs | 18 +- src/cli/test_runner/execution.rs | 538 ++++++++++++++---- tests/integration_tests.rs | 179 ++++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 6 files changed, 626 insertions(+), 131 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1feda441e..2ad2036a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc13" +version = "0.3.0-rc14" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index e7f950dde..3d2eed76e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc13" +version = "0.3.0-rc14" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index c85bda2bc..1bfd09e2d 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -165,6 +165,8 @@ pub struct IrCodegen<'a> { strict_generated_lints: bool, /// Private IR items called by generated code that is appended outside normal IR emission. externally_reachable_items: HashSet, + /// Private dependency-module IR items called by generated code appended inside that module. + externally_reachable_items_by_module: HashMap, HashSet>, /// Public serialized value-enum identities for library builds, keyed by source identity (`module.Type`). public_ordinal_type_identities: HashMap, /// Whether non-stdlib dependency modules keep public items that are not otherwise reachable. @@ -189,6 +191,7 @@ impl<'a> IrCodegen<'a> { library_manifest_index: None, strict_generated_lints: false, externally_reachable_items: HashSet::new(), + externally_reachable_items_by_module: HashMap::new(), public_ordinal_type_identities: HashMap::new(), preserve_dependency_public_items: true, #[cfg(feature = "rust_inspect")] @@ -206,6 +209,11 @@ impl<'a> IrCodegen<'a> { self.externally_reachable_items = names; } + /// Set private generated Rust entrypoints called by code injected into dependency modules. + pub fn set_externally_reachable_items_by_module(&mut self, names: HashMap, HashSet>) { + self.externally_reachable_items_by_module = names; + } + /// Set public serialized value-enum identities for library emission. pub fn set_public_ordinal_type_identities(&mut self, identities: HashMap) { self.public_ordinal_type_identities = identities; @@ -761,7 +769,10 @@ impl<'a> IrCodegen<'a> { let mut modules = HashMap::new(); for (name, module_path, ir) in &lowered_modules { - let reachable_items = dependency_reachable_items.get(module_path).cloned().unwrap_or_default(); + let mut reachable_items = dependency_reachable_items.get(module_path).cloned().unwrap_or_default(); + if let Some(injected_items) = self.externally_reachable_items_by_module.get(module_path) { + reachable_items.extend(injected_items.iter().cloned()); + } let preserve_public_items = should_preserve_dependency_public_items(module_path, self.preserve_dependency_public_items); let use_emit_service = env::var("INCAN_EMIT_SERVICE").ok().as_deref() == Some("1"); @@ -949,7 +960,10 @@ impl<'a> IrCodegen<'a> { let mut modules = HashMap::new(); for (path, ir) in &lowered_modules { - let reachable_items = dependency_reachable_items.get(path).cloned().unwrap_or_default(); + let mut reachable_items = dependency_reachable_items.get(path).cloned().unwrap_or_default(); + if let Some(injected_items) = self.externally_reachable_items_by_module.get(path) { + reachable_items.extend(injected_items.iter().cloned()); + } let preserve_public_items = should_preserve_dependency_public_items(path, self.preserve_dependency_public_items); let use_emit_service = env::var("INCAN_EMIT_SERVICE").ok().as_deref() == Some("1"); diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index b13650407..200443853 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -23,6 +23,7 @@ use crate::frontend::ast::{ }; use crate::frontend::decorator_resolution; use crate::frontend::library_manifest_index::LibraryManifestIndex; +use crate::frontend::module::logical_module_segments_from_file; use crate::frontend::testing_markers::{TestingMarkerKind, load_testing_marker_semantics, resolve_testing_marker_kind}; use crate::frontend::vocab_desugar_pass; use crate::frontend::{lexer, parser}; @@ -33,7 +34,7 @@ use sha2::{Digest, Sha256}; use super::module_graph::collect_source_modules_for_test; use super::types::{FixtureScope, TestInfo, TestResult}; -/// Generated `#[cfg(test)]` module that wraps Incan test functions as Rust `#[test]` cases, one `cargo test` per file. +/// Generated `#[cfg(test)]` module that wraps Incan test functions as Rust `#[test]` cases. const INCAN_FILE_TEST_MOD: &str = "__incan_file_tests"; #[derive(Debug, Clone, Copy, Default)] @@ -352,6 +353,226 @@ fn partition_collision_free_file_groups( .collect() } +/// Parse each source file in a generated test batch independently, then merge declarations for the shared harness. +/// +/// The parser's `module tests:` cardinality rule is intentionally per source file. A worker batch may contain several +/// files, so the runner must not concatenate source text and ask the parser to treat that batch as one file. +fn parse_test_batch_sources( + batch_sources: &[(PathBuf, String)], + library_imported_vocab: Option<&parser::ImportedLibraryVocab>, + library_imported_dsl_surfaces: Option<&parser::ImportedLibraryDslSurfaces>, +) -> Result { + let mut declarations = Vec::new(); + let mut warnings = Vec::new(); + let mut rust_module_path = None; + let source_path = batch_sources + .first() + .map(|(path, _)| path.to_string_lossy().to_string()); + + for (path, source) in batch_sources { + let tokens = lexer::lex(source).map_err(|e| format!("Lexer error in {}: {:?}", path.display(), e))?; + let parsed = parser::parse_with_context_and_surfaces( + &tokens, + Some(path.to_string_lossy().as_ref()), + library_imported_vocab, + library_imported_dsl_surfaces, + ) + .map_err(|e| format!("Parser error in {}: {:?}", path.display(), e))?; + if let Some(module_path) = parsed.rust_module_path { + if rust_module_path.is_some() { + return Err(format!( + "Parser error in {}: duplicate rust.module() directives in test batch", + path.display() + )); + } + rust_module_path = Some(module_path); + } + warnings.extend(parsed.warnings); + declarations.extend(parsed.declarations); + } + + Ok(Program { + declarations, + source_path, + rust_module_path, + warnings, + }) +} + +struct InlineSourceModuleBatch { + ast: Program, + source_modules: Vec, + harnesses: Vec, +} + +fn empty_test_batch_root(first_path: &Path) -> Program { + Program { + declarations: Vec::new(), + source_path: Some(first_path.to_string_lossy().to_string()), + rust_module_path: None, + warnings: Vec::new(), + } +} + +fn program_has_inline_test_module(program: &Program) -> bool { + program + .declarations + .iter() + .any(|decl| matches!(decl.node, Declaration::TestModule(_))) +} + +fn prepare_runner_program(ast: &Program) -> Result<(Program, HashMap), String> { + let mut runner_ast = ast_with_inline_test_declarations(ast); + normalize_runner_assert_statements(&mut runner_ast); + prune_shadowed_fixture_declarations(&mut runner_ast); + dedupe_import_declarations(&mut runner_ast); + let mut fixtures = collect_fixture_execution_info(&runner_ast, &HashMap::new()); + let fixture_teardowns = split_yield_fixture_declarations(&mut runner_ast)?; + apply_fixture_teardowns(&mut fixtures, &fixture_teardowns); + Ok((runner_ast, fixtures)) +} + +fn parse_and_desugar_test_sources( + batch_sources: &[(PathBuf, String)], + library_manifest_index: &LibraryManifestIndex, + library_imported_vocab: &parser::ImportedLibraryVocab, + library_imported_dsl_surfaces: &parser::ImportedLibraryDslSurfaces, +) -> Result { + let mut ast = parse_test_batch_sources( + batch_sources, + Some(library_imported_vocab), + Some(library_imported_dsl_surfaces), + )?; + let path_display = batch_sources + .last() + .or_else(|| batch_sources.first()) + .map(|(path, _)| path.to_string_lossy()); + if let Err(errors) = + vocab_desugar_pass::desugar_program_vocab_blocks(&mut ast, path_display.as_deref(), library_manifest_index) + { + return Err(format!("Vocab desugar error: {:?}", errors)); + } + Ok(ast) +} + +fn module_name_for_segments(segments: &[String]) -> String { + segments.join("_") +} + +fn read_conftest_sources(paths: &[PathBuf]) -> Result, String> { + let mut sources = Vec::new(); + for path in paths { + let source = + fs::read_to_string(path).map_err(|err| format!("Failed to read conftest {}: {}", path.display(), err))?; + sources.push((path.clone(), source)); + } + Ok(sources) +} + +fn prepare_inline_source_module_batch( + sources_by_file: &[(PathBuf, String)], + conftest_files_by_file: &HashMap>, + source_root: &Path, + library_manifest_index: &LibraryManifestIndex, + library_imported_vocab: &parser::ImportedLibraryVocab, + library_imported_dsl_surfaces: &parser::ImportedLibraryDslSurfaces, +) -> Result, String> { + if sources_by_file.len() <= 1 { + return Ok(None); + } + + let mut source_modules = Vec::new(); + let mut harnesses = Vec::new(); + let mut batch_files = HashSet::new(); + let mut seen_module_paths = HashSet::new(); + let mut parsed_sources = Vec::new(); + + for (path, source) in sources_by_file { + let Some(module_path) = logical_module_segments_from_file(source_root, path) else { + return Ok(None); + }; + let ast = parse_and_desugar_test_sources( + &[(path.clone(), source.clone())], + library_manifest_index, + library_imported_vocab, + library_imported_dsl_surfaces, + )?; + if !program_has_inline_test_module(&ast) { + return Ok(None); + } + batch_files.insert(canonical_path_for_cache_key(path)); + parsed_sources.push((path.clone(), source.clone(), module_path, ast)); + } + + let mut deferred_dependencies = Vec::new(); + for (path, source, module_path, ast) in parsed_sources { + let mut module_sources = + read_conftest_sources(conftest_files_by_file.get(&path).map(Vec::as_slice).unwrap_or(&[]))?; + module_sources.push((path.clone(), source.clone())); + let combined_ast = if module_sources.len() == 1 { + ast + } else { + parse_and_desugar_test_sources( + &module_sources, + library_manifest_index, + library_imported_vocab, + library_imported_dsl_surfaces, + )? + }; + let (runner_ast, fixtures) = prepare_runner_program(&combined_ast)?; + let module_name = module_name_for_segments(&module_path); + let module_source = module_sources + .iter() + .map(|(_, source)| source.as_str()) + .collect::>() + .join("\n"); + + for dependency in collect_source_modules_for_test( + &runner_ast, + source_root, + Some(library_imported_vocab), + Some(library_imported_dsl_surfaces), + Some(library_manifest_index), + )? { + deferred_dependencies.push(dependency); + } + + if seen_module_paths.insert(module_path.clone()) { + source_modules.push(ParsedModule { + name: module_name, + path_segments: module_path.clone(), + file_path: path.clone(), + source: module_source, + ast: runner_ast, + }); + } + harnesses.push(PreparedModuleHarness { + file_path: path, + module_path, + fixtures, + }); + } + + for dependency in deferred_dependencies { + if batch_files.contains(&canonical_path_for_cache_key(&dependency.file_path)) { + continue; + } + if seen_module_paths.insert(dependency.path_segments.clone()) { + source_modules.push(dependency); + } + } + + let first_path = sources_by_file + .first() + .map(|(path, _)| path.as_path()) + .unwrap_or_else(|| Path::new(".")); + Ok(Some(InlineSourceModuleBatch { + ast: empty_test_batch_root(first_path), + source_modules, + harnesses, + })) +} + /// Resolve a dotted expression path using local import aliases collected from the runner AST. fn resolved_expr_path(expr: &Spanned, aliases: &HashMap>) -> Option> { match &expr.node { @@ -494,6 +715,7 @@ pub(super) struct PreparedTestFile { pub library_manifest_index: LibraryManifestIndex, pub ast: Program, pub fixtures: HashMap, + pub module_harnesses: Vec, pub source_modules: Vec, pub project_root: PathBuf, pub resolved: ResolvedDependencies, @@ -504,6 +726,13 @@ pub(super) struct PreparedTestFile { pub rust_inspect_manifest_dir: PathBuf, } +/// Runner harness metadata for one inline source file emitted as its own Rust module. +pub(super) struct PreparedModuleHarness { + pub file_path: PathBuf, + pub module_path: Vec, + pub fixtures: HashMap, +} + /// Parsed dependency context for the project lock-validation entry point, shared across test batches in one session. struct PreparedLockEntry { modules: Vec, @@ -1334,6 +1563,30 @@ fn test_runner_stdlib_features( features.into_iter().collect() } +fn test_runner_stdlib_features_for_batch( + base: &[String], + tests: &[TestInfo], + fixtures: &HashMap, + module_harnesses: &[PreparedModuleHarness], +) -> Vec { + if module_harnesses.is_empty() { + return test_runner_stdlib_features(base, tests, fixtures); + } + + let mut features = base.iter().cloned().collect::>(); + if module_harnesses.iter().any(|harness| { + let file_tests = tests + .iter() + .filter(|test| test.file_path == harness.file_path) + .cloned() + .collect::>(); + harness_needs_async_runtime(&file_tests, &harness.fixtures) + }) { + features.insert("async".to_string()); + } + features.into_iter().collect() +} + /// Generate an expression that calls a fixture, recursively filling fixture dependencies. fn fixture_arg( name: &str, @@ -1663,6 +1916,17 @@ fn inject_file_test_harness( tests: &[TestInfo], project_root: &Path, fixtures: &HashMap, +) -> String { + let test_indices = (0..tests.len()).collect::>(); + inject_file_test_harness_with_indices(rust_code, tests, &test_indices, project_root, fixtures) +} + +fn inject_file_test_harness_with_indices( + rust_code: &str, + tests: &[TestInfo], + test_indices: &[usize], + project_root: &Path, + fixtures: &HashMap, ) -> String { let mut out = rust_code.to_string(); let project_root_literal = project_root.to_string_lossy().to_string(); @@ -1738,7 +2002,7 @@ fn inject_file_test_harness( ); } let teardown_fixtures = ordered_teardown_fixtures(tests, fixtures); - for (index, t) in tests.iter().enumerate() { + for (index, t) in test_indices.iter().copied().zip(tests.iter()) { let fname = harness_fn_name(t, index); let call = harness_call(t, index, fixtures); out.push_str(" #[test]\n fn "); @@ -2311,10 +2575,11 @@ fn preheat_status_label(status: HarnessPreheatStatus) -> &'static str { } } -/// Run every collected test in `tests` that lives in the same `.incn` file with **one** `cargo test` invocation (#271). +/// Run one collected test execution unit with a single generated Cargo/libtest invocation. /// -/// Returns an empty vector when `tests` is empty. Otherwise every entry must share the same [`TestInfo::file_path`]. -/// Skip/xfail handling stays in [`super::run_tests`]. +/// Ordinary test files still use the root harness shape. Cross-file inline source batches emit each tested source file +/// as its own Rust module and inject the harness beside the file-local declarations, so imports and public declarations +/// from different source files do not share one synthetic Rust scope. #[allow(clippy::too_many_arguments)] pub(super) fn run_file_tests_batch( tests: &[TestInfo], @@ -2335,6 +2600,7 @@ pub(super) fn run_file_tests_batch( // ---- Context: load test source, discover manifest, parse and vocab-desugar the test file ---- let mut source_parts = Vec::new(); + let mut batch_parse_sources = Vec::new(); let mut sources_by_file = Vec::new(); let mut seen_conftests = BTreeSet::new(); let mut seen_files = BTreeSet::new(); @@ -2348,7 +2614,10 @@ pub(super) fn run_file_tests_batch( continue; } match fs::read_to_string(conftest) { - Ok(source) => source_parts.push(source), + Ok(source) => { + source_parts.push(source.clone()); + batch_parse_sources.push((conftest.clone(), source)); + } Err(err) => { let message = format!("Failed to read conftest {}: {}", conftest.display(), err); return tests @@ -2362,6 +2631,7 @@ pub(super) fn run_file_tests_batch( match fs::read_to_string(&test.file_path) { Ok(source) => { sources_by_file.push((test.file_path.clone(), source.clone())); + batch_parse_sources.push((test.file_path.clone(), source.clone())); source_parts.push(source); } Err(e) => { @@ -2400,84 +2670,23 @@ pub(super) fn run_file_tests_batch( let library_imported_vocab = library_manifest_index.library_imported_vocab(); let library_imported_dsl_surfaces = library_manifest_index.library_imported_dsl_surfaces(); - if batch_has_cross_file_top_level_collision(&sources_by_file, Some(&library_imported_vocab)) { - let mut split_results = Vec::new(); - for file_group in partition_collision_free_file_groups(&sources_by_file, Some(&library_imported_vocab)) { - let file_group = file_group.into_iter().collect::>(); - let file_tests = tests - .iter() - .filter(|test| file_group.contains(&test.file_path)) - .cloned() - .collect::>(); - split_results.extend(run_file_tests_batch( - &file_tests, - conftest_files_by_file, - prep_cache, - cargo_policy, - cargo_features, - cargo_no_default_features, - cargo_all_features, - options, - )); - } - return split_results; - } - - let tokens = match lexer::lex(&source) { - Ok(t) => t, - Err(e) => { - return tests - .iter() - .map(|t| { - ( - t.clone(), - TestResult::Failed(start.elapsed(), format!("Lexer error: {:?}", e)), - ) - }) - .collect(); - } - }; + // ---- Context: resolve project paths and collect transitive Incan modules for the test ---- + let project_root = manifest + .as_ref() + .map(|m| m.project_root().to_path_buf()) + .unwrap_or_else(|| infer_project_root_without_manifest(&first.file_path)); + let project_root = absolute_project_root(&project_root); + let source_root = common::resolve_source_root(&project_root, manifest.as_ref()); - let path_display = first.file_path.to_string_lossy(); - let mut ast = match parser::parse_with_context_and_surfaces( - &tokens, - Some(path_display.as_ref()), - Some(&library_imported_vocab), - Some(&library_imported_dsl_surfaces), + let inline_module_batch = match prepare_inline_source_module_batch( + &sources_by_file, + conftest_files_by_file, + &source_root, + &library_manifest_index, + &library_imported_vocab, + &library_imported_dsl_surfaces, ) { - Ok(a) => a, - Err(e) => { - return tests - .iter() - .map(|t| { - ( - t.clone(), - TestResult::Failed(start.elapsed(), format!("Parser error: {:?}", e)), - ) - }) - .collect(); - } - }; - if let Err(errors) = - vocab_desugar_pass::desugar_program_vocab_blocks(&mut ast, Some(path_display.as_ref()), &library_manifest_index) - { - return tests - .iter() - .map(|t| { - ( - t.clone(), - TestResult::Failed(start.elapsed(), format!("Vocab desugar error: {:?}", errors)), - ) - }) - .collect(); - } - let mut runner_ast = ast_with_inline_test_declarations(&ast); - normalize_runner_assert_statements(&mut runner_ast); - prune_shadowed_fixture_declarations(&mut runner_ast); - dedupe_import_declarations(&mut runner_ast); - let mut fixtures = collect_fixture_execution_info(&runner_ast, &HashMap::new()); - let fixture_teardowns = match split_yield_fixture_declarations(&mut runner_ast) { - Ok(teardowns) => teardowns, + Ok(batch) => batch, Err(message) => { return tests .iter() @@ -2485,7 +2694,78 @@ pub(super) fn run_file_tests_batch( .collect(); } }; - apply_fixture_teardowns(&mut fixtures, &fixture_teardowns); + + let (runner_ast, fixtures, source_modules, module_harnesses) = if let Some(batch) = inline_module_batch { + (batch.ast, HashMap::new(), batch.source_modules, batch.harnesses) + } else { + if batch_has_cross_file_top_level_collision(&sources_by_file, Some(&library_imported_vocab)) { + let mut split_results = Vec::new(); + for file_group in partition_collision_free_file_groups(&sources_by_file, Some(&library_imported_vocab)) { + let file_group = file_group.into_iter().collect::>(); + let file_tests = tests + .iter() + .filter(|test| file_group.contains(&test.file_path)) + .cloned() + .collect::>(); + split_results.extend(run_file_tests_batch( + &file_tests, + conftest_files_by_file, + prep_cache, + cargo_policy, + cargo_features, + cargo_no_default_features, + cargo_all_features, + options, + )); + } + return split_results; + } + + let ast = match parse_and_desugar_test_sources( + &batch_parse_sources, + &library_manifest_index, + &library_imported_vocab, + &library_imported_dsl_surfaces, + ) { + Ok(ast) => ast, + Err(message) => { + return tests + .iter() + .map(|t| (t.clone(), TestResult::Failed(start.elapsed(), message.clone()))) + .collect(); + } + }; + let (runner_ast, fixtures) = match prepare_runner_program(&ast) { + Ok(prepared) => prepared, + Err(message) => { + return tests + .iter() + .map(|t| (t.clone(), TestResult::Failed(start.elapsed(), message.clone()))) + .collect(); + } + }; + let source_modules = match collect_source_modules_for_test( + &runner_ast, + &source_root, + Some(&library_imported_vocab), + Some(&library_imported_dsl_surfaces), + Some(&library_manifest_index), + ) { + Ok(m) => m, + Err(e) => { + return tests + .iter() + .map(|t| { + ( + t.clone(), + TestResult::Failed(start.elapsed(), format!("Failed to collect source modules: {}", e)), + ) + }) + .collect(); + } + }; + (runner_ast, fixtures, source_modules, Vec::new()) + }; let cargo_feature_selection = CargoFeatureSelection { cargo_features: cargo_features.to_vec(), @@ -2494,34 +2774,6 @@ pub(super) fn run_file_tests_batch( } .normalized(); - // ---- Context: resolve project paths and collect transitive Incan modules for the test ---- - let project_root = manifest - .as_ref() - .map(|m| m.project_root().to_path_buf()) - .unwrap_or_else(|| infer_project_root_without_manifest(&first.file_path)); - let project_root = absolute_project_root(&project_root); - let source_root = common::resolve_source_root(&project_root, manifest.as_ref()); - let source_modules = match collect_source_modules_for_test( - &runner_ast, - &source_root, - Some(&library_imported_vocab), - Some(&library_imported_dsl_surfaces), - Some(&library_manifest_index), - ) { - Ok(m) => m, - Err(e) => { - return tests - .iter() - .map(|t| { - ( - t.clone(), - TestResult::Failed(start.elapsed(), format!("Failed to collect source modules: {}", e)), - ) - }) - .collect(); - } - }; - // ---- Context: session prep cache — reuse deps / lock / rust-inspect when key matches ---- let cache_key = compute_test_prep_cache_key( &first.file_path, @@ -2737,6 +2989,7 @@ pub(super) fn run_file_tests_batch( library_manifest_index, ast: runner_ast, fixtures, + module_harnesses, source_modules, project_root, resolved: cargo_resolved, @@ -2764,7 +3017,26 @@ pub(super) fn run_file_tests_batch( codegen.add_module_with_path_segments(&module.name, &module.ast, module.path_segments.clone()); } let fixtures = prepared.fixtures.clone(); - codegen.set_externally_reachable_items(collect_harness_entrypoints(tests, &fixtures)); + if prepared.module_harnesses.is_empty() { + codegen.set_externally_reachable_items(collect_harness_entrypoints(tests, &fixtures)); + } else { + let reachable_by_module = prepared + .module_harnesses + .iter() + .map(|harness| { + let file_tests = tests + .iter() + .filter(|test| test.file_path == harness.file_path) + .cloned() + .collect::>(); + ( + harness.module_path.clone(), + collect_harness_entrypoints(&file_tests, &harness.fixtures), + ) + }) + .collect::>(); + codegen.set_externally_reachable_items_by_module(reachable_by_module); + } let batch_file_paths = tests.iter().map(|test| test.file_path.clone()).collect::>(); let dir_suffix = file_batch_dir_suffix(&batch_file_paths); @@ -2781,10 +3053,11 @@ pub(super) fn run_file_tests_batch( let mut generator = ProjectGenerator::new(&temp_dir, &runner_crate_name, false); generator.set_package_name(Some(prepared.project_name.clone())); - generator.set_stdlib_features(test_runner_stdlib_features( + generator.set_stdlib_features(test_runner_stdlib_features_for_batch( &prepared.project_requirements.stdlib_features, tests, &fixtures, + &prepared.module_harnesses, )); generator.set_cargo_lock_payload(prepared.lock_payload.clone()); let cargo_flags = common::cargo_command_flags(cargo_policy, &cargo_feature_selection); @@ -2822,11 +3095,40 @@ pub(super) fn run_file_tests_batch( .iter() .map(|m| m.path_segments.clone()) .collect(); - let (main_code, rust_modules) = match codegen.try_generate_multi_file_nested(&prepared.ast, &module_paths) { - Ok(result) => result, - Err(e) => return gen_err(format!("Code generation error: {}", e)), - }; - let main_code = inject_file_test_harness(&main_code, tests, &prepared.project_root, &fixtures); + let (mut main_code, mut rust_modules) = + match codegen.try_generate_multi_file_nested(&prepared.ast, &module_paths) { + Ok(result) => result, + Err(e) => return gen_err(format!("Code generation error: {}", e)), + }; + if prepared.module_harnesses.is_empty() { + main_code = inject_file_test_harness(&main_code, tests, &prepared.project_root, &fixtures); + } else { + for harness in &prepared.module_harnesses { + let tests_with_indices = tests + .iter() + .enumerate() + .filter(|(_, test)| test.file_path == harness.file_path) + .collect::>(); + let file_tests = tests_with_indices + .iter() + .map(|(_, test)| (*test).clone()) + .collect::>(); + let test_indices = tests_with_indices.iter().map(|(index, _)| *index).collect::>(); + let Some(module_code) = rust_modules.get_mut(&harness.module_path) else { + return gen_err(format!( + "generated test harness module `{}` was not emitted", + harness.module_path.join(".") + )); + }; + *module_code = inject_file_test_harness_with_indices( + module_code, + &file_tests, + &test_indices, + &prepared.project_root, + &harness.fixtures, + ); + } + } match generator.generate_nested(&main_code, &rust_modules) { Ok(changed) => changed, Err(e) => return gen_err(format!("Failed to generate project: {}", e)), diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c78e433c8..59df189ba 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8915,6 +8915,185 @@ def main() -> None: Ok(()) } + #[test] + fn e2e_directory_run_preserves_per_file_inline_test_modules_issue676() -> Result<(), Box> { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "inline_directory_batch" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + src_dir.join("alpha.incn"), + r#" +const ALPHA_OFFSET: int = 10 +static alpha_runs: int = 0 + +model AlphaRecord: + value: int + label: str + +def alpha_value() -> int: + return 1 + +def alpha_record() -> AlphaRecord: + return AlphaRecord(value=alpha_value() + ALPHA_OFFSET, label="alpha") + + +module tests: + def test_alpha_value() -> None: + alpha_runs += 1 + record = alpha_record() + assert alpha_value() == 1 + assert record.value == 11 + assert record.label == "alpha" + assert alpha_runs == 1 +"#, + )?; + std::fs::write( + src_dir.join("beta.incn"), + r#" +const BETA_OFFSET: int = 20 +static beta_runs: int = 0 + +model BetaRecord: + value: int + label: str + +def beta_value() -> int: + return 2 + +def beta_record() -> BetaRecord: + return BetaRecord(value=beta_value() + BETA_OFFSET, label="beta") + + +module tests: + def test_beta_value() -> None: + beta_runs += 1 + record = beta_record() + assert beta_value() == 2 + assert record.value == 22 + assert record.label == "beta" + assert beta_runs == 1 +"#, + )?; + let functions_dir = src_dir.join("functions"); + std::fs::create_dir_all(&functions_dir)?; + std::fs::write( + functions_dir.join("columns.incn"), + r#" +const COLUMN_OFFSET: int = 30 +static column_runs: int = 0 + +model Column: + value: int + label: str + +pub def col() -> int: + return 3 + +def column() -> Column: + return Column(value=col() + COLUMN_OFFSET, label="column") + + +module tests: + def test_col() -> None: + column_runs += 1 + item = column() + assert col() == 3 + assert item.value == 33 + assert item.label == "column" + assert column_runs == 1 +"#, + )?; + std::fs::write( + functions_dir.join("uses_columns.incn"), + r#" +from functions.columns import col + +const USES_COLUMN_OFFSET: int = 40 +static uses_column_runs: int = 0 + +model UsesColumn: + value: int + label: str + +def uses_col() -> int: + return col() + 1 + +def uses_column() -> UsesColumn: + return UsesColumn(value=uses_col() + USES_COLUMN_OFFSET, label="uses-column") + + +module tests: + def test_uses_col() -> None: + uses_column_runs += 1 + item = uses_column() + assert uses_col() == 4 + assert item.value == 44 + assert item.label == "uses-column" + assert uses_column_runs == 1 +"#, + )?; + + let alpha = run_incan_test_path(&src_dir.join("alpha.incn")); + assert!( + alpha.status.success(), + "expected direct alpha inline test run to pass.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&alpha.stdout), + String::from_utf8_lossy(&alpha.stderr), + ); + let beta = run_incan_test_path(&src_dir.join("beta.incn")); + assert!( + beta.status.success(), + "expected direct beta inline test run to pass.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&beta.stdout), + String::from_utf8_lossy(&beta.stderr), + ); + let uses_columns = run_incan_test_path(&functions_dir.join("uses_columns.incn")); + assert!( + uses_columns.status.success(), + "expected direct imported inline test run to pass.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&uses_columns.stdout), + String::from_utf8_lossy(&uses_columns.stderr), + ); + + let directory = run_incan_test_path(&src_dir); + let stdout = String::from_utf8_lossy(&directory.stdout); + let stderr = String::from_utf8_lossy(&directory.stderr); + assert!( + directory.status.success(), + "expected directory inline test run to keep per-file parser context.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + stdout.contains("alpha.incn::test_alpha_value") + && stdout.contains("beta.incn::test_beta_value") + && stdout.contains("columns.incn::test_col") + && stdout.contains("uses_columns.incn::test_uses_col"), + "expected every inline source file to run from directory discovery.\nstdout:\n{}", + stdout, + ); + assert!( + !stdout.contains("Only one `module tests:` block") && !stderr.contains("Only one `module tests:` block"), + "directory batching should not report duplicate inline modules across files.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + !stderr.contains("the name `col` is defined multiple times"), + "directory batching should keep imported names inside their source module scope.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + + Ok(()) + } + #[test] fn e2e_inline_module_parametrize_markers_strict_and_timeout() -> Result<(), Box> { let dir = write_test_project( diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 3f7b170b0..243bc529b 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -52,7 +52,7 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, lowercase exported static imports, generated script/test Cargo manifests that omit unreachable package-level Rust dependencies, keyword-named public symbols, imported static decorator string arguments, and storage-rooted method calls used as match scrutinees (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659, #665, #669, #671, #674). +- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, lowercase exported static imports, generated script/test Cargo manifests that omit unreachable package-level Rust dependencies, keyword-named public symbols, imported static decorator string arguments, storage-rooted method calls used as match scrutinees, and directory inline-test batching that preserves each file's parser and import scope (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659, #665, #669, #671, #674, #676). - **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. - **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). From 28851e3336d117b59e8279e44cab128596403d4a Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 25 May 2026 05:27:00 +0200 Subject: [PATCH 37/58] bugfix - preserve decorated builtin-name calls in inline tests (#677) (#679) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/codegen.rs | 18 +- src/backend/ir/codegen/dependency_metadata.rs | 3 +- src/backend/ir/emit/expressions/methods.rs | 2 +- src/backend/ir/lower/expr/calls.rs | 5 + src/backend/ir/reference_shape.rs | 2 +- src/cli/test_runner/execution.rs | 228 +++++- .../typechecker/check_expr/calls/builtins.rs | 35 +- .../typechecker/collect/stdlib_imports.rs | 40 +- src/frontend/typechecker/helpers/symbols.rs | 36 +- src/frontend/typechecker/mod.rs | 17 +- src/frontend/typechecker/tests.rs | 63 ++ tests/integration_tests.rs | 664 ++++-------------- .../docs-site/docs/release_notes/0_3.md | 187 ++++- workspaces/docs-site/docs/roadmap.md | 11 +- 16 files changed, 674 insertions(+), 657 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ad2036a8..d13e52ee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc14" +version = "0.3.0-rc15" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 3d2eed76e..64cc95ce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc14" +version = "0.3.0-rc15" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 1bfd09e2d..6fa92cd9d 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -904,14 +904,15 @@ impl<'a> IrCodegen<'a> { // Generate module files by path let mut lowered_modules = Vec::new(); - for (name, ast, _) in &self.dependency_modules { - // Find matching path by comparing joined segments with module name - // Module name is path segments joined with "_" (e.g., "db_models") - for path in module_paths { - let path_name = path.join("_"); - if path_name != *name { - continue; - } + for (name, ast, stored_path_segments) in &self.dependency_modules { + let matching_path = if let Some(stored_path_segments) = stored_path_segments { + module_paths.iter().find(|path| *path == stored_path_segments) + } else { + // Legacy callers may still register only a flat module name. Prefer explicit path segments when they + // exist because distinct paths such as `a_b` and `a/b` share the same underscore-joined fallback. + module_paths.iter().find(|path| path.join("_") == *name) + }; + if let Some(path) = matching_path { let module_type_info = { use crate::frontend::typechecker::TypeChecker; let mut tc = TypeChecker::new(); @@ -931,7 +932,6 @@ impl<'a> IrCodegen<'a> { // newtypes (e.g., stdlib wrapper types like std.web.request.Query/Path). super::trait_bound_inference::infer_trait_bounds(&mut ir); lowered_modules.push((path.clone(), ir)); - break; } } for idx in 0..lowered_modules.len() { diff --git a/src/backend/ir/codegen/dependency_metadata.rs b/src/backend/ir/codegen/dependency_metadata.rs index 5ae5bbf2e..541e4cb13 100644 --- a/src/backend/ir/codegen/dependency_metadata.rs +++ b/src/backend/ir/codegen/dependency_metadata.rs @@ -98,7 +98,8 @@ fn has_web_route_passthrough_decorator( }) } -/// Collect dependency-module declarations that are referenced through imports. +/// Collect dependency-module declarations that must remain reachable from externally visible roots such as imports, +/// ambient logging, and web route registration. pub(super) fn collect_externally_reachable_items_by_module( main: &Program, dependency_modules: &[(&str, &Program, Option>)], diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index 9548b8773..e1c5093f0 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -432,7 +432,7 @@ impl<'a> IrEmitter<'a> { /// Return the explicitly registered compatibility borrow policy for a metadata-free external method argument. /// /// Signature metadata remains the source of truth for Rust-boundary borrowing. These policies are only for - /// default-build interop surfaces that v0.3 already emits without rust-inspect metadata. + /// default-build interop surfaces emitted without rust-inspect metadata. fn metadata_free_method_arg_borrow_policy( receiver: &TypedExpr, method: &str, diff --git a/src/backend/ir/lower/expr/calls.rs b/src/backend/ir/lower/expr/calls.rs index 1dfbfd748..d5bbea002 100644 --- a/src/backend/ir/lower/expr/calls.rs +++ b/src/backend/ir/lower/expr/calls.rs @@ -1661,6 +1661,11 @@ impl AstLowering { if let ast::Expr::Ident(name) = &f.node && let Some(builtin) = BuiltinFn::from_name(name) && imported_callee_path.is_none() + && self + .type_info + .as_ref() + .is_none_or(|info| info.ident_kind(f.span).is_none()) + && self.callable_signature_for_call_span(call_span).is_none() && !matches!(func.ty, IrType::Function { .. }) { let args_ir = self.lower_call_args(args)?.into_iter().map(|a| a.expr).collect(); diff --git a/src/backend/ir/reference_shape.rs b/src/backend/ir/reference_shape.rs index 46b668100..596aba446 100644 --- a/src/backend/ir/reference_shape.rs +++ b/src/backend/ir/reference_shape.rs @@ -1,7 +1,7 @@ //! Predicates for IR expressions that already emit Rust reference-shaped values. //! //! Ownership and coercion planning may still see these expressions as ordinary Incan surface types. Keep the -//! reference-shape predicate here so conversions, method emission, and future argument planners do not drift. +//! reference-shape predicate here so conversions, method emission, and argument planning do not drift. use super::expr::{IrExpr, IrExprKind}; use super::types::IrType; diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 200443853..2c0fb0b69 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -220,14 +220,27 @@ fn dedupe_import_declarations(ast: &mut Program) { ast.declarations = declarations; } -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] struct TopLevelNames { types: HashSet, values: HashSet, + imported_types: HashSet, + imported_values: HashSet, +} + +#[derive(Debug, Clone)] +struct TopLevelNameSummary { + path: PathBuf, + names: TopLevelNames, } /// Collect top-level Rust item names that would collide if multiple Incan files were concatenated. fn collect_top_level_decl_names(program: &Program) -> TopLevelNames { + fn add_import_binding(name: &str, names: &mut TopLevelNames) { + names.imported_types.insert(name.to_string()); + names.imported_values.insert(name.to_string()); + } + /// Add the Rust type/value namespace names contributed by one declaration. fn collect_from_decl(decl: &Declaration, names: &mut TopLevelNames) { match decl { @@ -269,7 +282,40 @@ fn collect_top_level_decl_names(program: &Program) -> TopLevelNames { collect_from_decl(&nested.node, names); } } - Declaration::Import(_) | Declaration::Partial(_) | Declaration::Docstring(_) => {} + Declaration::Import(decl) => match &decl.kind { + ImportKind::Module(path) => { + let local = decl + .alias + .as_ref() + .or_else(|| path.segments.last()) + .map(String::as_str) + .unwrap_or("module"); + add_import_binding(local, names); + } + ImportKind::From { items, .. } + | ImportKind::PubFrom { items, .. } + | ImportKind::RustFrom { items, .. } => { + for item in items { + add_import_binding(item.alias.as_deref().unwrap_or(&item.name), names); + } + } + ImportKind::PubLibrary { library } => { + add_import_binding(decl.alias.as_deref().unwrap_or(library), names); + } + ImportKind::Python(pkg) => { + add_import_binding(decl.alias.as_deref().unwrap_or(pkg), names); + } + ImportKind::RustCrate { crate_name, path, .. } => { + let local = decl + .alias + .as_ref() + .or_else(|| path.last()) + .map(String::as_str) + .unwrap_or(crate_name); + add_import_binding(local, names); + } + }, + Declaration::Partial(_) | Declaration::Docstring(_) => {} } } @@ -280,51 +326,102 @@ fn collect_top_level_decl_names(program: &Program) -> TopLevelNames { names } -/// Return whether concatenating source files into one worker harness would collide at Rust module scope. -/// -/// Worker batches can share one process only when their source files can coexist in the generated crate. If two files -/// define the same model, function, or other top-level Rust item, the runner falls back to per-file harnesses. -fn batch_has_cross_file_top_level_collision( +fn collect_top_level_name_summary( + path: &Path, + source: &str, + library_imported_vocab: Option<&parser::ImportedLibraryVocab>, +) -> Option { + let tokens = lexer::lex(source).ok()?; + let ast = + parser::parse_with_context(&tokens, Some(path.to_string_lossy().as_ref()), library_imported_vocab).ok()?; + let names = collect_top_level_decl_names(&ast_with_inline_test_declarations(&ast)); + Some(TopLevelNameSummary { + path: path.to_path_buf(), + names, + }) +} + +fn collect_top_level_name_summaries( sources_by_file: &[(PathBuf, String)], library_imported_vocab: Option<&parser::ImportedLibraryVocab>, -) -> bool { - if sources_by_file.len() <= 1 { - return false; - } +) -> Option> { + sources_by_file + .iter() + .map(|(path, source)| collect_top_level_name_summary(path, source, library_imported_vocab)) + .collect() +} +fn top_level_summaries_have_collision<'a>(summaries: impl IntoIterator) -> bool { let mut type_owner: HashMap = HashMap::new(); let mut value_owner: HashMap = HashMap::new(); - for (path, source) in sources_by_file { - let Ok(tokens) = lexer::lex(source) else { - return false; - }; - let Ok(ast) = - parser::parse_with_context(&tokens, Some(path.to_string_lossy().as_ref()), library_imported_vocab) - else { - return false; - }; - let names = collect_top_level_decl_names(&ast_with_inline_test_declarations(&ast)); - for name in names.types { + let mut imported_type_owner: HashMap = HashMap::new(); + let mut imported_value_owner: HashMap = HashMap::new(); + for summary in summaries { + for name in &summary.names.types { + if imported_type_owner + .get(name) + .is_some_and(|owner| owner != &summary.path) + { + return true; + } if type_owner - .insert(name, path.clone()) - .is_some_and(|owner| owner != *path) + .insert(name.clone(), summary.path.clone()) + .is_some_and(|owner| owner != summary.path) { return true; } } - for name in names.values { + for name in &summary.names.values { + if imported_value_owner + .get(name) + .is_some_and(|owner| owner != &summary.path) + { + return true; + } if value_owner - .insert(name, path.clone()) - .is_some_and(|owner| owner != *path) + .insert(name.clone(), summary.path.clone()) + .is_some_and(|owner| owner != summary.path) { return true; } } + for name in &summary.names.imported_types { + if type_owner.get(name).is_some_and(|owner| owner != &summary.path) { + return true; + } + imported_type_owner + .entry(name.clone()) + .or_insert_with(|| summary.path.clone()); + } + for name in &summary.names.imported_values { + if value_owner.get(name).is_some_and(|owner| owner != &summary.path) { + return true; + } + imported_value_owner + .entry(name.clone()) + .or_insert_with(|| summary.path.clone()); + } } false } +/// Return whether concatenating source files into one worker harness would collide at Rust module scope. +/// +/// Worker batches can share one process only when their source files can coexist in the generated crate. If two files +/// define the same model, function, or another top-level Rust item, or when one file imports a name another file +/// declares, the runner falls back to per-file harnesses. +fn batch_has_cross_file_top_level_collision( + sources_by_file: &[(PathBuf, String)], + library_imported_vocab: Option<&parser::ImportedLibraryVocab>, +) -> bool { + if sources_by_file.len() <= 1 { + return false; + } + collect_top_level_name_summaries(sources_by_file, library_imported_vocab) + .is_some_and(|summaries| top_level_summaries_have_collision(&summaries)) +} + /// Partition files into greedy groups that can still share a generated Rust module scope. /// /// A single duplicate top-level name should not force the whole worker batch back to one Cargo harness per file. @@ -334,22 +431,26 @@ fn partition_collision_free_file_groups( sources_by_file: &[(PathBuf, String)], library_imported_vocab: Option<&parser::ImportedLibraryVocab>, ) -> Vec> { - let mut groups: Vec> = Vec::new(); - 'source: for (path, source) in sources_by_file { + let Some(summaries) = collect_top_level_name_summaries(sources_by_file, library_imported_vocab) else { + return vec![sources_by_file.iter().map(|(path, _)| path.clone()).collect()]; + }; + + let mut groups: Vec> = Vec::new(); + 'source: for summary in summaries { for group in &mut groups { let mut candidate = group.clone(); - candidate.push((path.clone(), source.clone())); - if !batch_has_cross_file_top_level_collision(&candidate, library_imported_vocab) { - group.push((path.clone(), source.clone())); + candidate.push(summary.clone()); + if !top_level_summaries_have_collision(&candidate) { + group.push(summary); continue 'source; } } - groups.push(vec![(path.clone(), source.clone())]); + groups.push(vec![summary]); } groups .into_iter() - .map(|group| group.into_iter().map(|(path, _)| path).collect()) + .map(|group| group.into_iter().map(|summary| summary.path).collect()) .collect() } @@ -456,7 +557,18 @@ fn parse_and_desugar_test_sources( } fn module_name_for_segments(segments: &[String]) -> String { - segments.join("_") + let mut hasher = Sha256::new(); + for segment in segments { + hasher.update(segment.as_bytes()); + hasher.update([0]); + } + let digest = hex::encode(hasher.finalize()); + let stem = if segments.is_empty() { + "module".to_string() + } else { + segments.join("_") + }; + format!("{stem}_{}", &digest[..8]) } fn read_conftest_sources(paths: &[PathBuf]) -> Result, String> { @@ -3759,6 +3871,52 @@ test test_runner_76001490ba86f677::__incan_file_tests::incan_harness_1_b ... FAI assert_eq!(name, "test_runner_76001490ba86f677"); } + #[test] + fn partition_collision_free_file_groups_considers_import_bindings() { + let sources = vec![ + ( + PathBuf::from("tests/test_imports_col.incn"), + "from helpers import col\n\ndef test_imported_col() -> None:\n assert col() == 1\n".to_string(), + ), + ( + PathBuf::from("tests/test_declares_col.incn"), + "def col() -> int:\n return 2\n\ndef test_local_col() -> None:\n assert col() == 2\n".to_string(), + ), + ]; + + let groups = partition_collision_free_file_groups(&sources, None); + + assert_eq!(groups.len(), 2); + } + + #[test] + fn partition_collision_free_file_groups_allows_repeated_import_bindings() { + let sources = vec![ + ( + PathBuf::from("tests/test_a.incn"), + "from std.testing import assert_eq\n\ndef test_a() -> None:\n assert_eq(1, 1)\n".to_string(), + ), + ( + PathBuf::from("tests/test_b.incn"), + "from std.testing import assert_eq\n\ndef test_b() -> None:\n assert_eq(2, 2)\n".to_string(), + ), + ]; + + let groups = partition_collision_free_file_groups(&sources, None); + + assert_eq!(groups.len(), 1); + } + + #[test] + fn module_name_for_segments_disambiguates_join_collisions() { + let flat = module_name_for_segments(&["a_b".to_string()]); + let nested = module_name_for_segments(&["a".to_string(), "b".to_string()]); + + assert_ne!(flat, nested); + assert!(flat.starts_with("a_b_")); + assert!(nested.starts_with("a_b_")); + } + #[test] fn inject_file_test_harness_emits_tests_module() { let rust = "fn test_a() {}\nfn test_b() {}\n"; diff --git a/src/frontend/typechecker/check_expr/calls/builtins.rs b/src/frontend/typechecker/check_expr/calls/builtins.rs index a2af26d76..f1870b73c 100644 --- a/src/frontend/typechecker/check_expr/calls/builtins.rs +++ b/src/frontend/typechecker/check_expr/calls/builtins.rs @@ -8,7 +8,7 @@ use crate::frontend::typechecker::helpers::{collection_type_id, dict_ty, list_ty use incan_core::lang::builtins::{self as core_builtins, BuiltinFnId}; use incan_core::lang::stdlib; use incan_core::lang::surface::constructors::{self as surface_constructors, ConstructorId}; -use incan_core::lang::surface::functions::{self as surface_functions, SurfaceFnId}; +use incan_core::lang::surface::functions::SurfaceFnId; use incan_core::lang::surface::types::{self as surface_types, SurfaceTypeId}; use incan_core::lang::types::collections::CollectionTypeId; @@ -118,10 +118,19 @@ impl TypeChecker { call_span: Span, respect_shadowing: bool, ) -> Option { - let has_function_symbol = respect_shadowing && self.has_non_builtin_function_definition(name); + let has_call_root_binding = respect_shadowing && self.has_non_builtin_call_root_binding(name); + let surface_function_binding = respect_shadowing + .then(|| self.active_surface_function_import(name)) + .flatten(); + let surface_type_binding = respect_shadowing + .then(|| self.active_surface_type_import(name)) + .flatten(); // Constructors (variant-like) if let Some(cid) = surface_constructors::from_str(name) { + if has_call_root_binding { + return None; + } return match cid { ConstructorId::Ok | ConstructorId::Err => { let arg_types = self.check_call_arg_types(args); @@ -178,7 +187,7 @@ impl TypeChecker { // Core builtin functions (registry-driven) if let Some(bid) = core_builtins::from_str(name) { - if has_function_symbol { + if has_call_root_binding { return None; } return match bid { @@ -459,10 +468,7 @@ impl TypeChecker { } // Surface/runtime functions (registry-driven) - if let Some(fid) = surface_functions::from_str(name) { - if !has_function_symbol { - return None; - } + if let Some(fid) = surface_function_binding { return match fid { SurfaceFnId::SleepMs => { if let Some(arg) = args.first() { @@ -542,7 +548,17 @@ impl TypeChecker { } // Surface types that behave like constructors and whose result type depends on args. - if let Some(tid) = surface_types::from_str(name) { + let surface_type = surface_type_binding.or_else(|| { + if has_call_root_binding { + None + } else { + surface_types::from_str(name) + } + }); + if let Some(tid) = surface_type { + if has_call_root_binding { + debug_assert_eq!(surface_type_binding, Some(tid)); + } return match tid { SurfaceTypeId::Json | SurfaceTypeId::Query => { Some(self.check_json_query_constructor_call(tid, args, call_span)) @@ -587,6 +603,9 @@ impl TypeChecker { // Python-like type conversion helpers (surface). These are not part of `lang::builtins`. if let Some(cid) = collection_type_id(name) { + if has_call_root_binding { + return None; + } return match cid { CollectionTypeId::Dict => { let (key_ty, val_ty) = if let Some(arg) = args.first() { diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index 12871e9d2..5cb4cd7d1 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -21,6 +21,7 @@ use crate::library_manifest::{ }; use incan_core::interop::{RustItemKind, RustTraitAssoc, fallback_rust_trait_methods, is_rust_capability_bound}; use incan_core::lang::stdlib::{self, is_typechecker_only_stdlib}; +use incan_core::lang::surface::functions as surface_functions; use incan_core::lang::surface::types as surface_types; use incan_semantics_core::{DecoratorFeature, SurfaceFeatureKey}; @@ -122,16 +123,12 @@ impl StdlibFromImportContext { }) } - /// Return `true` when a surface type import is legal from this stdlib module. - fn allows_surface_type_import(&self, item_name: &str) -> bool { - let Some(id) = surface_types::from_str(item_name) else { - return false; - }; - let Some(expected_module_path) = surface_types::stdlib_module_path(id) else { - return false; - }; + /// Return the imported surface type when it is legal from this stdlib module. + fn allowed_surface_type_import(&self, item_name: &str) -> Option { + let id = surface_types::from_str(item_name)?; + let expected_module_path = surface_types::stdlib_module_path(id)?; - match expected_module_path { + let allowed = match expected_module_path { "std.web" => self.is_web_namespace, "std.reflection" => self.is_reflection_module, _ if expected_module_path.starts_with("std.async.") => { @@ -140,7 +137,8 @@ impl StdlibFromImportContext { self.is_async_namespace && (async_root_or_prelude || self.module_path_str == expected_module_path) } _ => false, - } + }; + allowed.then_some(id) } } @@ -384,8 +382,12 @@ impl TypeChecker { if self.materialize_typechecker_only_stdlib_import(context.module, item, span) { return true; } - if stdlib_context.allows_surface_type_import(&item.name) { - self.define_from_import_symbol(item, SymbolKind::Type(TypeInfo::Builtin), span); + if let Some(surface_type) = stdlib_context.allowed_surface_type_import(&item.name) { + let local_name = Self::import_item_local_name(item); + let symbol_id = + self.define_named_import_symbol(local_name.clone(), SymbolKind::Type(TypeInfo::Builtin), span); + self.surface_type_import_bindings + .insert(local_name, (surface_type, symbol_id)); return true; } if self.materialize_stdlib_submodule_import(context.module, item, span) { @@ -451,8 +453,13 @@ impl TypeChecker { ) -> bool { if let Some(info) = self.stdlib_cache.lookup_function(&context.module.segments, &item.name) { let local_name = Self::import_item_local_name(item); + let surface_function = surface_functions::from_str(&item.name); self.record_testing_marker_import(context, item, &local_name, testing_semantics); - self.define_named_import_symbol(local_name, SymbolKind::Function(info), span); + let symbol_id = self.define_named_import_symbol(local_name.clone(), SymbolKind::Function(info), span); + if let Some(surface_function) = surface_function { + self.surface_function_import_bindings + .insert(local_name, (surface_function, symbol_id)); + } return true; } @@ -565,14 +572,14 @@ impl TypeChecker { } /// Define one already named imported symbol after root namespace validation. - fn define_named_import_symbol(&mut self, name: Ident, kind: SymbolKind, span: Span) { + fn define_named_import_symbol(&mut self, name: Ident, kind: SymbolKind, span: Span) -> SymbolId { self.validate_root_namespace(&name, span); self.symbols.define(Symbol { name, kind, span, scope: 0, - }); + }) } fn validate_pub_library_entry(&mut self, library: &str, span: Span) { @@ -1658,8 +1665,7 @@ impl TypeChecker { fn existing_from_import_symbol_kind(&self, name: &str) -> Option { let id = self.symbols.lookup(name)?; let sym = self.symbols.get(id)?; - let is_implicit_builtin = sym.scope == 0 && sym.span == Span::default(); - if is_implicit_builtin { + if Self::is_implicit_builtin_symbol(sym) { return None; } Some(sym.kind.clone()) diff --git a/src/frontend/typechecker/helpers/symbols.rs b/src/frontend/typechecker/helpers/symbols.rs index 56093f40c..4432a44f8 100644 --- a/src/frontend/typechecker/helpers/symbols.rs +++ b/src/frontend/typechecker/helpers/symbols.rs @@ -4,17 +4,37 @@ //! expression checking make the same shadowing decision. use crate::frontend::ast::Span; -use crate::frontend::symbols::SymbolKind; +use crate::frontend::symbols::Symbol; use crate::frontend::typechecker::TypeChecker; +use incan_core::lang::surface::functions::SurfaceFnId; +use incan_core::lang::surface::types::SurfaceTypeId; impl TypeChecker { - /// Return `true` when `name` resolves to a non-builtin function definition. + /// Return whether a symbol is one of the ambient builtins seeded into the root symbol table before source + /// collection. + pub(in crate::frontend::typechecker) fn is_implicit_builtin_symbol(sym: &Symbol) -> bool { + sym.scope == 0 && sym.span == Span::default() + } + + /// Return `true` when an implicit builtin-call root is shadowed by a real source/import binding. /// - /// Call checking uses this to decide whether builtin dispatch should yield to a user/imported function of the same - /// name. - pub(in crate::frontend::typechecker) fn has_non_builtin_function_definition(&self, name: &str) -> bool { - self.lookup_symbol(name).is_some_and(|sym| { - matches!(sym.kind, SymbolKind::Function(_)) && !(sym.scope == 0 && sym.span == Span::default()) - }) + /// Decorated functions are intentionally rebound from `Function` symbols to callable `Variable` symbols after + /// decorator checking. Builtin dispatch therefore has to ask whether the name is still the ambient builtin binding, + /// not whether the symbol is specifically a `Function`. + pub(in crate::frontend::typechecker) fn has_non_builtin_call_root_binding(&self, name: &str) -> bool { + self.lookup_symbol(name) + .is_some_and(|sym| !Self::is_implicit_builtin_symbol(sym)) + } + + /// Return the active stdlib surface helper imported under `name`, if the import has not been shadowed. + pub(in crate::frontend::typechecker) fn active_surface_function_import(&self, name: &str) -> Option { + let (id, imported_symbol_id) = self.surface_function_import_bindings.get(name)?; + (self.symbols.lookup(name) == Some(*imported_symbol_id)).then_some(*id) + } + + /// Return the active stdlib surface type imported under `name`, if the import has not been shadowed. + pub(in crate::frontend::typechecker) fn active_surface_type_import(&self, name: &str) -> Option { + let (id, imported_symbol_id) = self.surface_type_import_bindings.get(name)?; + (self.symbols.lookup(name) == Some(*imported_symbol_id)).then_some(*id) } } diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index f00bfef2c..13db28081 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -84,8 +84,9 @@ use incan_core::interop::{ }; use incan_core::lang::conventions; use incan_core::lang::decorators::{self as core_decorators, DecoratorId}; +use incan_core::lang::surface::functions::SurfaceFnId; use incan_core::lang::surface::types as surface_types; -use incan_core::lang::surface::types::SurfaceTypeKind; +use incan_core::lang::surface::types::{SurfaceTypeId, SurfaceTypeKind}; use incan_core::lang::traits::{self as builtin_traits, TraitId}; use incan_core::lang::types::collections::CollectionTypeId; use incan_core::lang::types::numerics::{self, NumericFamily, NumericTypeId}; @@ -232,6 +233,16 @@ pub struct TypeChecker { /// These names are disallowed in runtime call expressions; markers are decorator-only semantics consumed by the /// test runner. pub(crate) testing_marker_import_bindings: HashSet, + /// Local names bound to stdlib surface helpers that still need compiler-known call typing. + /// + /// The stored symbol id must remain the active lookup binding; later local declarations or user imports with the + /// same name shadow these helper semantics. + pub(crate) surface_function_import_bindings: HashMap, + /// Local names bound to stdlib surface types that still need compiler-known constructor typing. + /// + /// The stored symbol id must remain the active lookup binding; later local declarations or user imports with the + /// same name shadow these constructor semantics. + pub(crate) surface_type_import_bindings: HashMap, /// Fixture function names collected before body checking so dependency metadata is order-independent. pub(crate) testing_fixture_names: HashSet, /// Import aliases collected from `import` / `from ... import` declarations. @@ -303,6 +314,8 @@ impl TypeChecker { declared_crate_names: None, stdlib_cache: stdlib_loader::StdlibAstCache::new(), testing_marker_import_bindings: HashSet::new(), + surface_function_import_bindings: HashMap::new(), + surface_type_import_bindings: HashMap::new(), testing_fixture_names: HashSet::new(), import_aliases: HashMap::new(), surface_context: SurfaceContext::default(), @@ -3309,6 +3322,8 @@ impl TypeChecker { self.warnings.clear(); self.errors.clear(); self.testing_marker_import_bindings.clear(); + self.surface_function_import_bindings.clear(); + self.surface_type_import_bindings.clear(); self.testing_fixture_names.clear(); self.surface_context = SurfaceContext::from_program(program); self.import_aliases = self.surface_context.import_aliases().clone(); diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index e4b2adbd6..aa9259243 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -9402,6 +9402,69 @@ def foo() -> str: assert!(check_str(source).is_ok()); } +#[test] +fn test_local_function_named_sleep_ms_shadows_surface_helper() { + let source = r#" +def sleep_ms(value: str) -> str: + return value + +def foo() -> str: + return sleep_ms("ok") +"#; + assert_check_ok(source); +} + +#[test] +fn test_local_function_named_some_shadows_option_constructor() { + let source = r#" +def Some(value: str) -> str: + return value + +def foo() -> str: + return Some("ok") +"#; + assert_check_ok(source); +} + +#[test] +fn test_local_function_named_list_shadows_collection_helper() { + let source = r#" +def list(value: str) -> str: + return value + +def foo() -> str: + return list("ok") +"#; + assert_check_ok(source); +} + +#[test] +fn test_decorated_function_named_sum_shadows_builtin_sum_in_inline_module_tests() { + let source = r#" +model IntExpr: + value: int + +model Measure: + kind: str + +def registered[F](function_ref: str) -> ((F) -> F): + return (func) => func + +def expr(value: int) -> IntExpr: + return IntExpr(value=value) + +@registered("demo.sum") +def sum(value: IntExpr) -> Measure: + return Measure(kind="local") + +module tests: + def test_inline_sum() -> None: + measure = sum(expr(1)) + assert measure.kind == "local" +"#; + assert_check_ok(source); +} + #[test] fn test_explicit_std_builtins_sum_call() { let source = r#" diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 59df189ba..94acd4bca 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -8710,6 +8710,126 @@ def test_inferred_generic_decorator_factory_signature() -> None: Ok(()) } + #[test] + fn e2e_inline_decorated_sum_shadows_builtin_sum_issue677() -> Result<(), Box> { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "decorated_sum_inline" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + let source_path = src_dir.join("functions.incn"); + std::fs::write( + &source_path, + r#" +pub model IntExpr: + pub value: int + +pub model TextExpr: + pub value: str + +pub type Expr = IntExpr | TextExpr + +pub model Measure: + pub kind: str + +pub def registered[F](function_ref: str) -> ((F) -> F): + return (func) => func + +pub def expr(value: int) -> Expr: + return IntExpr(value=value) + +@registered("demo.sum") +pub def sum(value: Expr) -> Measure: + return Measure(kind="local") + +module tests: + def test_inline_test_resolves_decorated_sum_before_builtin_sum() -> None: + measure = sum(expr(1)) + assert measure.kind == "local" +"#, + )?; + + let output = run_incan_test_path(&source_path); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected decorated inline sum test to pass.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + stdout.contains("functions.incn::test_inline_test_resolves_decorated_sum_before_builtin_sum"), + "expected the #677 inline test to run.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + Ok(()) + } + + #[test] + fn e2e_conventional_test_batches_split_import_declaration_collisions_issue676() + -> Result<(), Box> { + let dir = write_test_project( + "incan.toml", + r#"[project] +name = "import_collision_batch" +version = "0.1.0" +"#, + ); + let src_dir = dir.join("src"); + let tests_dir = dir.join("tests"); + std::fs::create_dir_all(&src_dir)?; + std::fs::create_dir_all(&tests_dir)?; + std::fs::write( + src_dir.join("helpers.incn"), + r#" +pub def col() -> int: + return 1 +"#, + )?; + std::fs::write( + tests_dir.join("test_imports_col.incn"), + r#" +from helpers import col + +def test_imported_col() -> None: + assert col() == 1 +"#, + )?; + std::fs::write( + tests_dir.join("test_declares_col.incn"), + r#" +def col() -> int: + return 2 + +def test_local_col() -> None: + assert col() == 2 +"#, + )?; + + let output = run_incan_test(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected import/local declaration collision batch to split and pass.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + stdout.contains("test_imported_col") && stdout.contains("test_local_col"), + "expected both split test files to run.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + Ok(()) + } + #[test] fn e2e_method_call_decorator_factories_use_checked_receiver_lowering() -> Result<(), Box> { let dir = write_test_project( @@ -10509,527 +10629,6 @@ pub def display[T](data: DataSet[T]) -> None: Ok(()) } - #[allow(dead_code)] - fn write_nested_wasm_vocab_companion_crate( - project_root: &Path, - relative_path: &str, - package_name: &str, - ) -> Result<(), Box> { - let crate_root = project_root.join(relative_path); - std::fs::create_dir_all(crate_root.join("src"))?; - std::fs::write( - crate_root.join("Cargo.toml"), - format!( - "[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\npath = \"src/lib.rs\"\ncrate-type = [\"rlib\", \"cdylib\"]\n\n[dependencies]\nincan_vocab = {{ path = \"{}\" }}\n\n[workspace]\n", - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("crates") - .join("incan_vocab") - .display() - ), - )?; - std::fs::write( - crate_root.join("src/lib.rs"), - r#"use incan_vocab::{ - DesugarError, DesugarOutput, HelperBinding, IncanExpr, IncanStatement, KeywordActivation, - KeywordPlacement, KeywordRegistration, KeywordSpec, KeywordSurfaceKind, LibraryManifest, - VocabBodyItem, VocabClause, VocabClauseBody, VocabDeclaration, VocabDesugarer, - VocabFieldSpec, VocabRegistration, VocabSyntaxNode, -}; - -#[derive(Default)] -pub struct NestedOutputDesugarer; - -pub fn library_vocab() -> VocabRegistration { - VocabRegistration::new() - .with_keyword_registration(KeywordRegistration { - activation: KeywordActivation::OnImport { - namespace: "nested.dsl".to_string(), - }, - keywords: vec![ - KeywordSpec::new("compose", KeywordSurfaceKind::BlockDeclaration), - context_keyword("action", &["compose"]), - context_keyword("layout", &["compose"]), - context_keyword("page", &["compose"]), - context_keyword("projection", &["compose"]), - context_keyword("region", &["layout", "page"]), - context_keyword("heading", &["region"]), - context_keyword("text", &["region"]), - context_keyword("interaction", &["page"]), - context_keyword("require", &["interaction"]), - ], - valid_decorators: Vec::new(), - }) - .with_library_manifest(LibraryManifest { - helper_bindings: vec![ - helper_binding("action"), - helper_binding("layout"), - helper_binding("page_with_interactions"), - helper_binding("projection"), - helper_binding("region"), - helper_binding("heading"), - helper_binding("text"), - helper_binding("interaction"), - helper_binding("required_input"), - helper_binding("surface_with_governance"), - ], - ..LibraryManifest::default() - }) - .with_desugarer(NestedOutputDesugarer) -} - -impl VocabDesugarer for NestedOutputDesugarer { - fn desugar(&self, node: &VocabSyntaxNode) -> Result { - match node { - VocabSyntaxNode::Declaration(declaration) if declaration.keyword == "compose" => Ok( - DesugarOutput::Statements(vec![complex_artifact_let_statement(declaration)?]), - ), - VocabSyntaxNode::Declaration(_) => Err(DesugarError::new( - "nested output desugarer expected a compose declaration", - )), - _ => Err(DesugarError::new( - "nested output desugarer expected a declaration node", - )), - } - } -} - -fn helper_binding(name: &str) -> HelperBinding { - HelperBinding { - key: name.to_string(), - exported_name: name.to_string(), - } -} - -fn context_keyword(name: &str, parents: &[&str]) -> KeywordSpec { - KeywordSpec::new(name, KeywordSurfaceKind::BlockContextKeyword) - .with_placement(KeywordPlacement::in_block(parents.iter().copied())) -} - -fn complex_artifact_let_statement(declaration: &VocabDeclaration) -> Result { - Ok(IncanStatement::Let { - name: "nested_artifact".to_string(), - mutable: false, - value: complex_artifact_call(declaration)?, - }) -} - -fn complex_artifact_call(declaration: &VocabDeclaration) -> Result { - let name = declaration - .head - .name - .clone() - .ok_or_else(|| DesugarError::new("compose declarations require a name"))?; - let mut title = name.clone(); - let mut base = "/".to_string(); - let mut actions = Vec::new(); - let mut layouts = Vec::new(); - let mut pages = Vec::new(); - let mut projections = Vec::new(); - - for item in &declaration.body { - match item { - VocabBodyItem::Statement(statement) => apply_surface_statement(&mut title, &mut base, statement)?, - VocabBodyItem::Clause(clause) => match clause.keyword.as_str() { - "action" => actions.push(desugar_action(clause)?), - "layout" => layouts.push(desugar_layout(clause)?), - "page" => pages.push(desugar_page(clause)?), - "projection" => projections.push(desugar_projection(clause)?), - other => return Err(DesugarError::new(format!("unsupported compose clause `{other}`"))), - }, - VocabBodyItem::Declaration(declaration) => { - return Err(DesugarError::new(format!( - "unsupported nested declaration `{}`", - declaration.keyword - ))); - } - _ => return Err(DesugarError::new("unsupported compose body item")), - } - } - - Ok(call( - "surface_with_governance", - vec![ - string(&name), - string(&title), - string(&base), - list(actions), - list(layouts), - list(pages), - list(projections), - ], - )) -} - -fn apply_surface_statement(title: &mut String, base: &mut String, statement: &IncanStatement) -> Result<(), DesugarError> { - match statement { - IncanStatement::Let { name, value, .. } | IncanStatement::Assign { target: name, value } => match name.as_str() { - "title" => *title = string_value(value, "compose title")?, - "base" => *base = string_value(value, "compose base")?, - other => return Err(DesugarError::new(format!("unsupported compose assignment `{other}`"))), - }, - IncanStatement::Pass => {} - _ => return Err(DesugarError::new("compose body only supports assignments and nested clauses")), - } - Ok(()) -} - -fn desugar_action(clause: &VocabClause) -> Result { - let name = required_head_name(clause, "action")?; - let mut capability = name.clone(); - let mut required_evidence = String::new(); - match &clause.body { - VocabClauseBody::FieldSet(fields) => { - for field in fields { - apply_action_assignment( - &mut capability, - &mut required_evidence, - &field.name, - field_value(field, "action assignment")?, - )?; - } - } - _ => { - for item in clause_items(clause)? { - match item { - VocabBodyItem::Statement(statement) => match statement { - IncanStatement::Let { name, value, .. } | IncanStatement::Assign { target: name, value } => { - apply_action_assignment(&mut capability, &mut required_evidence, name, value)?; - } - IncanStatement::Pass => {} - _ => return Err(DesugarError::new("action body only supports assignments")), - }, - VocabBodyItem::Clause(other) => return Err(DesugarError::new(format!("unsupported `{}` block inside action", other.keyword))), - VocabBodyItem::Declaration(_) => return Err(DesugarError::new("action body only supports assignments")), - _ => return Err(DesugarError::new("action body only supports assignments")), - } - } - } - } - Ok(call("action", vec![string(&name), string(&capability), string(&required_evidence)])) -} - -fn apply_action_assignment( - capability: &mut String, - required_evidence: &mut String, - name: &str, - value: &IncanExpr, -) -> Result<(), DesugarError> { - match name { - "capability" => *capability = string_value(value, "action capability")?, - "requires" | "required_evidence" => *required_evidence = string_value(value, "action required evidence")?, - other => return Err(DesugarError::new(format!("unsupported action assignment `{other}`"))), - } - Ok(()) -} - -fn desugar_layout(clause: &VocabClause) -> Result { - let name = required_head_name(clause, "layout")?; - let mut regions = Vec::new(); - for item in clause_items(clause)? { - match item { - VocabBodyItem::Clause(region_clause) if region_clause.keyword == "region" => { - regions.push(string(&required_head_name(region_clause, "layout region")?)); - } - VocabBodyItem::Statement(IncanStatement::Pass) => {} - VocabBodyItem::Clause(other) => return Err(DesugarError::new(format!("unsupported `{}` block inside layout", other.keyword))), - _ => return Err(DesugarError::new("layout body only supports region blocks")), - } - } - Ok(call("layout", vec![string(&name), list(regions)])) -} - -fn desugar_page(clause: &VocabClause) -> Result { - let name = required_head_name(clause, "page")?; - let mut route = "/".to_string(); - let mut title = name.clone(); - let mut layout_name = "SimplePage".to_string(); - let mut regions = Vec::new(); - let mut interactions = Vec::new(); - for item in clause_items(clause)? { - match item { - VocabBodyItem::Statement(statement) => match statement { - IncanStatement::Let { name, value, .. } | IncanStatement::Assign { target: name, value } => match name.as_str() { - "route" => route = string_value(value, "page route")?, - "title" => title = string_value(value, "page title")?, - "layout" => layout_name = string_or_name_value(value, "page layout")?, - other => return Err(DesugarError::new(format!("unsupported page assignment `{other}`"))), - }, - IncanStatement::Pass => {} - _ => return Err(DesugarError::new("page body only supports assignments, regions, and interactions")), - }, - VocabBodyItem::Clause(region_clause) if region_clause.keyword == "region" => { - regions.push(desugar_region(region_clause)?); - } - VocabBodyItem::Clause(interaction_clause) if interaction_clause.keyword == "interaction" => { - interactions.push(desugar_interaction(interaction_clause)?); - } - VocabBodyItem::Clause(other) => return Err(DesugarError::new(format!("unsupported `{}` block inside page", other.keyword))), - VocabBodyItem::Declaration(_) => return Err(DesugarError::new("page body does not support nested declarations")), - _ => return Err(DesugarError::new("page body only supports assignments, regions, and interactions")), - } - } - Ok(call( - "page_with_interactions", - vec![ - string(&name), - string(&route), - string(&title), - string(&layout_name), - list(regions), - list(interactions), - ], - )) -} - -fn desugar_region(clause: &VocabClause) -> Result { - let name = required_head_name(clause, "region")?; - let mut nodes = Vec::new(); - for item in clause_items(clause)? { - match item { - VocabBodyItem::Clause(node_clause) if node_clause.keyword == "heading" => { - nodes.push(call("heading", vec![string(&required_head_string(node_clause, "heading")?)])); - } - VocabBodyItem::Clause(node_clause) if node_clause.keyword == "text" => { - nodes.push(call("text", vec![string(&required_head_string(node_clause, "text")?)])); - } - VocabBodyItem::Statement(IncanStatement::Pass) => {} - VocabBodyItem::Clause(other) => return Err(DesugarError::new(format!("unsupported `{}` block inside region", other.keyword))), - _ => return Err(DesugarError::new("region body only supports heading and text blocks")), - } - } - Ok(call("region", vec![string(&name), list(nodes)])) -} - -fn desugar_interaction(clause: &VocabClause) -> Result { - let name = required_head_name(clause, "interaction")?; - let mut action = name.clone(); - let mut constraints = Vec::new(); - match &clause.body { - VocabClauseBody::FieldSet(fields) => { - for field in fields { - if field.name == "action" { - action = string_or_name_value(field_value(field, "interaction action")?, "interaction action")?; - } else { - return Err(DesugarError::new(format!("unsupported interaction assignment `{}`", field.name))); - } - } - } - _ => { - for item in clause_items(clause)? { - match item { - VocabBodyItem::Statement(statement) => match statement { - IncanStatement::Let { name, value, .. } | IncanStatement::Assign { target: name, value } => match name.as_str() { - "action" => action = string_or_name_value(value, "interaction action")?, - other => return Err(DesugarError::new(format!("unsupported interaction assignment `{other}`"))), - }, - IncanStatement::Pass => {} - _ => return Err(DesugarError::new("interaction body only supports assignments and require blocks")), - }, - VocabBodyItem::Clause(require_clause) if require_clause.keyword == "require" => { - constraints.push(desugar_required_input(&name, require_clause)?); - } - VocabBodyItem::Clause(other) => return Err(DesugarError::new(format!("unsupported `{}` block inside interaction", other.keyword))), - VocabBodyItem::Declaration(_) => return Err(DesugarError::new("interaction body does not support nested declarations")), - _ => return Err(DesugarError::new("interaction body only supports assignments and require blocks")), - } - } - } - } - Ok(call("interaction", vec![string(&name), string(&action), list(constraints)])) -} - -fn desugar_required_input(interaction_name: &str, clause: &VocabClause) -> Result { - let mut field = required_input_field(clause)?; - let mut label = field.clone(); - let mut min_length = "1".to_string(); - let mut evidence_key = String::new(); - match &clause.body { - VocabClauseBody::FieldSet(fields) => { - for field_spec in fields { - apply_required_input_assignment( - &mut field, - &mut label, - &mut min_length, - &mut evidence_key, - &field_spec.name, - field_value(field_spec, "require input assignment")?, - )?; - } - } - _ => { - for item in clause_items(clause)? { - match item { - VocabBodyItem::Statement(statement) => match statement { - IncanStatement::Let { name, value, .. } | IncanStatement::Assign { target: name, value } => { - apply_required_input_assignment( - &mut field, - &mut label, - &mut min_length, - &mut evidence_key, - name, - value, - )?; - } - IncanStatement::Pass => {} - _ => return Err(DesugarError::new("require input body only supports assignments")), - }, - _ => return Err(DesugarError::new("require input body only supports assignments")), - } - } - } - } - Ok(call( - "required_input", - vec![ - string(interaction_name), - string(&field), - string(&label), - string(&min_length), - string(&evidence_key), - ], - )) -} - -fn apply_required_input_assignment( - field: &mut String, - label: &mut String, - min_length: &mut String, - evidence_key: &mut String, - name: &str, - value: &IncanExpr, -) -> Result<(), DesugarError> { - match name { - "field" => *field = string_or_name_value(value, "required input field")?, - "label" => *label = string_value(value, "required input label")?, - "min_length" => *min_length = int_or_string_value(value, "required input min_length")?, - "evidence" | "evidence_key" => *evidence_key = string_value(value, "required input evidence")?, - other => return Err(DesugarError::new(format!("unsupported require input assignment `{other}`"))), - } - Ok(()) -} - -fn desugar_projection(clause: &VocabClause) -> Result { - let name = required_head_name(clause, "projection")?; - let mut target = "static-web".to_string(); - match &clause.body { - VocabClauseBody::FieldSet(fields) => { - for field in fields { - if field.name == "target" { - target = string_value(field_value(field, "projection target")?, "projection target")?; - } else { - return Err(DesugarError::new(format!("unsupported projection assignment `{}`", field.name))); - } - } - } - _ => { - for item in clause_items(clause)? { - match item { - VocabBodyItem::Statement(statement) => match statement { - IncanStatement::Let { name, value, .. } | IncanStatement::Assign { target: name, value } if name == "target" => { - target = string_value(value, "projection target")?; - } - IncanStatement::Pass => {} - _ => return Err(DesugarError::new("projection body only supports target assignment")), - }, - _ => return Err(DesugarError::new("projection body only supports target assignment")), - } - } - } - } - Ok(call("projection", vec![string(&name), string(&target)])) -} - -fn clause_items(clause: &VocabClause) -> Result<&[VocabBodyItem], DesugarError> { - match &clause.body { - VocabClauseBody::Empty => Ok(&[]), - VocabClauseBody::Items(items) => Ok(items.as_slice()), - _ => Err(DesugarError::new(format!("unsupported `{}` body shape", clause.keyword))), - } -} - -fn required_head_name(clause: &VocabClause, label: &str) -> Result { - let Some(value) = clause.head.first() else { - return Err(DesugarError::new(format!("{label} requires a name"))); - }; - string_or_name_value(value, label) -} - -fn required_head_string(clause: &VocabClause, label: &str) -> Result { - let Some(value) = clause.head.first() else { - return Err(DesugarError::new(format!("{label} requires text"))); - }; - string_value(value, label) -} - -fn required_input_field(clause: &VocabClause) -> Result { - if !clause.compound_tokens.is_empty() && clause.compound_tokens[0] == "input" { - if let Some(value) = clause.head.first() { - return string_or_name_value(value, "require input field"); - } - return Ok(String::new()); - } - if !clause.head.is_empty() { - let first = string_or_name_value(&clause.head[0], "require input marker")?; - if first == "input" { - if clause.head.len() >= 2 { - return string_or_name_value(&clause.head[1], "require input field"); - } - return Ok(String::new()); - } - } - Err(DesugarError::new("required-input constraints must use `require input`")) -} - -fn string_value(expr: &IncanExpr, label: &str) -> Result { - match expr { - IncanExpr::Str(value) => Ok(value.clone()), - _ => Err(DesugarError::new(format!("{label} must be a string literal"))), - } -} - -fn string_or_name_value(expr: &IncanExpr, label: &str) -> Result { - match expr { - IncanExpr::Str(value) | IncanExpr::Name(value) => Ok(value.clone()), - _ => Err(DesugarError::new(format!("{label} must be a name or string literal"))), - } -} - -fn int_or_string_value(expr: &IncanExpr, label: &str) -> Result { - match expr { - IncanExpr::Int(value) => Ok(value.to_string()), - IncanExpr::Str(value) => Ok(value.clone()), - _ => Err(DesugarError::new(format!("{label} must be an integer or string literal"))), - } -} - -fn field_value<'a>(field: &'a VocabFieldSpec, label: &str) -> Result<&'a IncanExpr, DesugarError> { - field - .default_value - .as_ref() - .ok_or_else(|| DesugarError::new(format!("{label} `{}` requires a value", field.name))) -} - -fn call(helper: &str, args: Vec) -> IncanExpr { - IncanExpr::Call { - callee: Box::new(IncanExpr::Helper(helper.to_string())), - args, - } -} - -fn list(items: Vec) -> IncanExpr { - IncanExpr::List(items) -} - -fn string(value: &str) -> IncanExpr { - IncanExpr::Str(value.to_string()) -} - -incan_vocab::export_wasm_desugarer!(NestedOutputDesugarer); -"#, - )?; - Ok(()) - } - fn wat_bytes_string(bytes: &[u8]) -> String { let mut escaped = String::new(); for byte in bytes { @@ -12766,6 +12365,29 @@ def main() -> None: String::from_utf8_lossy(&screen_check.stdout), String::from_utf8_lossy(&screen_check.stderr) ); + + let where_out_dir = tmp.path().join("where_out"); + let where_build = run_build(&where_main, &where_out_dir)?; + assert!( + where_build.status.success(), + "expected helper-backed `where` build to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&where_build.stdout), + String::from_utf8_lossy(&where_build.stderr) + ); + let screen_out_dir = tmp.path().join("screen_out"); + let screen_build = run_build(&screen_main, &screen_out_dir)?; + assert!( + screen_build.status.success(), + "expected helper-backed `screen` build to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&screen_build.stdout), + String::from_utf8_lossy(&screen_build.stderr) + ); + let where_generated = std::fs::read_to_string(where_out_dir.join("src/main.rs"))?; + let screen_generated = std::fs::read_to_string(screen_out_dir.join("src/main.rs"))?; + assert_eq!( + where_generated, screen_generated, + "equivalent helper-backed keywords should emit identical Rust" + ); Ok(()) } diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 243bc529b..1a437723b 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -16,15 +16,6 @@ The main direction is not "more syntax for its own sake." `0.3` moves common pro - **Tooling**: `incan test`, `incan fmt`, `incan lock`, lifecycle commands, doctor diagnostics, checked API metadata, LSP metadata, and generated Rust audits are more deterministic and CI-friendly. - **Architecture**: More behavior is registry- and metadata-driven, and generated Rust relies less on scattered special cases. -## Read the details - -Use these entry points when a feature group needs more than release-note depth. - -- **Language**: [Numeric semantics](../language/reference/numeric_semantics.md), [Choosing numeric types](../language/how-to/choosing_numeric_types.md), [Control Flow](../language/explanation/control_flow.md), [Union types](../language/reference/union_types.md), [Enums](../language/explanation/enums.md), [Traits as language hooks](../language/explanation/traits_as_language_hooks.md), [Derives and traits](../language/reference/derives_and_traits.md), and [Callable objects](../language/reference/stdlib_traits/callable.md). -- **Stdlib**: [Choosing collection types](../language/how-to/choosing_collections.md), [`std.collections`](../language/reference/stdlib/collections.md), [Why `OrdinalMap` exists](../language/explanation/ordinal_map.md), [`std.graph`](../language/reference/stdlib/graph.md), [Working with graphs](../language/how-to/working_with_graphs.md), [`std.json`](../language/reference/stdlib/json.md), [Dynamic JSON](../language/how-to/dynamic_json.md), [`std.regex`](../language/reference/stdlib/regex.md), [Regular expressions](../language/how-to/regular_expressions.md), [`std.datetime`](../language/reference/stdlib/datetime.md), [Dates and times](../language/how-to/dates_and_times.md), [`std.logging`](../language/reference/stdlib/logging.md), [Logging](../language/how-to/logging.md), [`std.encoding`](../language/reference/stdlib/encoding.md), [Binary-text encoding](../language/how-to/binary_text_encoding.md), [`std.hash`](../language/reference/stdlib/hash.md), [`std.compression`](../language/reference/stdlib/compression.md), and [Compression](../language/how-to/compression.md). -- **Rust interop**: [Rust interop](../language/how-to/rust_interop.md), [Understanding Rust types](../language/how-to/rust_types_for_python_devs.md), [Rust-shaped confidence](../language/explanation/rust_shaped_confidence.md), [Derives and traits](../language/explanation/derives_and_traits.md), and [`std.traits`](../language/reference/stdlib/traits.md). -- **Tooling**: [Formatting with `incan fmt`](../tooling/how-to/formatting.md), [Tooling: Testing](../tooling/how-to/testing.md), [Project lifecycle](../language/how-to/project_lifecycle.md), [Project configuration](../tooling/reference/project_configuration.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md). - ## Migrating from 0.2 Most ordinary `0.2` programs should continue to compile. The changes below are the ones most likely to show up during adoption. @@ -35,46 +26,166 @@ Most ordinary `0.2` programs should continue to compile. The changes below are t 4. **Lockfiles are less noisy.** `incan.lock` no longer records generation timestamps, and routine `build` / `test` runs warn and reuse stale lock payloads instead of rewriting committed lockfiles. Run `incan lock` when you intentionally refresh the lock. 5. **New features are additive.** `loop:`, `if let`, `while let`, value enums, protocol hooks, iterator adapters, and `Result` combinators do not require rewriting existing `match`, `while True`, helper-function, or nested-`match` code. -## Features and Enhancements - -- **Language**: Numeric annotations now cover exact integer widths, pointer-sized integers, `f32` / `f64`, analytics aliases, fixed-scale `decimal[p, s]` / `numeric[p, s]`, lossless widening, explicit resize helpers, and Rust boundary adaptation ([RFC 009], #325). -- **Language**: Control flow gained `loop:` with `break `, single-pattern `if let` / `while let`, pattern alternation, anonymous union annotations with narrowing, and value enums with raw `str` / `int` representations ([RFC 016], [RFC 049], [RFC 071], [RFC 029], [RFC 032], #327, #333, #387, #317). -- **Language**: Enums can own methods and adopt traits; models, classes, traits, and wrappers gained computed properties, protocol hooks, operator hooks, typed decorators, generic decorator factories, declaration aliases, RHS partial callable presets, variadic parameters, call unpacking, and generator values ([RFC 050], [RFC 046], [RFC 068], [RFC 028], [RFC 036], [RFC 083], [RFC 084], [RFC 038], [RFC 006], #334, #203, #86, #162, #170, #437, #453, #83, #324, #640). -- **Rust interop**: Newtypes and rusttypes can adopt Rust traits with Incan source syntax, disambiguate same-name methods with `for Trait`, declare associated types, forward supported `@rust.derive(...)` metadata, and preserve inspected Rust signatures through generated calls ([RFC 043], [RFC 041], #200, #175). -- **Stdlib**: `std.collections` adds specialized containers, and `std.collections.OrdinalMap` adds deterministic immutable key-to-ordinal lookup for schemas, catalogs, dictionary-encoded domains, and reproducible serialized lookup tables ([RFC 030], [RFC 101]). -- **Stdlib**: `std.graph`, `std.json.JsonValue`, `std.regex`, `std.datetime`, and `std.logging` add first-party surfaces for dependency graphs, dynamic JSON payloads, safe-default regular expressions, temporal values, and structured logging ([RFC 047], [RFC 051], [RFC 059], [RFC 058], [RFC 072]). -- **Stdlib**: `std.encoding`, `std.hash`, `std.compression`, `std.fs`, `std.io`, `std.tempfile`, and `std.uuid` cover binary-text transforms, byte/file/reader hashing, codec-based compression, path-centric filesystem work, in-memory byte streams, temporary resources, and UUID parsing/formatting/generation ([RFC 064], [RFC 065], [RFC 061], [RFC 055], [RFC 056], [RFC 010], [RFC 060]). -- **Stdlib**: Iterator values gained lazy adapters and terminal consumers, `Result[T, E]` gained Rust-shaped `map`, `map_err`, `and_then`, `or_else`, `inspect`, and `inspect_err`, and `list.repeat(value, count)` provides import-free fixed-length list initialization ([RFC 088], [RFC 070], [RFC 069], #127, #386, #385). -- **Testing**: The `assert` statement is a language primitive, inline `module tests:` blocks are discovered by `incan test`, the runner now supports fixtures, parametrization, markers, resource-aware parallelism, JSON Lines, JUnit XML, durations, shuffling, `--nocapture`, built-in temp/env fixtures, and async fixtures ([RFC 018], [RFC 019], [RFC 004], #76). -- **Async**: `Awaitable[T]`, expression-position `race for`, `std.async.race`, channel reservation APIs, timeout-join helpers, cancellation-safe barrier behavior, and diagnostics for un-awaited async calls tighten the async surface ([RFC 039], #173, #415, #416, #417, #418, #146). -- **Tooling**: `incan fmt` now follows the vertical-spacing contract and wraps more long calls/signatures; `incan build`, `incan run`, and `incan test` support offline/locked/frozen policy; `incan tools doctor` reports offline readiness and editor binary health; lifecycle commands cover `incan new`, `incan init`, `incan version`, and `incan env` ([RFC 053], [RFC 020], [RFC 015], #73, #460, #426). -- **Tooling**: Checked contract metadata now flows through model bundles, project materialization, `.incnlib` artifacts, `incan tools metadata model`, `incan tools metadata api`, generated API docs, and LSP hover/command surfaces ([RFC 048], #205, #438). +## Feature guide + +Use this section as the map. The release note names each larger feature, says what it is for, and links to the docs that carry the real detail. + +### Language features + +- **Numeric types and fixed-scale decimals**: Use exact widths and schema-shaped names when a boundary needs them, while keeping `int` and `float` ergonomic for ordinary code. Start with [Choosing numeric types](../language/how-to/choosing_numeric_types.md), then [Numeric semantics](../language/reference/numeric_semantics.md) ([RFC 009], #325). +- **Loop expressions**: Use `loop:` plus `break ` for search, retry, and accumulator-free loops that produce a value. Read [Control Flow](../language/explanation/control_flow.md) ([RFC 016], #327, #387). +- **Pattern control flow**: Use `if let` and `while let` when one successful pattern should run and the miss case should do nothing. Read [Control Flow](../language/explanation/control_flow.md) ([RFC 049], #333). +- **Union narrowing and pattern alternation**: Model inputs that can take several shapes, then narrow them with checked patterns instead of hand-written tag logic. Read [Union types](../language/reference/union_types.md) ([RFC 071], [RFC 029]). +- **Value enums**: Keep enum type safety while exposing canonical `str` or `int` representations for external values. Read [Enums](../language/explanation/enums.md) and [Modeling with enums](../language/how-to/modeling_with_enums.md) ([RFC 032], #317). +- **Enum methods and trait adoption**: Put enum-owned behavior on the enum and let enums adopt the same trait protocols as other source types. Read [Enums](../language/explanation/enums.md) and [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) ([RFC 050], #334). +- **Computed properties and protocol hooks**: Define property-like readers and dunder-backed operator/protocol behavior without pushing users into Rust-shaped wrappers. Read [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) and [Derives and traits](../language/reference/derives_and_traits.md) ([RFC 046], [RFC 068], [RFC 028], #86, #162, #203). +- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape. Read [Functions](../language/reference/functions.md) and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640). +- **Symbol aliases**: Export an existing callable or type-like symbol under another name without pretending it is a hand-written wrapper. Read [Symbol aliases](../language/reference/symbol_aliases.md) ([RFC 083], #437). +- **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). +- **Variadics and call unpacking**: Describe call shapes that accept or forward flexible argument lists without losing static checks. Read [Functions](../language/reference/functions.md) ([RFC 038], #83). +- **Generators and lazy iterators**: Build pipelines with generator values, lazy adapters, and explicit terminal consumers such as `collect`, `count`, `find`, and `fold`. Read [Generators](../language/how-to/generators.md) and [Generator semantics](../language/explanation/generators.md) ([RFC 006], [RFC 088], #127, #324, #386). + +### Rust interop and API metadata + +- **Rust trait adoption from Incan source**: Newtypes and rusttypes can adopt Rust traits with `with Trait`, method-level `for Trait`, and associated type declarations. Read [Rust interop](../language/how-to/rust_interop.md), [Rust types for Python developers](../language/how-to/rust_types_for_python_devs.md), and [`std.traits`](../language/reference/stdlib/traits.md) ([RFC 043], #200). +- **Derived and inspected Rust metadata**: Supported `@rust.derive(...)`, associated types, inspected Rust signatures, and metadata-backed call boundaries now survive further through generated calls. Read [Derives and traits](../language/explanation/derives_and_traits.md) and [Rust-shaped confidence](../language/explanation/rust_shaped_confidence.md) ([RFC 041], #175). +- **Checked public API metadata**: Public declarations, aliases, partials, models, enum variants, and selected decorator metadata can be emitted for tools and downstream consumers. Read [Checked API metadata](../tooling/reference/checked_api_metadata.md) and [LSP protocol support](../tooling/reference/lsp_protocol_support.md) ([RFC 048], #205, #438). + +### Standard Library + +- **[`std.async`](../language/reference/stdlib/async.md)**: Awaitable races, channel reservation, timeout joins, cancellation-safe barriers, and un-awaited-call diagnostics move async workflows into documented stdlib APIs ([RFC 039], #173, #415, #416, #417, #418, #146). +- **[`std.collections`](../language/reference/stdlib/collections.md)**: Ordered, sorted, counter, queue, stack, multimap, bidict, and `list.repeat(value, count)` workflows have first-party containers and helpers; see also [Choosing collection types](../language/how-to/choosing_collections.md) ([RFC 030], [RFC 069], #385). +- **[`std.collections.OrdinalMap`](../language/explanation/ordinal_map.md)**: Deterministic immutable key-to-ordinal lookup supports schemas, catalogs, dictionary-encoded domains, and reproducible serialized lookup tables ([RFC 101]). +- **[`std.compression`](../language/reference/stdlib/compression.md)**: Gzip, zlib, deflate, bzip2, lzma, zstd, and Snappy-oriented byte/stream workflows are codec-explicit; see [Compression](../language/how-to/compression.md) ([RFC 061]). +- **[`std.datetime`](../language/reference/stdlib/datetime.md)**: Dates, times, datetimes, durations, parsing, formatting, clocks, and timezone offsets share one temporal vocabulary; see [Dates and times](../language/how-to/dates_and_times.md) ([RFC 058]). +- **[`std.encoding`](../language/reference/stdlib/encoding.md)**: Base64, hex, URL, and related byte/text transforms are strict and named; see [Binary-text encoding](../language/how-to/binary_text_encoding.md) ([RFC 064]). +- **[`std.fs`](../language/reference/stdlib/fs.md)**: Path-centric filesystem work covers paths, metadata, directory traversal, and file operations; see [File I/O](../language/how-to/file_io.md) ([RFC 055]). +- **[`std.graph`](../language/reference/stdlib/graph.md)**: Directed graph, DAG, traversal, dependency ordering, path query, and cycle-aware workflows are available without ad hoc containers; see [Working with graphs](../language/how-to/working_with_graphs.md) and [Graph model](../language/explanation/graph_model.md) ([RFC 047]). +- **[`std.hash`](../language/reference/stdlib/hash.md)**: Byte, file, and reader hashing use algorithm-specific helpers with normalized digest output; see [Hashing data](../language/how-to/hashing_data.md) ([RFC 065]). +- **[`std.io`](../language/reference/stdlib/io.md)**: In-memory byte streams and buffered readers cover byte-oriented I/O without direct Rust interop ([RFC 056]). +- **[`std.json`](../language/reference/stdlib/json.md)**: `JsonValue` supports dynamic payload construction, inspection, conversion, and extraction at API boundaries; see [Dynamic JSON](../language/how-to/dynamic_json.md) ([RFC 051]). +- **[`std.logging`](../language/reference/stdlib/logging.md)**: Structured logging gives modules stable logger names, levels, fields, and runtime-friendly generated Rust output; see [Logging](../language/how-to/logging.md) ([RFC 072]). +- **[`std.regex`](../language/reference/stdlib/regex.md)**: Safe-default regular expressions cover matching, captures, iteration, splitting, and replacement without backtracking-only features; see [Regular expressions](../language/how-to/regular_expressions.md) ([RFC 059]). +- **[`std.result`](../language/reference/stdlib/result.md)**: `Result[T, E]` gained Rust-shaped `map`, `map_err`, `and_then`, `or_else`, `inspect`, and `inspect_err` helpers for fallible pipelines ([RFC 070], #386). +- **[`std.tempfile`](../language/reference/stdlib/tempfile.md)**: Scoped temporary files and directories are first-party test and application resources ([RFC 010]). +- **[`std.testing`](../language/reference/stdlib/testing.md)**: Fixtures, parametrization, markers, temp/env fixtures, async fixtures, and assertion helpers back the `incan test` workflow; see [Testing in Incan](../language/how-to/testing_stdlib.md) ([RFC 018], [RFC 019], [RFC 004], #76). +- **[`std.uuid`](../language/reference/stdlib/uuid.md)**: UUID parsing, formatting, generation, version inspection, and byte/string conversion are available as source-defined helpers; see [Working with UUIDs](../language/how-to/working_with_uuids.md) ([RFC 060]). + +### Tooling + +- **`incan test`**: Inline `module tests:` blocks are discovered by the runner, with fixtures, parametrization, markers, resource-aware parallelism, JSON Lines, JUnit XML, durations, shuffling, and `--nocapture`; read [Tooling: Testing](../tooling/how-to/testing.md) and [Testing in Incan](../language/how-to/testing_stdlib.md) ([RFC 018], [RFC 019], [RFC 004], #76). +- **`incan fmt`**: Formatting follows the vertical-spacing contract and wraps more long calls and signatures; read [Formatting with `incan fmt`](../tooling/how-to/formatting.md) and the [Code Style Guide](../language/reference/code_style.md) ([RFC 053], #73). +- **Cargo policy and lockfiles**: `incan build`, `incan run`, and `incan test` propagate offline, locked, and frozen policy while `incan lock` owns intentional lock refreshes; read [Project configuration](../tooling/reference/project_configuration.md) ([RFC 020], #460). +- **Lifecycle and diagnostics**: `incan new`, `incan init`, `incan version`, `incan env`, and `incan tools doctor` cover project startup, environment inspection, offline readiness, and editor binary health; read [Project lifecycle](../language/how-to/project_lifecycle.md) ([RFC 015], #426). ## Bugfixes and Hardening -- **Release stabilization**: Validation against InQL and release smoke tests fixed formatter/parser drift for tuple-unpack comprehensions and f-string debug markers, generated-Rust ownership/coercion failures for loop-item fields and union call arguments, public alias reexports across library boundaries, union model variant construction/clone/alias equivalence, static list index assignment, collection f-string formatting, `std.regex` Rust-boundary text borrowing, Rust bridge type identity for inspected methods whose Rust signatures retain unknown generic or lifetime placeholders, test-runner source-module ordering for public aliases of imported items, `?` propagation in comprehension bodies, decorated-function source signatures in checked API metadata, imported/decorator `const str` argument materialization, generic decorator factory inference across package-style module boundaries, typed failure lowering for `assert false` in non-`None` return paths, method-call decorator factories on class/static registry receivers, const model metadata constructors, lowercase exported static imports, generated script/test Cargo manifests that omit unreachable package-level Rust dependencies, keyword-named public symbols, imported static decorator string arguments, storage-rooted method calls used as match scrutinees, and directory inline-test batching that preserves each file's parser and import scope (#615, #616, #617, #620, #621, #622, #624, #625, #627, #630, #631, #633, #636, #638, #640, #644, #645, #658, #659, #665, #669, #671, #674, #676). -- **Compiler**: Ownership and Rust-boundary coercion now route through shared argument-use and generated-use planning instead of parallel caller/emitter heuristics, which makes borrowed `str`/bytes calls, backend-inserted `Clone` bounds, method fallback borrowing, and retained generated imports follow the same metadata-backed decisions across typechecking, lowering, and emission. Remaining semantic string comparisons in high-risk compiler paths are now fingerprinted by a guardrail so new string-based behavior has to be centralized or explicitly classified. -- **Compiler**: Duckborrowing and generated-Rust ownership planning now cover more typed use sites, including arguments, returns, assignments, match scrutinees, collection elements, tuple elements, lookups, comprehensions, mutable aggregate parameters, Rust interop calls, and backend-inserted `Clone` bounds. This removes many user-authored `.clone()`, `.as_ref()`, `str(...)`, and `.into()` workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). -- **Compiler**: Multi-file and cross-module codegen is stricter: imported defaults expand with qualification, same-shaped union wrappers are shared through the crate root, wide union narrowing fully lowers, keyword-named modules escape consistently, public submodule reexports work under `src/`, and private route handlers/models are retained for web registration (#395, #457, #461, #458, #122, #287, #117). -- **Compiler**: Rust interop fixes cover retained enum-pattern imports, owned Incan values passed to shared borrowed generic Rust parameters, `Vec` adaptation from `list[T]`, prost-style inherent and trait-provided `decode(buf: T)` calls, extension-trait import retention from metadata, Rust bridge type identity across re-exported and placeholder-generic argument displays, and trait-typed local annotation diagnostics (#459, #506, #128, #609, #612, #447, #630, #462). -- **Compiler**: Typechecking and lowering now preserve more generic information, including `Self` substitution on instantiated generic receivers, generic model/class field access, generic instance methods, list literals containing `Self`, trait/supertrait upcasts, imported prost oneof payloads, explicit call-site generic cycles, and locals initialized from static factory calls (#237, #231, #253, #230, #184, #218, #279, #252, #255). -- **Compiler**: Runtime and generated-manifest hardening routes collection/JSON extraction and decorator misuse stubs through named helpers, keeps Tokio and `serde_json` behind feature gates, prunes unused generated Rust without broad `allow` attributes, and improves runtime diagnostics for f-string unknown symbols and collection/string conversion failures (#351, #157, #214, #71, #81). -- **Tooling**: `incan test` reuses more generated harness state, isolates single-file runs, keeps project cwd stable, includes generated helper modules such as `std.result` when test files use helper-backed surfaces, and treats project manifests as one lock surface across scripts and test harness inputs (#268, #269, #271, #288, #378, #610, #505). -- **Tooling**: Formatter fixes preserve escaped f-string newlines, numeric literal spelling, qualified enum/constructor patterns, `mut` markers, block blank-line intent, docstrings, multiline trailing commas, long logical-expression wrapping, and parseable class trait-adoption wrapping (#235, #250, #264, #289, #247, #394, #484, #565). -- **Docs**: User-facing docs were reorganized around reference/how-to/explanation boundaries for stdlib modules including graph, regex, logging, hash, UUID, tempfile, collections, encoding, compression, and datetime. Contributor docs now describe crate boundaries, ownership metadata, staged Rust inspection, and the quarantined `std.web` host-runtime bridge (#284). -- **Dependencies**: Release hardening removes the `atomic-polyfill` advisory path through the local rust-analyzer proc-macro API patch, updates Wasmtime/WASI and MSRV together, remediates Dependabot alerts across docs-site Python pins, VS Code lockfiles, Rust lock entries, and GitHub Actions, and bumps `pymdown-extensions` to `10.21.3` for `GHSA-62q4-447f-wv8h` (#260, #475, #464). +This section is grouped by outcome rather than by every minimized repro. Issue numbers are kept for traceability when you need the exact bug report. + +### Compiler Correctness + +- **Argument planning is shared**: Ownership and Rust-boundary coercion now route through one argument-use plan instead of parallel caller/emitter heuristics. +- **Duckborrowing covers more real use sites**: Generated Rust handles arguments, returns, assignments, match scrutinees, aggregate elements, lookups, comprehensions, mutable aggregate parameters, Rust calls, and generated `Clone` bounds with fewer user-authored workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). +- **Release smoke paths are less fragile**: InQL and release smoke testing fixed loop-item fields, union call arguments, storage-rooted match scrutinees, static list index assignment, typed `assert false` lowering, and const model metadata constructors (#620, #621, #622, #627, #630, #644, #671, #674). +- **Generic and trait flow keeps more type information**: Instantiated receivers, generic fields, generic methods, `list[Self]`, trait/supertrait upcasts, imported prost oneofs, explicit generic cycles, and static factory locals now survive typechecking and lowering more consistently (#237, #231, #253, #230, #184, #218, #279, #252, #255). +- **Runtime-boundary errors are clearer**: `std.regex` text borrowing, collection f-string formatting, and collection/string conversion diagnostics fail closer to the Incan source instead of surfacing as obscure Rust/runtime errors (#624, #625, #71, #81). +- **Stringly compiler behavior is guarded**: Remaining semantic string comparisons in high-risk compiler paths are fingerprinted so new string-based behavior has to be centralized or explicitly classified. + +### Rust Interop And Generated Rust + +- **Borrowing decisions are metadata-backed**: Borrowed `str`/bytes calls, method fallback borrowing, and retained generated imports now follow the same decisions across typechecking, lowering, and emission. +- **Rust bridge identity is preserved**: Inspected methods with unknown generic or lifetime placeholders and re-exported Rust argument displays keep stable bridge identity (#645, #630). +- **Collection adaptation is less manual**: Owned Incan values can flow to shared borrowed generic Rust parameters, and `list[T]` can adapt to `Vec` where metadata proves the boundary (#506, #128). +- **Protobuf-style APIs need fewer workarounds**: Prost-style inherent and trait-provided `decode(buf: T)` calls lower correctly (#609, #612). +- **Generated Rust pruning is safer**: Enum-pattern imports and metadata-derived extension-trait imports survive pruning, while unused generated Rust is pruned without broad `allow` attributes (#459, #447, #214). +- **Generated manifests stay smaller**: Tokio and `serde_json` stay behind feature gates, and generated helper stubs use named helpers (#351, #157). +- **Trait annotation failures are Incan diagnostics**: Trait-typed local annotations now produce diagnostics instead of obscure lowering or generated-Rust failures (#462). + +### Multi-File And Packages + +- **Cross-module codegen is more predictable**: Imported defaults qualify correctly, same-shaped union wrappers are shared, wide union narrowing lowers fully, keyword-named modules escape consistently, and public submodule reexports work under `src/` (#395, #457, #461, #458, #122, #287). +- **Web registration keeps private internals private**: Private route handlers and models are retained for web registration without making them user-visible public API (#117). +- **Package exports match ordinary builds**: Public aliases, package-boundary alias consumption, lowercase exported statics, imported static decorator strings, and keyword-named public symbols follow the same rules across build modes (#617, #631, #633, #658, #659). +- **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, and method-call decorator factories are represented in checked metadata more reliably (#636, #638, #640, #669). +- **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). + +### Formatter And Test Runner + +- **Formatter output preserves meaning**: Tuple-unpack comprehensions, f-string debug markers, escaped f-string newlines, numeric spelling, qualified enum/constructor patterns, `mut`, docstrings, trailing commas, logical-expression wrapping, and class trait-adoption wrapping now round-trip more safely (#615, #616, #235, #250, #264, #289, #247, #394, #484, #565). +- **Comprehension behavior is more complete**: `?` propagation works inside comprehensions, and collection/string conversion diagnostics are clearer when runtime coercion fails. +- **Inline tests keep file-local scope**: Directory inline-test runs preserve each file's parser and import scope, conventional test batches split on imported-name collisions, and decorated functions named like builtins resolve to the source binding inside inline tests (#676, #677). +- **The test harness reuses more correctly**: `incan test` reuses generated harness state, isolates single-file runs, keeps project cwd stable, and includes helper modules such as `std.result` when test files use helper-backed surfaces (#268, #269, #271, #288, #378, #610, #505). + +### Docs And Dependencies + +- **User docs are closer to Divio shape**: Stdlib pages for graph, regex, logging, hash, UUID, tempfile, collections, encoding, compression, datetime, and related modules now separate reference contracts from how-to or explanation material (#284). +- **Contributor docs name the important boundaries**: Crate boundaries, ownership metadata, staged Rust inspection, and the quarantined `std.web` host-runtime bridge are documented for maintainers (#284). +- **Dependency alerts are closed out**: The `atomic-polyfill` advisory path is removed, Wasmtime/WASI and MSRV move together, Dependabot alerts are remediated across docs-site Python pins, VS Code lockfiles, Rust lock entries, and GitHub Actions, and `pymdown-extensions` is pinned to `10.21.3` for `GHSA-62q4-447f-wv8h` (#260, #475, #464). ## Known limitations - Decimal arithmetic is not yet general language behavior. The `0.3` decimal surface covers typed annotations, literal validation, formatting, generated Rust representation, and display; arithmetic semantics need a follow-up language/library decision. - `incan fmt` is still conservative. RFC 053 gives vertical spacing and common wrapping rules, but it is not a general pretty-printer overhaul for every nested expression shape. -- `std.regex` is a safe-default regular-expression surface, not a Python/PCRE compatibility layer. Lookaround, pattern backreferences, and other backtracking-only features belong in a separate package or future stdlib track if standardized. +- `std.regex` is a safe-default regular-expression surface, not a Python/PCRE compatibility layer. Lookaround, pattern backreferences, and other backtracking-only features are tracked separately by [RFC 100] for a future `std.re` surface. - Native Windows filesystem behavior is not part of the `0.3` contract. `std.fs` documents Unix-like host behavior until the stdlib has an explicit platform split. ## RFCs implemented -- **Language and compiler**: [RFC 004], [RFC 006], [RFC 009], [RFC 016], [RFC 017], [RFC 018], [RFC 024], [RFC 025], [RFC 028], [RFC 029], [RFC 032], [RFC 036], [RFC 038], [RFC 039], [RFC 043], [RFC 044], [RFC 046], [RFC 049], [RFC 050], [RFC 053], [RFC 057], [RFC 068], [RFC 069], [RFC 070], [RFC 071], [RFC 083], [RFC 084], [RFC 088]. -- **Stdlib**: [RFC 010], [RFC 030], [RFC 047], [RFC 051], [RFC 055], [RFC 056], [RFC 058], [RFC 059], [RFC 060], [RFC 061], [RFC 064], [RFC 065], [RFC 072], [RFC 101]. -- **Tooling and metadata**: [RFC 015], [RFC 019], [RFC 020], [RFC 040], [RFC 045], [RFC 048]. +### Language and compiler + +- [RFC 004]: async fixtures +- [RFC 006]: Python-style generators +- [RFC 009]: numeric type system and builtin type registry +- [RFC 016]: `loop` and `break ` loop expressions +- [RFC 017]: validated newtypes with implicit coercion +- [RFC 018]: language primitives for testing +- [RFC 024]: extensible derive protocol +- [RFC 025]: multi-instantiation trait dispatch +- [RFC 028]: trait-based operator overloading +- [RFC 029]: union types and type narrowing +- [RFC 032]: value enums with `str` and `int` backing values +- [RFC 036]: user-defined decorators +- [RFC 038]: variadic args and unpacking +- [RFC 039]: `race` for awaitable concurrency +- [RFC 043]: Rust trait implementation from Incan +- [RFC 044]: open-ended trait methods +- [RFC 046]: computed properties +- [RFC 049]: `if let` and `while let` pattern control flow +- [RFC 050]: enum methods and enum trait adoption +- [RFC 053]: formatter vertical spacing buckets +- [RFC 057]: targeted Rust lint suppression for generated code +- [RFC 068]: protocol hooks for core language syntax +- [RFC 069]: `list.repeat` helper for fixed-length initialization +- [RFC 070]: result combinators for `Result[T, E]` +- [RFC 071]: pattern alternation in `match` and `if let` +- [RFC 083]: symbol and method aliases +- [RFC 084]: RHS partial callable presets +- [RFC 088]: iterator adapter surface + +### Standard library + +- [RFC 010]: Python-style `tempfile` standard library +- [RFC 030]: `std.collections` extended collection types +- [RFC 047]: lightweight directed graph types +- [RFC 051]: `JsonValue` for `std.json` +- [RFC 055]: `std.fs` filesystem APIs +- [RFC 056]: `std.io` byte streams and binary parsing helpers +- [RFC 058]: `std.datetime` temporal values, intervals, and runtime timing +- [RFC 059]: `std.regex` regular expressions, matches, captures, and replacement +- [RFC 060]: `std.uuid` parsing, generation, and formatting +- [RFC 061]: `std.compression` codec-based compression and decompression +- [RFC 064]: `std.encoding` binary-text encoding and decoding utilities +- [RFC 065]: `std.hash` stable hashing primitives +- [RFC 072]: `std.logging` logger acquisition, configuration, and structured events +- [RFC 101]: `std.collections.OrdinalMap` deterministic key-to-ordinal lookup + +### Tooling and metadata + +- [RFC 015]: hatch-like project lifecycle tooling +- [RFC 019]: test runner, CLI, and ecosystem +- [RFC 020]: offline, locked, and reproducible builds +- [RFC 040]: scoped DSL surface forms +- [RFC 045]: scoped DSL symbol surfaces +- [RFC 048]: checked contract metadata, Incan emit, and interrogation tooling --8<-- "_snippets/rfcs_refs.md" diff --git a/workspaces/docs-site/docs/roadmap.md b/workspaces/docs-site/docs/roadmap.md index ea52040e5..866cc1e0e 100644 --- a/workspaces/docs-site/docs/roadmap.md +++ b/workspaces/docs-site/docs/roadmap.md @@ -134,7 +134,7 @@ The 1.0 milestone consolidates the post-cutover compiler architecture, ABI/packa ## Status by Area - Core language: see [RFC 000] / [RFC 008]. -- Testing surface: see [RFC 001] / [RFC 002] / [RFC 004] / [RFC 007]. +- Testing surface: see [RFC 018] / [RFC 019] / [RFC 004]. - Tooling and first-contact: install, starter, diagnostics, explain, codegraph, artifact inspection, and build reports are the immediate release surface. - Rust interop: see [RFC 005] / [RFC 013] and the [Rust Interop guide](language/how-to/rust_interop.md). Rust-hosted consumption should be reframed through ABI and Cargo-native package direction instead of generated Rust as the public semantic path. - Web and interactive runtime: see the [Web Framework guide](language/tutorials/web_framework.md), [RFC 092](RFCs/092_interactive_runtime_stdlib_contracts.md), and related runtime/DSL RFCs. @@ -144,12 +144,9 @@ The 1.0 milestone consolidates the post-cutover compiler architecture, ABI/packa The following items remain intentionally deferred until they have a focused RFC or implementation lane: -- SSR/SSG for frontend: Server-Side Rendering / Static Site Generation for the WASM/UI stack (render pages ahead of time - or on the server, then hydrate). -- Desktop/mobile via wgpu: using the wgpu graphics stack to run Incan apps as native desktop/mobile apps (instead of - browser-only). -- CRDT/collab features: real-time collaboration primitives (Conflict-free Replicated Data Types) for things like - collaborative editing, shared state, etc. +- SSR/SSG for frontend: Server-Side Rendering / Static Site Generation for the WASM/UI stack (render pages ahead of time or on the server, then hydrate). +- Desktop/mobile via wgpu: using the wgpu graphics stack to run Incan apps as native desktop/mobile apps instead of browser-only. +- CRDT/collab features: real-time collaboration primitives (Conflict-free Replicated Data Types) for collaborative editing, shared state, and similar workflows. ### Guides From d6068c9f7dcf5fb2e12fa1d72b0e87e36c7d2a35 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 25 May 2026 06:58:17 +0200 Subject: [PATCH 38/58] bugfix - prevent imported static initialization deadlocks (#680) (#691) --- Cargo.lock | 18 +-- Cargo.toml | 2 +- src/backend/ir/emit/decls/mod.rs | 23 ++- src/backend/ir/emit/expressions/mod.rs | 12 +- src/backend/ir/emit/mod.rs | 51 ++++++- src/backend/ir/emit/program.rs | 56 +++++++- src/backend/ir/emit/statements.rs | 2 + tests/integration_tests.rs | 136 ++++++++++++++++++ ...t_tests__rfc052_module_static_storage.snap | 5 +- ...apshot_tests__user_defined_decorators.snap | 3 +- ...tests__user_defined_method_decorators.snap | 4 +- ...ser_defined_mutable_method_decorators.snap | 4 +- 12 files changed, 288 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d13e52ee8..bca87b1b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc15" +version = "0.3.0-rc16" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 64cc95ce9..ef39dc18f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc15" +version = "0.3.0-rc16" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/emit/decls/mod.rs b/src/backend/ir/emit/decls/mod.rs index 29ff6edc0..5d7b73071 100644 --- a/src/backend/ir/emit/decls/mod.rs +++ b/src/backend/ir/emit/decls/mod.rs @@ -279,7 +279,11 @@ impl<'a> IrEmitter<'a> { // ---- Import emission ---- /// Return whether an import path refers to the source-authored Incan stdlib namespace. - fn is_incan_source_stdlib_import(origin: &IrImportOrigin, qualifier: &IrImportQualifier, path: &[String]) -> bool { + pub(super) fn is_incan_source_stdlib_import( + origin: &IrImportOrigin, + qualifier: &IrImportQualifier, + path: &[String], + ) -> bool { !matches!(origin, IrImportOrigin::PubLibrary { .. }) && !matches!(qualifier, IrImportQualifier::None) && stdlib::is_any_stdlib_path(path) @@ -488,6 +492,7 @@ impl<'a> IrEmitter<'a> { && item.name.chars().next().is_some_and(|ch| ch.is_ascii_uppercase())) }) .map(|item| { + let binding = item.alias.as_ref().unwrap_or(&item.name); let name_ident = if item.is_static { Self::rust_static_ident(&item.name) } else { @@ -496,7 +501,18 @@ impl<'a> IrEmitter<'a> { let path_tokens_clone = path_tokens.clone(); let path_ts_clone = join_path_tokens(&path_tokens_clone); let absolute_path = matches!(qualifier, IrImportQualifier::None) && !is_pub_library_import; - if let Some(alias) = &item.alias { + let static_init_import = if item.is_static && self.static_needs_imported_init_import(binding) { + let init_ident = Self::rust_ident("__incan_init_module_statics"); + let init_alias = Self::imported_static_init_ident(binding); + if absolute_path { + quote! { use :: #path_ts_clone :: #init_ident as #init_alias; } + } else { + quote! { use #path_ts_clone :: #init_ident as #init_alias; } + } + } else { + quote! {} + }; + let item_import = if let Some(alias) = &item.alias { let alias_ident = if item.is_static { Self::rust_static_ident(alias) } else { @@ -529,7 +545,8 @@ impl<'a> IrEmitter<'a> { quote! { use #path_ts_clone :: #name_ident; } } } - } + }; + quote! { #static_init_import #item_import } }) .collect(); Ok(quote! { #(#item_stmts)* }) diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 7c1d3fb76..3f929b966 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -592,7 +592,7 @@ impl<'a> IrEmitter<'a> { match Self::expr_storage_root(expr) { Some(StorageRoot::Static(name)) => { let ident = Self::rust_static_ident(&name); - let init_call = self.emit_module_static_init_call(); + let init_call = self.emit_static_init_call_for_static(&name); Ok(quote! {{ #init_call #ident.with_ref(|#local_name| { #body }) @@ -611,7 +611,7 @@ impl<'a> IrEmitter<'a> { match Self::expr_storage_root(expr) { Some(StorageRoot::Static(name)) => { let ident = Self::rust_static_ident(&name); - let init_call = self.emit_module_static_init_call(); + let init_call = self.emit_static_init_call_for_static(&name); Ok(quote! {{ #init_call #ident.with_mut(|#local_name| { #body }) @@ -694,10 +694,10 @@ impl<'a> IrEmitter<'a> { IrExprKind::StaticRead { name } => { let n = Self::rust_static_ident(name); - if *self.in_static_initializer.borrow() { + if *self.in_static_initializer.borrow() && !self.static_needs_imported_init_call(name) { Ok(quote! { #n.get() }) } else { - let init_call = self.emit_module_static_init_call(); + let init_call = self.emit_static_init_call_for_static(name); Ok(quote! {{ #init_call #n.get() @@ -707,10 +707,10 @@ impl<'a> IrEmitter<'a> { IrExprKind::StaticBinding { name } => { let n = Self::rust_static_ident(name); - if *self.in_static_initializer.borrow() { + if *self.in_static_initializer.borrow() && !self.static_needs_imported_init_call(name) { Ok(quote! { incan_stdlib::storage::StaticBinding::from_static(&#n) }) } else { - let init_call = self.emit_module_static_init_call(); + let init_call = self.emit_static_init_call_for_static(name); Ok(quote! {{ #init_call incan_stdlib::storage::StaticBinding::from_static(&#n) diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 1591d1bd1..c1f77dd2d 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -286,6 +286,11 @@ pub struct IrEmitter<'a> { newtype_checked_ctor: HashMap, /// Whether the currently emitted module contains any local `static` declarations. module_has_local_statics: RefCell, + /// Imported static bindings that need their defining module's static-init guard before use. + imported_static_init_bindings: RefCell>, + /// Imported static bindings re-exported by this module whose defining module's static-init guard should be + /// chained from this module's init helper. + imported_static_module_init_bindings: RefCell>, /// Whether expression emission is currently inside a static initializer. /// /// Used to avoid recursively forcing the module-wide static init helper while generating static initializer code. @@ -362,6 +367,8 @@ impl<'a> IrEmitter<'a> { rust_import_paths: RefCell::new(std::collections::HashMap::new()), newtype_checked_ctor: HashMap::new(), module_has_local_statics: RefCell::new(false), + imported_static_init_bindings: RefCell::new(HashSet::new()), + imported_static_module_init_bindings: RefCell::new(Vec::new()), in_static_initializer: RefCell::new(false), qualify_internal_canonical_paths: RefCell::new(false), qualify_union_types_from_crate: false, @@ -436,7 +443,7 @@ impl<'a> IrEmitter<'a> { } pub(super) fn emit_module_static_init_call(&self) -> TokenStream { - if *self.module_has_local_statics.borrow() { + if *self.module_has_local_statics.borrow() || !self.imported_static_module_init_bindings.borrow().is_empty() { let init_fn = Self::rust_ident("__incan_init_module_statics"); quote! { #init_fn(); } } else { @@ -444,6 +451,48 @@ impl<'a> IrEmitter<'a> { } } + pub(super) fn set_imported_static_init_bindings(&self, bindings: HashSet) { + *self.imported_static_init_bindings.borrow_mut() = bindings; + } + + pub(super) fn set_imported_static_module_init_bindings(&self, bindings: Vec) { + *self.imported_static_module_init_bindings.borrow_mut() = bindings; + } + + pub(super) fn imported_static_init_ident(name: &str) -> proc_macro2::Ident { + let mut rendered = String::from("__incan_init_imported_static_"); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + rendered.push(ch.to_ascii_lowercase()); + } else { + rendered.push('_'); + } + } + proc_macro2::Ident::new(&rendered, proc_macro2::Span::call_site()) + } + + pub(super) fn static_needs_imported_init_call(&self, name: &str) -> bool { + self.imported_static_init_bindings.borrow().contains(name) + } + + pub(super) fn static_needs_imported_init_import(&self, name: &str) -> bool { + self.static_needs_imported_init_call(name) + || self + .imported_static_module_init_bindings + .borrow() + .iter() + .any(|binding| binding == name) + } + + pub(super) fn emit_static_init_call_for_static(&self, name: &str) -> TokenStream { + if self.static_needs_imported_init_call(name) { + let init_fn = Self::imported_static_init_ident(name); + quote! { #init_fn(); } + } else { + self.emit_module_static_init_call() + } + } + /// Return the private helper method name used to call callable-object observers through a borrowed payload. pub(super) fn result_observer_borrowed_method_name() -> &'static str { "__incan_result_observer_borrow___call__" diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index b66b7917d..40ea06cc0 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -1088,6 +1088,44 @@ impl<'program> GeneratedUseAnalyzer<'program> { } impl<'a> IrEmitter<'a> { + fn collect_imported_static_init_bindings(&self, declarations: &[&IrDecl]) -> (HashSet, Vec) { + let mut access_bindings = HashSet::new(); + let mut module_init_bindings = HashSet::new(); + for decl in declarations { + let IrDeclKind::Import { + visibility, + origin, + qualifier, + path, + items, + .. + } = &decl.kind + else { + continue; + }; + if matches!(origin, IrImportOrigin::PubLibrary { .. }) || matches!(qualifier, IrImportQualifier::None) { + continue; + } + let is_incan_source_stdlib = Self::is_incan_source_stdlib_import(origin, qualifier, path); + let is_public_reexport = !matches!(visibility, Visibility::Private); + for item in items { + if !item.is_static { + continue; + } + let binding = item.alias.as_ref().unwrap_or(&item.name); + if self.should_emit_import_binding(binding) { + access_bindings.insert(binding.clone()); + } + if is_public_reexport && !(is_incan_source_stdlib && binding.starts_with('_')) { + module_init_bindings.insert(binding.clone()); + } + } + } + let mut module_init_bindings: Vec<_> = module_init_bindings.into_iter().collect(); + module_init_bindings.sort(); + (access_bindings, module_init_bindings) + } + /// Return whether the current emitted module defines one registry-backed temporary capability trait contract. fn emitted_declarations_define_capability_trait( program: &IrProgram, @@ -2252,6 +2290,10 @@ impl<'a> IrEmitter<'a> { }) .collect(); *self.module_has_local_statics.borrow_mut() = !static_names.is_empty(); + let (imported_static_init_bindings, imported_static_module_init_bindings) = + self.collect_imported_static_init_bindings(&emitted_declarations); + self.set_imported_static_init_bindings(imported_static_init_bindings); + self.set_imported_static_module_init_bindings(imported_static_module_init_bindings); if self.emit_strict_generated_lint_denies { items.push(quote! { @@ -2338,7 +2380,16 @@ impl<'a> IrEmitter<'a> { } // RFC 052: force declaration-order static initialization once per module before any static access helper call. - if !static_names.is_empty() { + let imported_static_init_calls: Vec = self + .imported_static_module_init_bindings + .borrow() + .iter() + .map(|name| { + let ident = Self::imported_static_init_ident(name); + quote! { #ident(); } + }) + .collect(); + if !static_names.is_empty() || !imported_static_init_calls.is_empty() { let force_calls: Vec = static_names .iter() .map(|name| { @@ -2348,7 +2399,7 @@ impl<'a> IrEmitter<'a> { .collect(); items.push(quote! { #[inline(always)] - fn __incan_init_module_statics() { + pub(crate) fn __incan_init_module_statics() { static __INCAN_STATIC_INIT_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); if __INCAN_STATIC_INIT_RUNNING.load(std::sync::atomic::Ordering::Acquire) { @@ -2364,6 +2415,7 @@ impl<'a> IrEmitter<'a> { } __INCAN_STATIC_INIT_RUNNING.store(true, std::sync::atomic::Ordering::Release); let _guard = __IncanStaticInitGuard(&__INCAN_STATIC_INIT_RUNNING); + #(#imported_static_init_calls)* #(#force_calls)* }); } diff --git a/src/backend/ir/emit/statements.rs b/src/backend/ir/emit/statements.rs index e4efeb2bd..c0bf6e20f 100644 --- a/src/backend/ir/emit/statements.rs +++ b/src/backend/ir/emit/statements.rs @@ -1093,8 +1093,10 @@ impl<'a> IrEmitter<'a> { IrStmtKind::Assign { target, value } => { if let AssignTarget::Static(name) = target { let n = Self::rust_static_ident(name); + let init_call = self.emit_static_init_call_for_static(name); let v = self.emit_assignment_value(value, None)?; return Ok(quote! { + #init_call let __incan_static_rhs = #v; #n.with_mut(|__incan_static_value| { *__incan_static_value = __incan_static_rhs.into(); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 94acd4bca..582d58966 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -647,6 +647,26 @@ fn incan_command() -> Command { command } +fn run_incan_command_with_timeout( + mut command: Command, + timeout: std::time::Duration, +) -> std::io::Result<(std::process::Output, bool)> { + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + let mut child = command.spawn()?; + let start = std::time::Instant::now(); + loop { + if child.try_wait()?.is_some() { + return child.wait_with_output().map(|output| (output, false)); + } + if start.elapsed() >= timeout { + let _ = child.kill(); + return child.wait_with_output().map(|output| (output, true)); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } +} + fn is_incan_fixture(path: &Path) -> bool { matches!(path.extension().and_then(|e| e.to_str()), Some("incn") | Some("incan")) } @@ -2099,6 +2119,122 @@ def main() -> None: Ok(()) } +#[test] +fn test_imported_static_initializer_does_not_deadlock_issue680() -> Result<(), Box> { + let dir = make_temp_test_dir(); + let project_name = unique_test_project_name("imported_static_deadlock"); + std::fs::write( + dir.join("incan.toml"), + format!("[project]\nname = \"{project_name}\"\nversion = \"0.1.0\"\n"), + )?; + let src_dir = dir.join("src"); + std::fs::create_dir_all(&src_dir)?; + let state = src_dir.join("state.incn"); + let facade = src_dir.join("facade.incn"); + let direct_user = src_dir.join("direct_user.incn"); + let reexport_user = src_dir.join("reexport_user.incn"); + let main = src_dir.join("main.incn"); + std::fs::write( + &state, + r#" +pub class Registry: + pub entries: list[int] + + @staticmethod + def new() -> Self: + return Registry(entries=[]) + + +pub static registry: Registry = Registry.new() + + +pub def registry_len() -> int: + return len(registry.entries) +"#, + )?; + std::fs::write(&facade, "pub from state import registry\n")?; + std::fs::write( + &direct_user, + r#" +from state import registry + + +pub def add_direct() -> None: + registry.entries.append(1) +"#, + )?; + std::fs::write( + &reexport_user, + r#" +from facade import registry + + +pub def add_reexport() -> None: + registry.entries.append(1) +"#, + )?; + std::fs::write( + &main, + r#" +from direct_user import add_direct +from reexport_user import add_reexport +from state import registry_len + + +def main() -> None: + add_direct() + add_reexport() + assert registry_len() == 2 + println("ok") +"#, + )?; + + let mut command = incan_command(); + command + .arg("run") + .arg(main.strip_prefix(&dir)?) + .current_dir(&dir) + .env("CARGO_NET_OFFLINE", "true"); + let (output, timed_out) = run_incan_command_with_timeout(command, std::time::Duration::from_secs(30))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !timed_out, + "imported static init repro timed out; likely deadlocked.\nstdout:\n{}\nstderr:\n{}", + stdout, stderr + ); + assert!( + output.status.success(), + "expected imported static init repro to run.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr + ); + assert!( + stdout.lines().any(|line| line.trim() == "ok"), + "expected imported static init repro to print ok.\nstdout:\n{stdout}" + ); + + let generated_src_dir = dir.join("target/incan").join(project_name).join("src"); + let generated_direct_user = std::fs::read_to_string(generated_src_dir.join("direct_user.rs"))?; + assert!( + generated_direct_user + .contains("use crate::state::__incan_init_module_statics as __incan_init_imported_static_registry;") + && generated_direct_user.contains("__incan_init_imported_static_registry();"), + "direct imported static access should call the defining module init guard before forcing REGISTRY:\n{}", + generated_direct_user + ); + let generated_facade = std::fs::read_to_string(generated_src_dir.join("facade.rs"))?; + assert!( + generated_facade + .contains("use crate::state::__incan_init_module_statics as __incan_init_imported_static_registry;") + && generated_facade.contains("pub(crate) fn __incan_init_module_statics()") + && generated_facade.contains("__incan_init_imported_static_registry();"), + "static re-export modules should chain the defining module init guard:\n{}", + generated_facade + ); + Ok(()) +} + #[test] fn test_static_list_index_assignment_and_remove_compile_and_run() -> Result<(), Box> { let source = r#" diff --git a/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap b/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap index 0af4680e9..5dd911ec9 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc052_module_static_storage.snap @@ -1,5 +1,6 @@ --- source: tests/codegen_snapshot_tests.rs +assertion_line: 2191 expression: rust_code --- // Generated by the Incan compiler v @@ -8,7 +9,7 @@ expression: rust_code incan_stdlib::__incan_stdlib_version_check!(""); #[inline(always)] -fn __incan_init_module_statics() { +pub(crate) fn __incan_init_module_statics() { static __INCAN_STATIC_INIT_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new( false, ); @@ -48,6 +49,7 @@ fn main() { } }), ); + __incan_init_module_statics(); let __incan_static_rhs = { __incan_init_module_statics(); COUNTER.get() @@ -56,6 +58,7 @@ fn main() { .with_mut(|__incan_static_value| { *__incan_static_value = __incan_static_rhs.into(); }); + __incan_init_module_statics(); let __incan_static_rhs = { __incan_init_module_statics(); COUNTER.get() diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_decorators.snap index 9f3dea305..a7d481d21 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_decorators.snap @@ -1,5 +1,6 @@ --- source: tests/codegen_snapshot_tests.rs +assertion_line: 654 expression: rust_code --- // Generated by the Incan compiler v @@ -8,7 +9,7 @@ expression: rust_code incan_stdlib::__incan_stdlib_version_check!(""); #[inline(always)] -fn __incan_init_module_statics() { +pub(crate) fn __incan_init_module_statics() { static __INCAN_STATIC_INIT_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new( false, ); diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap index ac1ee685f..cc44b82f4 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap @@ -1,6 +1,6 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 655 +assertion_line: 661 expression: rust_code --- // Generated by the Incan compiler v @@ -9,7 +9,7 @@ expression: rust_code incan_stdlib::__incan_stdlib_version_check!(""); #[inline(always)] -fn __incan_init_module_statics() { +pub(crate) fn __incan_init_module_statics() { static __INCAN_STATIC_INIT_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new( false, ); diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap index b71e92cf5..3340d916f 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap @@ -1,6 +1,6 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 662 +assertion_line: 668 expression: rust_code --- // Generated by the Incan compiler v @@ -9,7 +9,7 @@ expression: rust_code incan_stdlib::__incan_stdlib_version_check!(""); #[inline(always)] -fn __incan_init_module_statics() { +pub(crate) fn __incan_init_module_statics() { static __INCAN_STATIC_INIT_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new( false, ); From 5265d4124576fc7769cb29acb80edce10b3539d8 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 25 May 2026 13:48:38 +0200 Subject: [PATCH 39/58] updted docs --- .agents/skills/review-architecture/SKILL.md | 40 +++++++++++- .../review-incan-source-quality/SKILL.md | 40 +++++++++++- ...92_interactive_runtime_stdlib_contracts.md | 1 + ...bient_runtime_capabilities_and_receipts.md | 1 + workspaces/docs-site/docs/roadmap.md | 61 ++++++++++++++++++- 5 files changed, 138 insertions(+), 5 deletions(-) diff --git a/.agents/skills/review-architecture/SKILL.md b/.agents/skills/review-architecture/SKILL.md index 467cc6f0e..898438bfd 100644 --- a/.agents/skills/review-architecture/SKILL.md +++ b/.agents/skills/review-architecture/SKILL.md @@ -24,7 +24,7 @@ Do not own: - test style - final branch-clean judgment -## Output artifact +## Output artifacts Write a slice report at: @@ -32,6 +32,20 @@ Write a slice report at: Do not write to the canonical `.agents/state/review-report.md`. +When the architecture report has findings, also copy the scope and findings into a lightweight central snapshot outside the repo/worktree under: + +- `/tmp/incan-review-findings/` + +Use a deterministic, descriptive filename when possible: + +- `YYYY-MM-DD-pr--architecture-.md` +- `YYYY-MM-DD-branch--architecture.md` +- `YYYY-MM-DD-review-architecture.md` when no PR or branch context is known + +Do not create a snapshot for clean reviews. The snapshot is raw corpus for later analysis, not canonical guidance. +Treat `/tmp/incan-review-findings/` as an append-only central corpus for local review work: create new snapshot files, but do not delete, overwrite, prune, or "clean up" existing snapshots unless the user explicitly asks for that exact maintenance. +Snapshot content is evidence, not a fix log. Preserve the original finding blocks verbatim, including severity, category, file path, line reference, and explanatory text. If the finding is fixed before the snapshot is written, keep the original finding as observed and add a separate `Resolution` note after it; do not replace the evidence with a resolved checklist item or a summary of the fix. + ## Workflow 1. Review touched code by subsystem, not merely by file. @@ -46,6 +60,7 @@ Do not write to the canonical `.agents/state/review-report.md`. - maintainability warnings, - or design tensions. All three are valid findings. Classify them so downstream fixers know how to treat them, but do not suppress them. +6. If the report contains findings, create `/tmp/incan-review-findings/` if needed and write a new snapshot containing only the review source metadata, Scope, and Findings sections. Copy the finding blocks verbatim from `.agents/state/review-report.architecture.md`; do not generalize them into policy or rewrite them as fix summaries. Preserve exact file:line evidence when the report has it; if a finding is only file-level, make that explicit in the finding text. Do not overwrite an existing snapshot path; add a suffix if needed. ## Slice report shape @@ -69,3 +84,26 @@ Do not write to the canonical `.agents/state/review-report.md`. ``` If there are no findings, say so explicitly. + +## Findings snapshot shape + +Only write this file when findings are present. + +```md +# Architecture Findings Snapshot + +- source: PR # / +- date: YYYY-MM-DD +- reviewer: review-architecture + +## Scope +- reviewed subsystems: + - ... + +## Findings +- [ ] warning | design-tension | wrong layer | src/cli/commands/lifecycle.rs:210 + Resolution policy duplicates env semantics that should stay in `src/project_lifecycle/**`. + +## Resolution +- +``` diff --git a/.agents/skills/review-incan-source-quality/SKILL.md b/.agents/skills/review-incan-source-quality/SKILL.md index fdb418487..1a23b0b9e 100644 --- a/.agents/skills/review-incan-source-quality/SKILL.md +++ b/.agents/skills/review-incan-source-quality/SKILL.md @@ -37,7 +37,7 @@ Do not own: - docs truthfulness outside comments/docstrings embedded in source - final branch-clean judgment -## Output artifact +## Output artifacts Write a slice report at: @@ -45,6 +45,20 @@ Write a slice report at: Do not write to the canonical `.agents/state/review-report.md`. +When the source-quality report has findings, also copy the scope and findings into the shared lightweight central snapshot folder outside the repo/worktree: + +- `/tmp/incan-review-findings/` + +Use a deterministic, descriptive filename when possible: + +- `YYYY-MM-DD-pr--incan-source-quality-.md` +- `YYYY-MM-DD-branch--incan-source-quality.md` +- `YYYY-MM-DD-review-incan-source-quality.md` when no PR or branch context is known + +Do not create a snapshot for clean reviews. The snapshot is raw corpus for later analysis, not canonical guidance. +Treat `/tmp/incan-review-findings/` as an append-only central corpus for local review work: create new snapshot files, but do not delete, overwrite, prune, or "clean up" existing snapshots unless the user explicitly asks for that exact maintenance. +Snapshot content is evidence, not a fix log. Preserve the original finding blocks verbatim, including severity, category, file path, line reference, and explanatory text. If the finding is fixed before the snapshot is written, keep the original finding as observed and add a separate `Resolution` note after it; do not replace the evidence with a resolved checklist item or a summary of the fix. + ## Review standard Treat touched Incan source as user-facing language showcase code, especially under `crates/incan_stdlib/stdlib/`, examples, fixtures that teach behavior, and RFC-backed language features. @@ -136,6 +150,7 @@ Flag Incan source that has: 8. Inspect comments/docstrings last as part of source quality, not as a separate docs-only pass. Short or non-descriptive docstrings are findings even when every declaration technically has one. 9. For each finding, explain what a Pythonic/Incan-native version would make clearer. Do not demand style churn when the existing shape is already direct and readable. 10. Stay report-only unless the user explicitly asks for fixes. +11. If the report contains findings, create `/tmp/incan-review-findings/` if needed and write a new snapshot containing only the review source metadata, Scope, and Findings sections. Copy the finding blocks verbatim from `.agents/state/review-report.incan-source-quality.md`; do not generalize them into policy or rewrite them as fix summaries. Preserve exact file:line evidence when the report has it; if a finding is only file-level, make that explicit in the finding text. Do not overwrite an existing snapshot path; add a suffix if needed. ## Slice report shape @@ -169,3 +184,26 @@ Finding severities: - `note`: cleanup is optional but useful if the file is already being edited. If there are no findings, say so explicitly. + +## Findings snapshot shape + +Only write this file when findings are present. + +```md +# Incan Source Quality Findings Snapshot + +- source: PR # / +- date: YYYY-MM-DD +- reviewer: review-incan-source-quality + +## Scope +- assigned files: + - crates/incan_stdlib/stdlib/uuid.incn + +## Findings +- [ ] warning | source-quality | Rust-shaped sentinel read | crates/incan_stdlib/stdlib/uuid.incn:117 + The function initializes a placeholder byte and overwrites it from a match arm. A direct helper returning `Result[u8, UuidError]` would read like authored Incan rather than generated Rust-shaped control flow. + +## Resolution +- +``` diff --git a/workspaces/docs-site/docs/RFCs/092_interactive_runtime_stdlib_contracts.md b/workspaces/docs-site/docs/RFCs/092_interactive_runtime_stdlib_contracts.md index b259c14c0..3df822c9e 100644 --- a/workspaces/docs-site/docs/RFCs/092_interactive_runtime_stdlib_contracts.md +++ b/workspaces/docs-site/docs/RFCs/092_interactive_runtime_stdlib_contracts.md @@ -48,6 +48,7 @@ This RFC narrows the problem: Incan owns the contracts that make interactive run - Making WASM the default runtime posture. WASM may be one target capability, not the definition of interactive runtime support. - Defining native JSX, `html()` parsing, a component DSL, or a browser router in this RFC. - Defining GPU algorithms, shader language semantics, scene-graph APIs, physics engines, or rendering engines. +- Defining no-std/freestanding targets, kernel support, unsafe/layout controls, panic strategy, or allocator strategy. Runtime target manifests may inform that later work, but this RFC is not the freestanding/kernel RFC. - Replacing RFC 037 handler semantics. - Committing to a specific Rust web framework, JS framework, graphics crate, or bundler as the public contract. diff --git a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md index 2a2981b1b..57d470d3d 100644 --- a/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md +++ b/workspaces/docs-site/docs/RFCs/104_ambient_runtime_capabilities_and_receipts.md @@ -71,6 +71,7 @@ The key design constraint is usability. This RFC must not turn ordinary Incan in - This RFC does not require every function type to include a capability parameter or effect row. - This RFC does not make imports fail merely because the current run has not granted a capability. - This RFC does not define a complete operating-system sandbox. +- This RFC does not define no-std/freestanding target profiles, kernel support, unsafe/layout controls, panic strategy, or allocator strategy. Capability and receipt metadata may inform those later RFCs, but this RFC is not the freestanding/kernel RFC. - This RFC does not guarantee perfect deterministic replay for external systems. - This RFC does not replace `std.telemetry`, `std.logging`, diagnostics, or semantic inspection. - This RFC does not require every package to publish capability metadata. diff --git a/workspaces/docs-site/docs/roadmap.md b/workspaces/docs-site/docs/roadmap.md index ea52040e5..2385ea212 100644 --- a/workspaces/docs-site/docs/roadmap.md +++ b/workspaces/docs-site/docs/roadmap.md @@ -24,12 +24,14 @@ Incan's current direction is: That means Incan should not compete as a small systems language or as a generic Python clone. The compiler, standard library, and tooling should make domain packages, capability metadata, policy, generated artifacts, diagnostics, and backend facts inspectable by humans and agents. -The near-term roadmap is therefore split into four release lanes: +The near-term roadmap is therefore split into six release lanes: - Tooling and first-contact inspection. - Backend replacement foundation. - Backend cutover. - Broader feature reopening after the compiler architecture is no longer split between old and new semantic paths. +- Freestanding target foundations. +- Kernel capability proof before 1.0 stabilization. ## Release Milestones @@ -125,11 +127,64 @@ Examples of deferred lanes: - trait/newtype language features not required by backend cutover. - broader editor and package lifecycle work. +0.7 should not absorb freestanding/kernel primitives by default. That work needs its own release lanes so feature reopening does not become the place where unsafe, layout, target, runtime, and kernel proof work all land at once. + +### 0.8 Release: freestanding foundations + +The 0.8 milestone defines the compiler, runtime, ABI, and package foundations needed for freestanding targets. It should make low-level targets possible without promising a production kernel or stabilizing every low-level surface. + +The release should answer how Incan code can compile without assuming hosted `std`, a process environment, filesystem access, threads, default allocator availability, or ordinary hosted panic behavior. + +Expected scope: + +- freestanding target profiles and capability manifests; +- runtime layering across `core`, `alloc`, hosted `std`, and future kernel-facing APIs; +- no-std/freestanding build mode; +- panic strategy and allocator hooks; +- ABI/layout/repr/alignment/calling-convention controls; +- an explicit unsafe model for raw pointers, volatile access, MMIO, and low-level intrinsics; +- package metadata for freestanding compatibility. + +Core tracking issues: + +- [#681](https://github.com/dannys-code-corner/incan/issues/681): RFC proposal for freestanding targets and runtime layering. +- [#682](https://github.com/dannys-code-corner/incan/issues/682): RFC proposal for unsafe blocks and low-level operations. +- [#683](https://github.com/dannys-code-corner/incan/issues/683): RFC proposal for representation, layout, and calling convention controls. +- [#684](https://github.com/dannys-code-corner/incan/issues/684): stdlib/runtime layer inventory for freestanding foundations. +- [#685](https://github.com/dannys-code-corner/incan/issues/685): freestanding target profiles and runtime requirement reports. +- [#686](https://github.com/dannys-code-corner/incan/issues/686): no-std freestanding build mode and restricted artifact smoke test. +- [#687](https://github.com/dannys-code-corner/incan/issues/687): unsafe low-level operation surface v0. +- [#688](https://github.com/dannys-code-corner/incan/issues/688): layout, repr, and calling-convention metadata v0. +- [#689](https://github.com/dannys-code-corner/incan/issues/689): panic strategy and allocator hooks for freestanding targets. + +0.8 is successful when Incan can compile a restricted freestanding artifact and report which runtime, allocator, panic, target, and ABI capabilities it requires. + +### 0.9 Release: kernel capability proof + +The 0.9 milestone is the vertical proof that the freestanding foundations work under real low-level pressure. It should boot a tiny Incan-authored kernel under an emulator, not ship a production operating system. + +Expected scope: + +- minimal architecture support layer; +- linker and boot configuration; +- QEMU runner and smoke harness; +- serial output; +- panic halt/report path; +- allocator hookup; +- MMIO/volatile/raw pointer use; +- one interrupt, timer, or simple task proof. + +Core tracking issues: + +- [#690](https://github.com/dannys-code-corner/incan/issues/690): QEMU tiny kernel capability proof. + +0.9 is successful when Incan can build and boot a tiny freestanding kernel under QEMU with Incan-authored init logic and a concrete low-level capability proof. + ### 1.0 Release: stabilization and public contracts -The 1.0 milestone consolidates the post-cutover compiler architecture, ABI/package direction, tooling contracts, stdlib maturity, ecosystem workflows, and documentation into a coherent public surface. +The 1.0 milestone consolidates the post-cutover compiler architecture, ABI/package direction, tooling contracts, stdlib maturity, ecosystem workflows, freestanding lessons, and documentation into a coherent public surface. -1.0 should describe what Incan is, what it guarantees, how packages and generated artifacts are consumed, and where Rust-facing interop boundaries are stable. +1.0 should describe what Incan is, what it guarantees, how packages and generated artifacts are consumed, where Rust-facing interop boundaries are stable, and which freestanding/kernel-facing surfaces are stable, experimental, or intentionally deferred. ## Status by Area From 6e9a85d65f29325e6bd4a119683cdd67d90bd4d6 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 25 May 2026 14:05:14 +0200 Subject: [PATCH 40/58] bugfix - rebase multi-file test batch spans (#692) (#693) --- src/cli/test_runner/execution.rs | 23 +++++++++++++- tests/integration_tests.rs | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 2c0fb0b69..1716829bd 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -454,6 +454,24 @@ fn partition_collision_free_file_groups( .collect() } +fn rebase_token_spans(tokens: &mut [lexer::Token], source_offset: usize) { + if source_offset == 0 { + return; + } + + for token in tokens { + token.span.start = token.span.start.saturating_add(source_offset); + token.span.end = token.span.end.saturating_add(source_offset); + if let lexer::TokenKind::FString(parts) = &mut token.kind { + for part in parts { + if let lexer::FStringPart::Expr { offset, .. } = part { + *offset = offset.saturating_add(source_offset); + } + } + } + } +} + /// Parse each source file in a generated test batch independently, then merge declarations for the shared harness. /// /// The parser's `module tests:` cardinality rule is intentionally per source file. A worker batch may contain several @@ -466,12 +484,14 @@ fn parse_test_batch_sources( let mut declarations = Vec::new(); let mut warnings = Vec::new(); let mut rust_module_path = None; + let mut source_offset = 0usize; let source_path = batch_sources .first() .map(|(path, _)| path.to_string_lossy().to_string()); for (path, source) in batch_sources { - let tokens = lexer::lex(source).map_err(|e| format!("Lexer error in {}: {:?}", path.display(), e))?; + let mut tokens = lexer::lex(source).map_err(|e| format!("Lexer error in {}: {:?}", path.display(), e))?; + rebase_token_spans(&mut tokens, source_offset); let parsed = parser::parse_with_context_and_surfaces( &tokens, Some(path.to_string_lossy().as_ref()), @@ -490,6 +510,7 @@ fn parse_test_batch_sources( } warnings.extend(parsed.warnings); declarations.extend(parsed.declarations); + source_offset = source_offset.saturating_add(source.len()).saturating_add(1); } Ok(Program { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 582d58966..87dd9cddf 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -7454,6 +7454,57 @@ def test_b() -> None: Ok(()) } + #[test] + fn e2e_cross_file_batch_rebases_spans_for_type_info_issue692() -> Result<(), Box> { + fn source_with_call_offset(header: &str, call_prefix: &str, call_and_tail: &str, offset: usize) -> String { + let fixed_len = header.len() + call_prefix.len(); + assert!( + offset >= fixed_len + 6, + "test fixture offset leaves no room for padding" + ); + let padding = format!(" #{}\n", "x".repeat(offset - fixed_len - 6)); + format!("{header}{padding}{call_prefix}{call_and_tail}") + } + + let target_offset = 320; + let dir = write_test_project( + "test_constructor_marker.incn", + &source_with_call_offset( + "model Box:\n value: int\n\ndef test_type_constructor() -> None:\n", + " item = ", + "Box(value=1)\n assert item.value == 1\n", + target_offset, + ), + ); + std::fs::write( + dir.join("test_zero_arg_call.incn"), + source_with_call_offset( + "def tap() -> str:\n return \"ok\"\n\ndef test_zero_arg_call_in_list() -> None:\n", + " values = [", + "tap()]\n assert values[0] == \"ok\"\n", + target_offset, + ), + )?; + + let output = run_incan_test(&dir); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "expected same-span constructor and zero-argument calls from different files not to share type-info facts.\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr, + ); + assert!( + stdout.contains("test_constructor_marker.incn::test_type_constructor") + && stdout.contains("test_zero_arg_call.incn::test_zero_arg_call_in_list"), + "expected both files to run in one directory test batch.\nstdout:\n{}", + stdout, + ); + Ok(()) + } + #[test] fn e2e_imported_default_expression_expands_with_required_scope_issue395() -> Result<(), Box> { From d02436f36393fda309f06c6410170aac23955f8a Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 25 May 2026 15:16:33 +0200 Subject: [PATCH 41/58] feature - materialize decorator metadata projections (#694, #695) (#696) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/cli/commands/build.rs | 4 +- src/cli/commands/tools.rs | 95 +++- src/frontend/api_metadata.rs | 535 +++++++++++++++++- src/frontend/library_exports.rs | 110 +++- src/library_manifest/tests.rs | 61 ++ src/library_manifest/validation.rs | 18 + tests/cli_integration.rs | 95 ++++ .../docs-site/docs/release_notes/0_3.md | 4 +- .../tooling/reference/checked_api_metadata.md | 12 +- 11 files changed, 913 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bca87b1b4..cb0a5332e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc16" +version = "0.3.0-rc17" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index ef39dc18f..fa3192dce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc16" +version = "0.3.0-rc17" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/cli/commands/build.rs b/src/cli/commands/build.rs index 941d9b50c..a91cdcc34 100644 --- a/src/cli/commands/build.rs +++ b/src/cli/commands/build.rs @@ -13,7 +13,7 @@ use crate::cli::{CliError, CliResult, ExitCode}; use crate::dependency_resolver::{resolve_dependencies, resolve_reachable_dependencies}; use crate::frontend::api_metadata::{ CHECKED_API_METADATA_SCHEMA_VERSION, CheckedApiMetadataPackage, CheckedApiPackageIdentity, - collect_checked_api_metadata, validate_checked_api_docstrings, + collect_checked_api_metadata, materialize_api_alias_projections, validate_checked_api_docstrings, }; use crate::frontend::ast::{Declaration, Decorator, ImportKind, Span, Spanned}; use crate::frontend::contract_metadata::{ContractMetadataPackage, read_project_model_bundles}; @@ -835,6 +835,8 @@ pub fn build_library( return Err(CliError::failure(all_errors.trim_end())); } + materialize_api_alias_projections(&mut api_metadata_modules); + for diagnostic in validate_checked_api_docstrings(&api_metadata_modules) { if let Some(module) = modules .iter() diff --git a/src/cli/commands/tools.rs b/src/cli/commands/tools.rs index e801aff24..dca8c176d 100644 --- a/src/cli/commands/tools.rs +++ b/src/cli/commands/tools.rs @@ -13,7 +13,8 @@ use crate::cli::prelude::ParsedModule; use crate::cli::{CliError, CliResult, ExitCode}; use crate::frontend::api_metadata::{ ApiDeclaration, ApiFunction, ApiPartial, CHECKED_API_METADATA_SCHEMA_VERSION, CheckedApiMetadataPackage, - CheckedApiPackageIdentity, collect_checked_api_metadata, validate_checked_api_docstrings, + CheckedApiPackageIdentity, collect_checked_api_metadata, materialize_api_alias_projections, + validate_checked_api_docstrings, }; use crate::frontend::contract_metadata::{ CanonicalModelBundle, read_model_bundles_from_json, read_project_model_bundles, @@ -417,6 +418,8 @@ fn collect_api_metadata_package(path: &Path) -> CliResult Result<(), Box> { + let tmp = tempfile::tempdir()?; + let src = tmp.path().join("src"); + let operators = src.join("functions").join("operators"); + fs::create_dir_all(&operators)?; + fs::write( + tmp.path().join("incan.toml"), + r#" +[project] +name = "metadata_registry" +version = "0.1.0" +"#, + )?; + fs::write( + src.join("registry.incn"), + r#" +pub def registered[F](spec: str) -> ((F) -> F): + return (func) => func +"#, + )?; + fs::write( + operators.join("eq.incn"), + r#" +from registry import registered + +pub model ColumnExpr: + pub name: str + +@registered("equal") +pub def eq(left: ColumnExpr, right: ColumnExpr) -> ColumnExpr: + return left +"#, + )?; + fs::write( + operators.join("mod.incn"), + "pub from functions.operators.eq import eq\n", + )?; + fs::write(src.join("lib.incn"), "pub from functions.operators.mod import eq\n")?; + + let package = collect_api_metadata_package(tmp.path())?; + let lib_alias = package + .modules + .iter() + .find(|module| module.module_path == vec!["lib".to_string()]) + .and_then(|module| { + module.declarations.iter().find_map(|decl| match decl { + ApiDeclaration::Alias(alias) if alias.name == "eq" => Some(alias), + _ => None, + }) + }) + .ok_or("expected lib facade alias")?; + let projection = lib_alias + .projected_function + .as_ref() + .ok_or("expected projected function metadata on facade alias")?; + + assert_eq!(projection.callable.name, "eq"); + assert_eq!( + projection.source_path, + vec![ + "functions".to_string(), + "operators".to_string(), + "eq".to_string(), + "eq".to_string(), + ] + ); + assert_eq!( + projection + .callable + .params + .iter() + .map(|param| param.name.as_str()) + .collect::>(), + vec!["left", "right"] + ); + assert!( + projection.decorators.iter().any(|decorator| { + decorator.path == vec!["registry".to_string(), "registered".to_string()] + && decorator + .decorated_callable + .as_ref() + .is_some_and(|callable| callable.name == "eq") + }), + "expected projected decorator metadata with decorated callable context, got {projection:?}" + ); + Ok(()) + } + #[test] fn cargo_config_hints_detect_vendor_source_replacement() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/src/frontend/api_metadata.rs b/src/frontend/api_metadata.rs index 61ed54800..608c53fb2 100644 --- a/src/frontend/api_metadata.rs +++ b/src/frontend/api_metadata.rs @@ -9,9 +9,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde::{Deserialize, Serialize}; use crate::frontend::ast::{ - ClassDecl, Declaration, Decorator, DecoratorArg, DecoratorArgValue, EnumDecl, Expr, FieldDecl, FunctionDecl, - ImportDecl, ImportItem, ImportKind, MethodDecl, ModelDecl, NewtypeDecl, Program, Span, Spanned, Statement, - TraitDecl, TypeAliasDecl, Visibility, + CallArg, ClassDecl, Declaration, Decorator, DecoratorArg, DecoratorArgValue, DictEntry, EnumDecl, Expr, FieldDecl, + FunctionDecl, ImportDecl, ImportItem, ImportKind, ListEntry, MethodDecl, ModelDecl, NewtypeDecl, Program, Span, + Spanned, Statement, TraitDecl, TypeAliasDecl, Visibility, }; use crate::frontend::decorator_resolution; use crate::frontend::diagnostics::CompileError; @@ -21,6 +21,7 @@ use crate::frontend::library_exports::{ CheckedPartialTargetKind, CheckedPresetValue, CheckedTraitExport, CheckedTypeAliasExport, CheckedTypeBound, CheckedTypeParam, collect_checked_public_exports, }; +use crate::frontend::module::canonicalize_source_module_segments; use crate::frontend::typechecker::{ConstValue, TypeChecker}; use crate::library_manifest::{ EnumValueExport, EnumValueTypeExport, FieldExport, ParamExport, ParamKindExport, PartialPresetExport, @@ -208,6 +209,8 @@ pub struct ApiAlias { pub name: String, pub anchor: SourceAnchor, pub target_path: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projected_function: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -244,7 +247,30 @@ pub struct DecoratorMetadata { pub path: Vec, pub source_name: String, pub anchor: SourceSpan, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub type_args: Vec, pub args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decorated_callable: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiProjectedFunction { + pub source_path: Vec, + pub callable: ApiCallableMetadata, + pub decorators: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiCallableMetadata { + pub name: String, + pub anchor: SourceAnchor, + pub type_params: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver: Option, + pub params: Vec, + pub return_type: TypeRef, + pub is_async: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -264,6 +290,20 @@ pub enum DecoratorValue { name: String, value: Option, }, + SymbolRef { + path: Vec, + }, + List { + items: Vec, + }, + Dict { + entries: Vec, + }, + Call { + callee: Vec, + type_args: Vec, + args: Vec, + }, Type { ty: TypeRef, }, @@ -272,6 +312,21 @@ pub enum DecoratorValue { }, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DecoratorDictEntry { + pub key: DecoratorValue, + pub value: DecoratorValue, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DecoratorCallArgMetadata { + Positional { value: DecoratorValue }, + Named { name: String, value: DecoratorValue }, + PositionalUnpack { value: DecoratorValue }, + KeywordUnpack { value: DecoratorValue }, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "snake_case")] pub enum SafeMetadataValue { @@ -443,6 +498,7 @@ pub fn collect_checked_api_metadata( name: alias.name.clone(), anchor: anchor(&module_path, &alias.name, decl.span), target_path: alias.target.segments.clone(), + projected_function: None, })); } Declaration::Partial(partial) if public(partial.visibility) => { @@ -468,6 +524,106 @@ pub fn collect_checked_api_metadata( } } +/// Attach checked function projections to public aliases that target decorated or ordinary public functions. +/// +/// Metadata package consumers should not need to force producer module initialization just to discover declaration-side +/// decorator facts. This projection pass resolves aliases across the already checked API package and carries the target +/// function's decorators and checked callable shape onto facade aliases. +pub fn materialize_api_alias_projections(modules: &mut [CheckedApiMetadata]) { + let mut projections = HashMap::new(); + let mut aliases = Vec::new(); + + for module in modules.iter() { + for declaration in &module.declarations { + match declaration { + ApiDeclaration::Function(function) => { + projections.insert( + declaration_path(&module.module_path, &function.name), + ApiProjectedFunction { + source_path: declaration_path(&module.module_path, &function.name), + callable: callable_from_function(function), + decorators: function.decorators.clone(), + }, + ); + } + ApiDeclaration::Alias(alias) => aliases.push(ApiAliasProjectionRequest { + path: declaration_path(&module.module_path, &alias.name), + target_path: normalized_api_target_path(&alias.target_path), + name: alias.name.clone(), + anchor: alias.anchor.clone(), + }), + _ => {} + } + } + } + + let mut changed = true; + while changed { + changed = false; + for alias in &aliases { + if projections.contains_key(&alias.path) { + continue; + } + if let Some(target) = projections.get(&alias.target_path) { + projections.insert(alias.path.clone(), projected_function_for_alias(alias, target)); + changed = true; + } + } + } + + for module in modules { + for declaration in &mut module.declarations { + if let ApiDeclaration::Alias(alias) = declaration { + let alias_path = declaration_path(&module.module_path, &alias.name); + alias.projected_function = projections.get(&alias_path).cloned(); + } + } + } +} + +#[derive(Debug)] +struct ApiAliasProjectionRequest { + path: Vec, + target_path: Vec, + name: String, + anchor: SourceAnchor, +} + +fn declaration_path(module_path: &[String], name: &str) -> Vec { + let mut path = module_path.to_vec(); + path.push(name.to_string()); + path +} + +fn normalized_api_target_path(path: &[String]) -> Vec { + if path.first().is_some_and(|segment| segment == "crate") { + return path[1..].to_vec(); + } + path.to_vec() +} + +fn callable_from_function(function: &ApiFunction) -> ApiCallableMetadata { + ApiCallableMetadata { + name: function.name.clone(), + anchor: function.anchor.clone(), + type_params: function.type_params.clone(), + receiver: None, + params: function.params.clone(), + return_type: function.return_type.clone(), + is_async: function.is_async, + } +} + +fn projected_function_for_alias( + alias: &ApiAliasProjectionRequest, + target: &ApiProjectedFunction, +) -> ApiProjectedFunction { + let mut projected = target.clone(); + projected.callable.name = alias.name.clone(); + projected.callable.anchor = alias.anchor.clone(); + projected +} + fn checked_kind<'a>(exports: &'a HashMap, name: &str) -> Option<&'a CheckedExportKind> { exports.get(name).map(|export| &export.kind) } @@ -553,13 +709,32 @@ fn api_function( module_path: &[String], ) -> ApiFunction { let docstring = function_docstring(&function.body); + let callable = api_callable_for_function(function, span, export, checker, module_path); ApiFunction { - name: export.name.clone(), - anchor: anchor(module_path, &export.name, span), + name: callable.name.clone(), + anchor: callable.anchor.clone(), docstring_sections: parse_docstring(docstring.as_deref()), docstring, - decorators: decorators_metadata(&function.decorators, checker), + decorators: decorators_metadata(&function.decorators, checker, Some(&callable)), + type_params: callable.type_params, + params: callable.params, + return_type: callable.return_type, + is_async: callable.is_async, + } +} + +fn api_callable_for_function( + function: &FunctionDecl, + span: Span, + export: &CheckedFunctionExport, + checker: &TypeChecker, + module_path: &[String], +) -> ApiCallableMetadata { + ApiCallableMetadata { + name: export.name.clone(), + anchor: anchor(module_path, &export.name, span), type_params: type_params(&export.type_params), + receiver: None, params: source_function_params(function, checker), return_type: source_function_return_type(function, checker), is_async: function.is_async(), @@ -611,7 +786,7 @@ fn api_model( anchor: anchor(module_path, &export.name, span), docstring_sections: parse_docstring(docstring.as_deref()), docstring, - decorators: decorators_metadata(&model.decorators, checker), + decorators: decorators_metadata(&model.decorators, checker, None), type_params: type_params(&export.type_params), traits: export.traits.clone(), derives: export.derives.clone(), @@ -633,7 +808,7 @@ fn api_class( anchor: anchor(module_path, &export.name, span), docstring_sections: parse_docstring(docstring.as_deref()), docstring, - decorators: decorators_metadata(&class.decorators, checker), + decorators: decorators_metadata(&class.decorators, checker, None), type_params: type_params(&export.type_params), extends: export.extends.clone(), traits: export.traits.clone(), @@ -657,7 +832,7 @@ fn api_trait( anchor: anchor(module_path, &export.name, span), docstring_sections: parse_docstring(docstring.as_deref()), docstring, - decorators: decorators_metadata(&trait_decl.decorators, checker), + decorators: decorators_metadata(&trait_decl.decorators, checker, None), type_params: type_params(&export.type_params), supertraits: export.supertraits.iter().map(type_bound).collect(), requires: export @@ -689,7 +864,7 @@ fn api_enum( anchor: anchor(module_path, &export.name, span), docstring_sections: parse_docstring(docstring.as_deref()), docstring, - decorators: decorators_metadata(&enum_decl.decorators, checker), + decorators: decorators_metadata(&enum_decl.decorators, checker, None), type_params: type_params(&export.type_params), value_type: export.value_type.map(|value_type| match value_type { crate::frontend::symbols::ValueEnumBacking::Str => EnumValueTypeExport::Str, @@ -732,7 +907,7 @@ fn api_newtype( anchor: anchor(module_path, &export.name, span), docstring_sections: parse_docstring(docstring.as_deref()), docstring, - decorators: decorators_metadata(&newtype.decorators, checker), + decorators: decorators_metadata(&newtype.decorators, checker, None), type_params: type_params(&export.type_params), is_rusttype: export.is_rusttype, underlying: type_ref_from_resolved(&export.underlying), @@ -775,7 +950,8 @@ fn api_const( fn api_aliases(import: &ImportDecl, span: Span, module_path: &[String]) -> Vec { match &import.kind { ImportKind::From { module, items } => { - let base_path = decorator_resolution::path_segments_with_prefix(module); + let base_path = + canonicalize_source_module_segments(&decorator_resolution::path_segments_with_prefix(module)); aliases_from_items(items, base_path, span, module_path) } ImportKind::RustFrom { @@ -812,6 +988,7 @@ fn aliases_from_items( anchor: anchor(module_path, &name, span), name, target_path, + projected_function: None, } }) .collect() @@ -841,12 +1018,9 @@ fn methods( continue; }; let docstring = method.node.body.as_ref().and_then(|body| function_docstring(body)); - out.push(ApiMethod { + let callable = ApiCallableMetadata { name: checked.name.clone(), anchor: anchor(module_path, &format!("{owner}.{}", checked.name), method.span), - docstring_sections: parse_docstring(docstring.as_deref()), - docstring, - decorators: decorators_metadata(&method.node.decorators, checker), type_params: type_params(&checked.type_params), receiver: checked.receiver.map(|receiver| match receiver { crate::frontend::ast::Receiver::Immutable => ReceiverExport::Immutable, @@ -855,6 +1029,18 @@ fn methods( params: params(&checked.params), return_type: type_ref_from_resolved(&checked.return_type), is_async: checked.is_async, + }; + out.push(ApiMethod { + name: callable.name.clone(), + anchor: callable.anchor.clone(), + docstring_sections: parse_docstring(docstring.as_deref()), + docstring, + decorators: decorators_metadata(&method.node.decorators, checker, Some(&callable)), + type_params: callable.type_params, + receiver: callable.receiver, + params: callable.params, + return_type: callable.return_type, + is_async: callable.is_async, has_body: checked.has_body, }); } @@ -1001,7 +1187,11 @@ fn fields_in_source_order(ast_fields: &[Spanned], checked_fields: &[C out } -fn decorators_metadata(decorators: &[Spanned], checker: &TypeChecker) -> Vec { +fn decorators_metadata( + decorators: &[Spanned], + checker: &TypeChecker, + decorated_callable: Option<&ApiCallableMetadata>, +) -> Vec { decorators .iter() .map(|decorator| { @@ -1010,12 +1200,24 @@ fn decorators_metadata(decorators: &[Spanned], checker: &TypeChecker) path: resolved, source_name: decorator.node.path.segments.join("."), anchor: source_span(decorator.span), + type_args: decorator + .node + .type_args + .iter() + .map(|type_arg| { + type_ref_from_resolved(&crate::frontend::symbols::resolve_type( + &type_arg.node, + &checker.symbols, + )) + }) + .collect(), args: decorator .node .args .iter() .map(|arg| decorator_arg_metadata(arg, checker)) .collect(), + decorated_callable: decorated_callable.cloned(), } }) .collect() @@ -1048,12 +1250,134 @@ fn decorator_expr_value(expr: &Spanned, checker: &TypeChecker) -> Decorato name: name.clone(), value: checker.type_info().const_value(name).map(safe_value_from_const), }, + Expr::Field(base, field) => { + let mut path = decorator_expr_path(&base.node); + if path.is_empty() { + DecoratorValue::Unsupported { + reason: "decorator field expression is not a symbolic path".to_string(), + } + } else { + path.push(field.clone()); + DecoratorValue::SymbolRef { path } + } + } + Expr::List(entries) => DecoratorValue::List { + items: entries + .iter() + .map(|entry| match entry { + ListEntry::Element(value) => decorator_expr_value(value, checker), + ListEntry::Spread(value) => DecoratorValue::Unsupported { + reason: format!( + "decorator list spread `{}` is not declaration-safe metadata", + decorator_expr_label(&value.node) + ), + }, + }) + .collect(), + }, + Expr::Dict(entries) => { + let mut metadata_entries = Vec::new(); + for entry in entries { + match entry { + DictEntry::Pair(key, value) => metadata_entries.push(DecoratorDictEntry { + key: decorator_expr_value(key, checker), + value: decorator_expr_value(value, checker), + }), + DictEntry::Spread(value) => metadata_entries.push(DecoratorDictEntry { + key: DecoratorValue::Unsupported { + reason: "decorator dict spread has no declaration-safe key".to_string(), + }, + value: decorator_expr_value(value, checker), + }), + } + } + DecoratorValue::Dict { + entries: metadata_entries, + } + } + Expr::Call(callee, type_args, args) => { + let path = decorator_expr_path(&callee.node); + if path.is_empty() { + return DecoratorValue::Unsupported { + reason: "decorator call callee is not a symbolic path".to_string(), + }; + } + DecoratorValue::Call { + callee: path, + type_args: type_args + .iter() + .map(|type_arg| { + type_ref_from_resolved(&crate::frontend::symbols::resolve_type( + &type_arg.node, + &checker.symbols, + )) + }) + .collect(), + args: args + .iter() + .map(|arg| decorator_call_arg_metadata(arg, checker)) + .collect(), + } + } + Expr::Constructor(name, args) => DecoratorValue::Call { + callee: vec![name.clone()], + type_args: Vec::new(), + args: args + .iter() + .map(|arg| decorator_call_arg_metadata(arg, checker)) + .collect(), + }, _ => DecoratorValue::Unsupported { reason: "decorator argument is not a literal, const reference, or type".to_string(), }, } } +fn decorator_call_arg_metadata(arg: &CallArg, checker: &TypeChecker) -> DecoratorCallArgMetadata { + match arg { + CallArg::Positional(value) => DecoratorCallArgMetadata::Positional { + value: decorator_expr_value(value, checker), + }, + CallArg::Named(name, value) => DecoratorCallArgMetadata::Named { + name: name.clone(), + value: decorator_expr_value(value, checker), + }, + CallArg::PositionalUnpack(value) => DecoratorCallArgMetadata::PositionalUnpack { + value: decorator_expr_value(value, checker), + }, + CallArg::KeywordUnpack(value) => DecoratorCallArgMetadata::KeywordUnpack { + value: decorator_expr_value(value, checker), + }, + } +} + +fn decorator_expr_path(expr: &Expr) -> Vec { + match expr { + Expr::Ident(name) => vec![name.clone()], + Expr::Field(base, field) => { + let mut path = decorator_expr_path(&base.node); + if path.is_empty() { + return Vec::new(); + } + path.push(field.clone()); + path + } + _ => Vec::new(), + } +} + +fn decorator_expr_label(expr: &Expr) -> &'static str { + match expr { + Expr::Ident(_) => "identifier", + Expr::Literal(_) => "literal", + Expr::Call(_, _, _) | Expr::Constructor(_, _) => "call", + Expr::List(_) => "list", + Expr::Dict(_) => "dict", + Expr::Field(_, _) => "field", + _ => "expression", + } +} + /// Convert a literal into the safe metadata subset used by checked API output. fn safe_value_from_literal(literal: &crate::frontend::ast::Literal) -> SafeMetadataValue { match literal { @@ -1986,6 +2310,183 @@ pub def col(name: str) -> ColumnExpr: Ok(()) } + #[test] + fn checked_api_metadata_projects_decorated_callable_context_issue694() -> Result<(), String> { + let source = r#" +const EQUAL_FUNCTION_ANCHOR = "substrait.equal" + +model ColumnExpr: + name: str + +model FunctionLifecycle: + since: str + changed: List[str] + deprecated: Option[str] + +def extension_mapping(name: str, anchor: str) -> str: + return name + +def deterministic_spec(kind: str, lifecycle: FunctionLifecycle, mapping: str) -> str: + return kind + +def registered[F](spec: str) -> ((F) -> F): + return (func) => func + +@registered(deterministic_spec("scalar", FunctionLifecycle(since="v0.3", changed=[], deprecated=None), extension_mapping("equal", EQUAL_FUNCTION_ANCHOR))) +pub def eq(left: ColumnExpr, right: ColumnExpr) -> ColumnExpr: + return left +"#; + let metadata = metadata_for(source).map_err(|errs| format!("{errs:?}"))?; + let function = metadata + .declarations + .iter() + .find_map(|decl| match decl { + ApiDeclaration::Function(function) if function.name == "eq" => Some(function), + _ => None, + }) + .ok_or_else(|| "expected decorated function metadata".to_string())?; + let decorator = function + .decorators + .first() + .ok_or_else(|| "expected decorator metadata".to_string())?; + let callable = decorator + .decorated_callable + .as_ref() + .ok_or_else(|| "expected decorated callable context".to_string())?; + + assert_eq!(callable.name, "eq"); + assert_eq!( + callable + .params + .iter() + .map(|param| (param.name.as_str(), ¶m.ty)) + .collect::>(), + vec![ + ( + "left", + &TypeRef::Named { + name: "ColumnExpr".to_string(), + }, + ), + ( + "right", + &TypeRef::Named { + name: "ColumnExpr".to_string(), + }, + ), + ] + ); + assert_eq!( + callable.return_type, + TypeRef::Named { + name: "ColumnExpr".to_string(), + } + ); + + let [ + DecoratorArgMetadata::Positional { + value: DecoratorValue::Call { callee, args, .. }, + }, + ] = decorator.args.as_slice() + else { + return Err(format!( + "expected structured decorator call metadata, got {decorator:?}" + )); + }; + assert_eq!(callee, &vec!["deterministic_spec".to_string()]); + let lifecycle_args = args + .iter() + .find_map(|arg| match arg { + DecoratorCallArgMetadata::Positional { + value: DecoratorValue::Call { callee, args, .. }, + } if callee == &vec!["FunctionLifecycle".to_string()] => Some(args), + _ => None, + }) + .ok_or_else(|| format!("expected nested lifecycle constructor call metadata, got {args:?}"))?; + assert!( + lifecycle_args.iter().any(|arg| matches!( + arg, + DecoratorCallArgMetadata::Named { + name, + value: DecoratorValue::List { items }, + } if name == "changed" && items.is_empty() + )), + "expected lifecycle `changed=[]` metadata, got {lifecycle_args:?}" + ); + assert!( + lifecycle_args.iter().any(|arg| matches!( + arg, + DecoratorCallArgMetadata::Named { + name, + value: DecoratorValue::Literal { + value: SafeMetadataValue::None, + }, + } if name == "deprecated" + )), + "expected lifecycle `deprecated=None` metadata, got {lifecycle_args:?}" + ); + assert!( + args.iter().any(|arg| matches!( + arg, + DecoratorCallArgMetadata::Positional { + value: DecoratorValue::Call { callee, args, .. }, + } if callee == &vec!["extension_mapping".to_string()] + && args.iter().any(|arg| matches!( + arg, + DecoratorCallArgMetadata::Positional { + value: DecoratorValue::ConstRef { + name, + value: Some(SafeMetadataValue::String(value)), + }, + } if name == "EQUAL_FUNCTION_ANCHOR" && value == "substrait.equal" + )) + )), + "expected nested extension mapping call metadata with checked const ref, got {args:?}" + ); + Ok(()) + } + + #[test] + fn checked_api_metadata_rejects_non_symbolic_decorator_field_metadata() -> Result<(), String> { + let source = r#" +model Holder: + value: str + +def holder() -> Holder: + return Holder(value="equal") + +def registered[F](name: str) -> ((F) -> F): + return (func) => func + +@registered(holder().value) +pub def eq(left: int, right: int) -> int: + return left +"#; + let metadata = metadata_for(source).map_err(|errs| format!("{errs:?}"))?; + let function = metadata + .declarations + .iter() + .find_map(|decl| match decl { + ApiDeclaration::Function(function) if function.name == "eq" => Some(function), + _ => None, + }) + .ok_or_else(|| "expected decorated function metadata".to_string())?; + let [ + DecoratorArgMetadata::Positional { + value: DecoratorValue::Unsupported { reason }, + }, + ] = function.decorators[0].args.as_slice() + else { + return Err(format!( + "expected non-symbolic field decorator argument to stay unsupported, got {:?}", + function.decorators[0].args + )); + }; + + assert_eq!(reason, "decorator field expression is not a symbolic path"); + Ok(()) + } + #[test] fn checked_api_docstring_validation_matches_overloaded_method_by_params() -> Result<(), String> { let source = r#" diff --git a/src/frontend/library_exports.rs b/src/frontend/library_exports.rs index 41a5b5302..b7cafeb64 100644 --- a/src/frontend/library_exports.rs +++ b/src/frontend/library_exports.rs @@ -6,9 +6,12 @@ use std::collections::HashMap; use crate::frontend::ast::{ - AliasDecl, ClassDecl, Declaration, DictEntry, EnumDecl, Expr, FunctionDecl, ListEntry, Literal, ModelDecl, - NewtypeDecl, PartialDecl, Program, Spanned, TraitBound, TraitDecl, TypeAliasDecl, TypeParam, Visibility, + AliasDecl, ClassDecl, Declaration, DictEntry, EnumDecl, Expr, FunctionDecl, ImportDecl, ImportItem, ImportKind, + ListEntry, Literal, ModelDecl, NewtypeDecl, PartialDecl, Program, Spanned, TraitBound, TraitDecl, TypeAliasDecl, + TypeParam, Visibility, }; +use crate::frontend::decorator_resolution; +use crate::frontend::module::canonicalize_source_module_segments; use crate::frontend::symbols::{ CallableParam, ClassInfo, FieldInfo, FunctionInfo, MethodInfo, ModelInfo, NewtypeInfo, ResolvedType, SymbolKind, TraitInfo, TypeBoundInfo, TypeInfo, ValueEnumBacking, ValueEnumValue, VariableInfo, resolve_type, @@ -307,6 +310,9 @@ pub fn collect_checked_public_exports(program: &Program, checker: &TypeChecker) exports.push(export); } } + Declaration::Import(import) if matches!(import.visibility, Visibility::Public) => { + exports.extend(checked_import_exports(import, checker)); + } Declaration::Partial(partial) if matches!(partial.visibility, Visibility::Public) => { if let Some(export) = checked_partial_export(partial, checker) { exports.push(CheckedNamedExport { @@ -326,10 +332,7 @@ pub fn collect_checked_public_exports(program: &Program, checker: &TypeChecker) /// Build a checked public export entry for a module-level alias. fn checked_alias_export(alias: &AliasDecl, checker: &TypeChecker) -> Option { let symbol = checker.lookup_symbol(alias.name.as_str())?; - let projected_function = match &symbol.kind { - SymbolKind::Function(info) => Some(checked_alias_function_export(&alias.name, info)), - _ => None, - }; + let projected_function = checked_projected_function_export(&alias.name, &symbol.kind); Some(CheckedNamedExport { name: alias.name.clone(), kind: CheckedExportKind::Alias(CheckedAliasExport { @@ -340,6 +343,57 @@ fn checked_alias_export(alias: &AliasDecl, checker: &TypeChecker) -> Option Vec { + match &import.kind { + ImportKind::From { module, items } => { + let base_path = + canonicalize_source_module_segments(&decorator_resolution::path_segments_with_prefix(module)); + checked_import_item_exports(items, base_path, checker) + } + ImportKind::RustFrom { + crate_name, + path, + items, + .. + } => { + let mut base_path = vec!["rust".to_string(), crate_name.clone()]; + base_path.extend(path.iter().cloned()); + checked_import_item_exports(items, base_path, checker) + } + ImportKind::PubFrom { library, items } => { + let base_path = vec!["pub".to_string(), library.clone()]; + checked_import_item_exports(items, base_path, checker) + } + _ => Vec::new(), + } +} + +fn checked_import_item_exports( + items: &[ImportItem], + base_path: Vec, + checker: &TypeChecker, +) -> Vec { + items + .iter() + .map(|item| { + let exported_name = item.alias.as_ref().unwrap_or(&item.name).clone(); + let mut target_path = base_path.clone(); + target_path.push(item.name.clone()); + let projected_function = checker + .lookup_symbol(exported_name.as_str()) + .and_then(|symbol| checked_projected_function_export(&exported_name, &symbol.kind)); + CheckedNamedExport { + name: exported_name.clone(), + kind: CheckedExportKind::Alias(CheckedAliasExport { + name: exported_name, + target_path, + projected_function, + }), + } + }) + .collect() +} + /// Build manifest-ready callable metadata for an alias that projects a function. fn checked_alias_function_export(name: &str, info: &FunctionInfo) -> CheckedFunctionExport { CheckedFunctionExport { @@ -351,6 +405,23 @@ fn checked_alias_function_export(name: &str, info: &FunctionInfo) -> CheckedFunc } } +fn checked_projected_function_export(name: &str, kind: &SymbolKind) -> Option { + match kind { + SymbolKind::Function(info) => Some(checked_alias_function_export(name, info)), + SymbolKind::Variable(VariableInfo { + ty: ResolvedType::Function(params, return_type), + .. + }) => Some(CheckedFunctionExport { + name: name.to_string(), + type_params: Vec::new(), + params: params.clone(), + return_type: return_type.as_ref().clone(), + is_async: false, + }), + _ => None, + } +} + /// Build checked export metadata for a public partial callable preset. fn checked_partial_export(partial: &PartialDecl, checker: &TypeChecker) -> Option { let symbol = checker.lookup_symbol(partial.name.as_str())?; @@ -499,6 +570,9 @@ fn checked_preset_path(expr: &Expr) -> Vec { Expr::Ident(name) => vec![name.clone()], Expr::Field(base, field) => { let mut path = checked_preset_path(&base.node); + if path.is_empty() { + return Vec::new(); + } path.push(field.clone()); path } @@ -897,3 +971,27 @@ fn sorted_vec(mut values: Vec) -> Vec { values.sort(); values } + +#[cfg(test)] +mod tests { + use super::*; + use crate::frontend::ast::{Span, Spanned}; + + fn spanned(expr: Expr) -> Spanned { + Spanned::new(expr, Span::default()) + } + + #[test] + fn checked_preset_value_rejects_non_symbolic_field_paths() { + let value = Expr::Field( + Box::new(spanned(Expr::Call( + Box::new(spanned(Expr::Ident("defaults".to_string()))), + Vec::new(), + Vec::new(), + ))), + "method".to_string(), + ); + + assert_eq!(checked_preset_value(&value), CheckedPresetValue::Unsupported); + } +} diff --git a/src/library_manifest/tests.rs b/src/library_manifest/tests.rs index f24963020..659660347 100644 --- a/src/library_manifest/tests.rs +++ b/src/library_manifest/tests.rs @@ -240,6 +240,67 @@ fn manifest_validation_rejects_unsupported_rust_abi_schema_version() { assert!(err.is_err(), "expected unsupported Rust ABI schema to fail"); } +#[test] +fn manifest_validation_rejects_unsupported_api_metadata_package_schema_version() { + let raw = format!( + r#"{{ + "name": "mylib", + "version": "0.1.0", + "incan_version": "{}", + "manifest_format": {}, + "exports": {{}}, + "soft_keywords": {{}}, + "contract_metadata": {{ + "api": {{ + "schema_version": {}, + "package": null, + "modules": [] + }} + }} +}}"#, + crate::version::INCAN_VERSION, + LIBRARY_MANIFEST_FORMAT, + crate::frontend::api_metadata::CHECKED_API_METADATA_SCHEMA_VERSION + 1 + ); + + let err = LibraryManifest::from_json_str(&raw); + assert!(err.is_err(), "expected unsupported API metadata schema to fail"); +} + +#[test] +fn manifest_validation_rejects_unsupported_api_metadata_module_schema_version() { + let raw = format!( + r#"{{ + "name": "mylib", + "version": "0.1.0", + "incan_version": "{}", + "manifest_format": {}, + "exports": {{}}, + "soft_keywords": {{}}, + "contract_metadata": {{ + "api": {{ + "schema_version": {}, + "package": null, + "modules": [ + {{ + "schema_version": {}, + "module_path": ["lib"], + "declarations": [] + }} + ] + }} + }} +}}"#, + crate::version::INCAN_VERSION, + LIBRARY_MANIFEST_FORMAT, + crate::frontend::api_metadata::CHECKED_API_METADATA_SCHEMA_VERSION, + crate::frontend::api_metadata::CHECKED_API_METADATA_SCHEMA_VERSION + 1 + ); + + let err = LibraryManifest::from_json_str(&raw); + assert!(err.is_err(), "expected unsupported API metadata module schema to fail"); +} + #[test] fn manifest_io_round_trip_preserves_rest_parameter_metadata() -> Result<(), Box> { let mut manifest = LibraryManifest::new("mylib", "0.1.0"); diff --git a/src/library_manifest/validation.rs b/src/library_manifest/validation.rs index 5e6eeb5f0..ee3189c3c 100644 --- a/src/library_manifest/validation.rs +++ b/src/library_manifest/validation.rs @@ -15,6 +15,7 @@ use super::{ EnumExport, EnumValueExport, EnumValueTypeExport, LIBRARY_MANIFEST_FORMAT, LibraryManifestError, ParamExport, ParamKindExport, PartialExport, RUST_ABI_SCHEMA_VERSION, VocabProviderManifest, }; +use crate::frontend::api_metadata::CHECKED_API_METADATA_SCHEMA_VERSION; use crate::frontend::contract_metadata::CONTRACT_METADATA_SCHEMA_VERSION; /// Validate one raw manifest payload before it is written or decoded into the semantic model. @@ -69,6 +70,23 @@ fn validate_contract_metadata(raw: &RawLibraryManifest) -> Result<(), LibraryMan metadata .validate() .map_err(|error| LibraryManifestError::Invalid(error.to_string()))?; + + if let Some(api) = &raw.contract_metadata.api { + if api.schema_version != CHECKED_API_METADATA_SCHEMA_VERSION { + return Err(LibraryManifestError::Invalid(format!( + "contract_metadata.api.schema_version {} is unsupported (expected {})", + api.schema_version, CHECKED_API_METADATA_SCHEMA_VERSION + ))); + } + for module in &api.modules { + if module.schema_version != CHECKED_API_METADATA_SCHEMA_VERSION { + return Err(LibraryManifestError::Invalid(format!( + "contract_metadata.api.modules schema_version {} is unsupported (expected {})", + module.schema_version, CHECKED_API_METADATA_SCHEMA_VERSION + ))); + } + } + } Ok(()) } diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 4a85e8f15..650a4a3ab 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1680,6 +1680,101 @@ def main() -> None: Ok(()) } +#[test] +fn build_lib_materializes_facade_decorator_metadata_projection_issue695() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let producer_root = tmp.path().join("metadata_registry"); + let src = producer_root.join("src"); + let operators = src.join("functions").join("operators"); + fs::create_dir_all(&operators)?; + fs::write( + producer_root.join("incan.toml"), + r#"[project] +name = "metadata_registry" +version = "0.1.0" +"#, + )?; + fs::write( + src.join("registry.incn"), + r#"pub def registered[F](spec: str) -> ((F) -> F): + return (func) => func +"#, + )?; + fs::write( + operators.join("eq.incn"), + r#"from registry import registered + +pub model ColumnExpr: + pub name: str + +@registered("equal") +pub def eq(left: ColumnExpr, right: ColumnExpr) -> ColumnExpr: + return left +"#, + )?; + fs::write( + operators.join("mod.incn"), + "pub from functions.operators.eq import eq\n", + )?; + fs::write(src.join("lib.incn"), "pub from functions.operators.mod import eq\n")?; + + let producer_build = run_incan(&producer_root, &["build", "--lib"])?; + assert_success( + &producer_build, + "producer build --lib for decorator metadata projection issue695", + ); + + let manifest_path = producer_root + .join("target") + .join("lib") + .join("metadata_registry.incnlib"); + let manifest: serde_json::Value = serde_json::from_str(&fs::read_to_string(&manifest_path)?)?; + assert!( + manifest.pointer("/exports/aliases/0/projected_function").is_some(), + "reexport-only facade should materialize callable alias projection in manifest exports, got:\n{manifest}" + ); + let api_modules = manifest + .pointer("/contract_metadata/api/modules") + .and_then(|value| value.as_array()) + .ok_or("expected checked API modules in manifest")?; + let lib_alias = api_modules + .iter() + .flat_map(|module| { + module + .pointer("/declarations") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + }) + .find(|decl| { + decl.pointer("/kind").and_then(|value| value.as_str()) == Some("alias") + && decl.pointer("/name").and_then(|value| value.as_str()) == Some("eq") + && decl.pointer("/projected_function").is_some() + }) + .ok_or("expected projected eq alias declaration in checked API metadata")?; + assert_eq!( + lib_alias + .pointer("/projected_function/callable/name") + .and_then(|value| value.as_str()), + Some("eq") + ); + assert_eq!( + lib_alias + .pointer("/projected_function/source_path") + .and_then(|value| value.as_array()) + .map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()), + Some(vec!["functions", "operators", "eq", "eq"]) + ); + assert!( + lib_alias + .pointer("/projected_function/decorators/0/decorated_callable/name") + .and_then(|value| value.as_str()) + == Some("eq"), + "projected decorator metadata should carry decorated callable identity/signature, got:\n{lib_alias}" + ); + Ok(()) +} + #[test] fn test_accepts_public_alias_of_imported_item_issue631() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 1a437723b..9e4dfcabf 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -49,7 +49,7 @@ Use this section as the map. The release note names each larger feature, says wh - **Rust trait adoption from Incan source**: Newtypes and rusttypes can adopt Rust traits with `with Trait`, method-level `for Trait`, and associated type declarations. Read [Rust interop](../language/how-to/rust_interop.md), [Rust types for Python developers](../language/how-to/rust_types_for_python_devs.md), and [`std.traits`](../language/reference/stdlib/traits.md) ([RFC 043], #200). - **Derived and inspected Rust metadata**: Supported `@rust.derive(...)`, associated types, inspected Rust signatures, and metadata-backed call boundaries now survive further through generated calls. Read [Derives and traits](../language/explanation/derives_and_traits.md) and [Rust-shaped confidence](../language/explanation/rust_shaped_confidence.md) ([RFC 041], #175). -- **Checked public API metadata**: Public declarations, aliases, partials, models, enum variants, and selected decorator metadata can be emitted for tools and downstream consumers. Read [Checked API metadata](../tooling/reference/checked_api_metadata.md) and [LSP protocol support](../tooling/reference/lsp_protocol_support.md) ([RFC 048], #205, #438). +- **Checked public API metadata**: Public declarations, aliases, partials, models, enum variants, decorator-backed callable context, and facade alias projections can be emitted for tools and downstream consumers. Read [Checked API metadata](../tooling/reference/checked_api_metadata.md) and [LSP protocol support](../tooling/reference/lsp_protocol_support.md) ([RFC 048], #205, #438, #694, #695). ### Standard Library @@ -106,7 +106,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Cross-module codegen is more predictable**: Imported defaults qualify correctly, same-shaped union wrappers are shared, wide union narrowing lowers fully, keyword-named modules escape consistently, and public submodule reexports work under `src/` (#395, #457, #461, #458, #122, #287). - **Web registration keeps private internals private**: Private route handlers and models are retained for web registration without making them user-visible public API (#117). - **Package exports match ordinary builds**: Public aliases, package-boundary alias consumption, lowercase exported statics, imported static decorator strings, and keyword-named public symbols follow the same rules across build modes (#617, #631, #633, #658, #659). -- **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, and method-call decorator factories are represented in checked metadata more reliably (#636, #638, #640, #669). +- **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, method-call decorator factories, and reexport-only facade projections are represented in checked metadata more reliably (#636, #638, #640, #669, #694, #695). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). ### Formatter And Test Runner diff --git a/workspaces/docs-site/docs/tooling/reference/checked_api_metadata.md b/workspaces/docs-site/docs/tooling/reference/checked_api_metadata.md index 2bb9e4d46..a6f83025b 100644 --- a/workspaces/docs-site/docs/tooling/reference/checked_api_metadata.md +++ b/workspaces/docs-site/docs/tooling/reference/checked_api_metadata.md @@ -163,12 +163,16 @@ The metadata is derived from parsed and typechecked semantics. Public declaratio - public partial callable presets with target provenance, preset metadata, projected callable parameters, return type, and async status - raw docstring text when the declaration or method has a docstring - parsed docstring sections in `docstring_sections`, including summary, parameters, returns, fields, aliases, and decorators -- decorator metadata with resolved decorator paths +- decorator metadata with resolved decorator paths, safe argument projections, and decorated callable context when the decorator is attached to a callable declaration - safe const values for public consts and safe decorator arguments Types use the same structural `TypeRef` encoding as library manifest exports. For example, a non-generic type is encoded as `{"Named": {"name": "str"}}`, while a generic application is encoded as `{"Applied": {"name": "List", "args": [...]}}`. -When decorator processing exposes a public function as a callable-valued binding, metadata follows that checked binding. In that case, function metadata reports the callable binding's parameters and return type rather than the original source signature. Existing decorator metadata remains attached separately through `decorators`, so consumers that inspect marker decorators, safe decorator arguments, or docstring `Decorators:` sections can keep using that lane without inferring binding types from it. +Function metadata keeps the source declaration's public callable surface. For a decorated callable, each decorator entry also carries `decorated_callable`, which contains the decorated declaration's checked public identity, source anchor, type parameters, parameter names and types, return type, receiver when applicable, and async marker. Registry and catalog tooling should read that field instead of asking authors to repeat the decorated function name or signature in decorator arguments. + +Decorator arguments are represented structurally when the compiler can do so without executing user code. Literals, checked const references, symbolic references, lists, dicts, constructors, and ordinary calls can appear as metadata values. Unsupported expressions remain explicit `unsupported` entries. + +Public import aliases can include `projected_function` when the alias target resolves to a public function or callable-valued decorated binding. The projection includes the source declaration path, the callable signature under the alias name, and the source decorators. This lets reexport-only facades expose declaration metadata without no-op loader functions or runtime module initialization hooks. Public partial declarations use `kind: "partial"`. A partial declaration remains distinct from a hand-written function or alias: @@ -198,7 +202,7 @@ Metadata only carries values that the compiler can expose without executing user | `bytes` | Bytes literal or frozen bytes const | | `none` | Literal `None` | -Decorator arguments that are not literals, type arguments, or const references are reported as `unsupported` metadata values instead of being evaluated. +Decorator arguments that are not declaration-safe literals, const references, symbolic references, lists, dicts, constructors, or ordinary call trees are reported as `unsupported` metadata values instead of being evaluated. ## Docstrings @@ -242,4 +246,4 @@ The metadata JSON describes public declarations from checked Incan source and ma Checked API metadata extraction does not inspect built `.incnlib` artifacts. Artifact inspection remains a separate tooling surface from source/project metadata extraction. -The extractor exposes only checked compiler facts and safe literal/const values. Unsupported decorator expressions are reported as `unsupported` metadata rather than evaluated, and consumers should not treat docstrings or decorator payloads as trusted executable input. +The extractor exposes only checked compiler facts and declaration-safe metadata values. Unsupported decorator expressions are reported as `unsupported` metadata rather than evaluated, and consumers should not treat docstrings or decorator payloads as trusted executable input. From c1b460d80c0fe2cdcd8b00775018826911b67bfc Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Tue, 26 May 2026 16:37:38 +0200 Subject: [PATCH 42/58] bugfix - preserve decorator callable identity and partial presets (#694, #698) (#700) --- Cargo.lock | 18 +- Cargo.toml | 2 +- .../src/bin/generate_lang_reference.rs | 15 + crates/incan_core/src/lang/features.rs | 3 +- src/backend/ir/codegen.rs | 180 ++++++++++- src/backend/ir/emit/expressions/calls.rs | 8 +- src/backend/ir/emit/expressions/indexing.rs | 80 +++++ src/backend/ir/emit/mod.rs | 174 +++++++++- src/backend/ir/emit/program.rs | 304 ++++++++++++++++-- src/backend/ir/lower/decl/functions.rs | 280 ++++++++++++++++ src/backend/ir/mod.rs | 26 ++ src/backend/ir/trait_bound_inference.rs | 4 + src/frontend/module.rs | 27 +- src/frontend/typechecker/check_expr/access.rs | 7 + src/frontend/typechecker/tests.rs | 48 +++ tests/cli_integration.rs | 216 +++++++++++++ .../semantic_string_audit.json | 22 +- .../language/reference/feature_inventory.md | 5 +- .../docs/language/reference/language.md | 15 + .../docs-site/docs/release_notes/0_3.md | 6 +- 20 files changed, 1382 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb0a5332e..51627a401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc17" +version = "0.3.0-rc18" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index fa3192dce..b193ff9dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc17" +version = "0.3.0-rc18" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/bin/generate_lang_reference.rs b/crates/incan_core/src/bin/generate_lang_reference.rs index 57fb45e59..1935a304c 100644 --- a/crates/incan_core/src/bin/generate_lang_reference.rs +++ b/crates/incan_core/src/bin/generate_lang_reference.rs @@ -524,6 +524,21 @@ pub def col(name: str) -> ColumnExpr: The post-decoration binding keeps the concrete callable signature of the decorated function unless the decorator deliberately returns a different callable shape. Checked API metadata and imports observe that concrete signature, not the generic helper's `F`. +The callable value passed into a decorator exposes `__name__` as the source callable name. Registry and catalog decorators can use this from concrete decorator helpers and from generic `(F) -> F` helpers, so a decorator can record `func.__name__` without requiring the decorated declaration to repeat its own public name in a string argument. + +```incan +def capture[F](func: F) -> F: + registry_names.append(func.__name__) + return func + +def registered[F]() -> ((F) -> F): + return (func) => capture[F](func) + +@registered() +pub def sample(value: int) -> int: + return value + 1 +``` + Method decorators receive an unbound callable shape with the receiver first. A decorator on `def label(self, value: int) -> str` sees `(&Box, int) -> str`; a decorator on `def bump(mut self, value: int) -> int` sees `(&mut Box, int) -> int`. The wrapper passes the actual receiver borrow through to the decorated callable, so method decorators do not require cloning the receiver. Class, model, trait, enum, newtype, field, alias, and module decorators remain limited to compiler-owned decorators. Compiler-owned decorators such as `@derive`, `@route`, `@rust.extern`, `@rust.allow`, `@staticmethod`, `@classmethod`, and `@requires` keep their existing special behavior. diff --git a/crates/incan_core/src/lang/features.rs b/crates/incan_core/src/lang/features.rs index f53d83377..efe27205a 100644 --- a/crates/incan_core/src/lang/features.rs +++ b/crates/incan_core/src/lang/features.rs @@ -500,10 +500,11 @@ pub const FEATURES: &[FeatureDescriptor] = &[ introduced_in_rfc: RFC::_036, stability: Stability::Stable, activation: "None for user-defined decorators; compiler-owned decorators keep their documented imports.", - summary: "Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type.", + summary: "Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type and decorator helpers that expose `func.__name__`.", canonical_forms: &[ "@logged", "@registered(\"catalog.ref\")", + "func.__name__", "@registered[(str) -> ColumnExpr](\"catalog.ref\")", ], prefer_over: "Boilerplate wrapper declarations around every function that needs the same callable transform.", diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 6fa92cd9d..899bd3afd 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -37,11 +37,12 @@ use crate::frontend::ast::Program; use crate::frontend::diagnostics::CompileError; use crate::frontend::library_manifest_index::LibraryManifestIndex; +use super::emit::CallableNameResolution; use super::scanners::{ check_for_this_import as scan_check_for_this_import, collect_rust_crates as scan_collect_rust_crates, detect_serde_usage, }; -use super::{AstLowering, EmitError, EmitService, IrEmitter, LoweringErrors}; +use super::{AstLowering, EmitError, EmitService, FunctionRegistry, IrEmitter, IrProgram, LoweringErrors}; mod dependency_metadata; mod ordinal_bridge; @@ -199,6 +200,25 @@ impl<'a> IrCodegen<'a> { } } + /// Build a registry for explicit canonical cross-module calls. + fn canonical_registry_for_programs<'program>( + programs: impl IntoIterator, + ) -> FunctionRegistry { + let mut registry = FunctionRegistry::new(); + for (module_path, program) in programs { + for (name, signature) in program.function_registry.iter() { + let mut canonical_path = module_path.to_vec(); + canonical_path.push(name.clone()); + registry.register_canonical_path( + &canonical_path, + signature.params.clone(), + signature.return_type.clone(), + ); + } + } + registry + } + /// Enable strict generated Rust lint validation for `--emit-rust --strict`. pub fn set_strict_generated_lints(&mut self, enabled: bool) { self.strict_generated_lints = enabled; @@ -459,7 +479,7 @@ impl<'a> IrCodegen<'a> { program: &Program, internal_module_roots: &HashSet, ) -> Result { - self.try_generate_via_ir_with_union_config(program, internal_module_roots, HashMap::new(), false) + self.try_generate_via_ir_with_union_config(program, internal_module_roots, HashMap::new(), false, None, None) } /// Generate code via the IR pipeline with optional crate-root union sharing for multi-file source modules. @@ -469,6 +489,8 @@ impl<'a> IrCodegen<'a> { internal_module_roots: &HashSet, generated_union_types: HashMap, qualify_union_types_from_crate: bool, + mut callable_name_resolutions: Option<&mut HashMap>, + mut callable_name_used_signature_keys: Option<&mut HashSet>, ) -> Result { let deps: Vec<(&str, &Program)> = self .dependency_modules @@ -514,11 +536,29 @@ impl<'a> IrCodegen<'a> { // RFC 023: Infer trait bounds for generic functions. super::trait_bound_inference::infer_trait_bounds(&mut ir_program); - - // Build unified function registry including imported module functions - let mut unified_registry = ir_program.function_registry.clone(); + if let Some(used_keys) = callable_name_used_signature_keys.as_deref_mut() { + used_keys.extend(IrEmitter::callable_name_signature_keys_for_program( + &ir_program, + &self.externally_reachable_items, + true, + &dependency_type_metadata.error_trait_type_names, + )); + } + if let Some(resolutions) = callable_name_resolutions.as_deref_mut() { + IrEmitter::add_callable_name_resolutions_for_program(resolutions, Vec::new(), &ir_program); + } + let callable_name_resolutions_for_emit = callable_name_resolutions + .as_ref() + .map(|resolutions| (**resolutions).clone()) + .unwrap_or_default(); + let callable_name_used_signature_keys_for_emit = callable_name_used_signature_keys + .as_ref() + .map(|used_keys| (**used_keys).clone()) + .unwrap_or_default(); + + let mut canonical_registry = FunctionRegistry::new(); let mut dependency_ir_programs = Vec::new(); - for (_, dep_ast, _) in &self.dependency_modules { + for (dep_name, dep_ast, dep_path_segments) in &self.dependency_modules { // For dependencies, use best-effort lowering without type info to // preserve prior behavior and avoid redundant typechecking. let mut dep_lowering = AstLowering::new(); @@ -530,7 +570,18 @@ impl<'a> IrCodegen<'a> { ); dep_lowering.seed_struct_field_aliases(global_aliases.clone()); let dep_ir = dep_lowering.lower_program(dep_ast)?; - unified_registry.merge(&dep_ir.function_registry); + let module_path = dep_path_segments + .clone() + .unwrap_or_else(|| vec![(*dep_name).to_string()]); + for (name, signature) in dep_ir.function_registry.iter() { + let mut canonical_path = module_path.clone(); + canonical_path.push(name.clone()); + canonical_registry.register_canonical_path( + &canonical_path, + signature.params.clone(), + signature.return_type.clone(), + ); + } dependency_ir_programs.push(dep_ir); } @@ -557,12 +608,17 @@ impl<'a> IrCodegen<'a> { self.apply_ordinal_bridge_config(inner, &ordinal_bridge); inner.set_qualify_union_types_from_crate(qualify_union_types_from_crate); inner.set_generated_union_types(generated_union_types); + inner.set_canonical_function_registry(canonical_registry.clone()); + inner.set_callable_name_current_module_path(Vec::new()); + inner.set_callable_name_resolutions(callable_name_resolutions_for_emit); + inner.set_callable_name_used_signature_keys(callable_name_used_signature_keys_for_emit); + inner.set_callable_name_local_registry(ir_program.function_registry.clone()); for dep_ir in &dependency_ir_programs { inner.seed_dependency_nominal_metadata_from_program(dep_ir); } Ok(svc.emit_program(&ir_program)?) } else { - let mut emitter = IrEmitter::new(&unified_registry); + let mut emitter = IrEmitter::new(&ir_program.function_registry); emitter.set_internal_module_roots(internal_module_roots.clone()); if self.emit_zen_in_main { emitter.set_emit_zen(true); @@ -580,6 +636,11 @@ impl<'a> IrCodegen<'a> { self.apply_ordinal_bridge_config(&mut emitter, &ordinal_bridge); emitter.set_qualify_union_types_from_crate(qualify_union_types_from_crate); emitter.set_generated_union_types(generated_union_types); + emitter.set_canonical_function_registry(canonical_registry.clone()); + emitter.set_callable_name_current_module_path(Vec::new()); + emitter.set_callable_name_resolutions(callable_name_resolutions_for_emit); + emitter.set_callable_name_used_signature_keys(callable_name_used_signature_keys_for_emit); + emitter.set_callable_name_local_registry(ir_program.function_registry.clone()); for dep_ir in &dependency_ir_programs { emitter.seed_dependency_nominal_metadata_from_program(dep_ir); } @@ -758,14 +819,57 @@ impl<'a> IrCodegen<'a> { .collect(); super::trait_bound_inference::propagate_trait_bounds_from_programs(current_ir, &external_programs); } + let all_module_canonical_registry = Self::canonical_registry_for_programs( + lowered_modules + .iter() + .map(|(_, module_path, ir)| (module_path.as_slice(), ir)), + ); let mut shared_union_types = HashMap::new(); for (_, _, ir) in &lowered_modules { shared_union_types.extend(IrEmitter::collect_union_types_from_program(ir)); } // Generate main file after dependency lowering so it can own shared crate-root union wrappers. - let main_code = - self.try_generate_via_ir_with_union_config(program, &internal_roots, shared_union_types, true)?; + let mut callable_name_resolutions = HashMap::new(); + let mut callable_name_used_signature_keys = HashSet::new(); + let mut generic_callable_name_trait_used = false; + for (_, module_path, ir) in &lowered_modules { + IrEmitter::add_callable_name_resolutions_for_program( + &mut callable_name_resolutions, + module_path.clone(), + ir, + ); + let mut reachable_items = dependency_reachable_items.get(module_path).cloned().unwrap_or_default(); + if let Some(injected_items) = self.externally_reachable_items_by_module.get(module_path) { + reachable_items.extend(injected_items.iter().cloned()); + } + let preserve_public_items = + should_preserve_dependency_public_items(module_path, self.preserve_dependency_public_items); + callable_name_used_signature_keys.extend(IrEmitter::callable_name_signature_keys_for_program( + ir, + &reachable_items, + preserve_public_items, + &dependency_type_metadata.error_trait_type_names, + )); + generic_callable_name_trait_used |= IrEmitter::generic_callable_name_trait_used_for_program( + ir, + &reachable_items, + preserve_public_items, + &dependency_type_metadata.error_trait_type_names, + ); + } + if generic_callable_name_trait_used { + callable_name_used_signature_keys.extend(callable_name_resolutions.keys().cloned()); + } + + let main_code = self.try_generate_via_ir_with_union_config( + program, + &internal_roots, + shared_union_types, + true, + Some(&mut callable_name_resolutions), + Some(&mut callable_name_used_signature_keys), + )?; let mut modules = HashMap::new(); for (name, module_path, ir) in &lowered_modules { @@ -791,6 +895,10 @@ impl<'a> IrCodegen<'a> { inner.set_external_rust_functions(self.external_rust_functions.clone()); inner.set_qualify_union_types_from_crate(true); inner.set_emit_generated_union_definitions(false); + inner.set_canonical_function_registry(all_module_canonical_registry.clone()); + inner.set_callable_name_current_module_path(module_path.clone()); + inner.set_callable_name_resolutions(callable_name_resolutions.clone()); + inner.set_callable_name_used_signature_keys(callable_name_used_signature_keys.clone()); self.apply_ordinal_bridge_config(inner, &ordinal_bridge); for (_, _, dep_ir) in &lowered_modules { inner.seed_dependency_nominal_metadata_from_program(dep_ir); @@ -810,6 +918,10 @@ impl<'a> IrCodegen<'a> { emitter.set_external_rust_functions(self.external_rust_functions.clone()); emitter.set_qualify_union_types_from_crate(true); emitter.set_emit_generated_union_definitions(false); + emitter.set_canonical_function_registry(all_module_canonical_registry.clone()); + emitter.set_callable_name_current_module_path(module_path.clone()); + emitter.set_callable_name_resolutions(callable_name_resolutions.clone()); + emitter.set_callable_name_used_signature_keys(callable_name_used_signature_keys.clone()); self.apply_ordinal_bridge_config(&mut emitter, &ordinal_bridge); for (_, _, dep_ir) in &lowered_modules { emitter.seed_dependency_nominal_metadata_from_program(dep_ir); @@ -949,14 +1061,50 @@ impl<'a> IrCodegen<'a> { .collect(); super::trait_bound_inference::propagate_trait_bounds_from_programs(current_ir, &external_programs); } + let all_module_canonical_registry = + Self::canonical_registry_for_programs(lowered_modules.iter().map(|(path, ir)| (path.as_slice(), ir))); let mut shared_union_types = HashMap::new(); for (_, ir) in &lowered_modules { shared_union_types.extend(IrEmitter::collect_union_types_from_program(ir)); } // Generate main file after dependency lowering so it can own shared crate-root union wrappers. - let main_code = - self.try_generate_via_ir_with_union_config(program, &internal_roots, shared_union_types, true)?; + let mut callable_name_resolutions = HashMap::new(); + let mut callable_name_used_signature_keys = HashSet::new(); + let mut generic_callable_name_trait_used = false; + for (path, ir) in &lowered_modules { + IrEmitter::add_callable_name_resolutions_for_program(&mut callable_name_resolutions, path.clone(), ir); + let mut reachable_items = dependency_reachable_items.get(path).cloned().unwrap_or_default(); + if let Some(injected_items) = self.externally_reachable_items_by_module.get(path) { + reachable_items.extend(injected_items.iter().cloned()); + } + let preserve_public_items = + should_preserve_dependency_public_items(path, self.preserve_dependency_public_items); + callable_name_used_signature_keys.extend(IrEmitter::callable_name_signature_keys_for_program( + ir, + &reachable_items, + preserve_public_items, + &dependency_type_metadata.error_trait_type_names, + )); + generic_callable_name_trait_used |= IrEmitter::generic_callable_name_trait_used_for_program( + ir, + &reachable_items, + preserve_public_items, + &dependency_type_metadata.error_trait_type_names, + ); + } + if generic_callable_name_trait_used { + callable_name_used_signature_keys.extend(callable_name_resolutions.keys().cloned()); + } + + let main_code = self.try_generate_via_ir_with_union_config( + program, + &internal_roots, + shared_union_types, + true, + Some(&mut callable_name_resolutions), + Some(&mut callable_name_used_signature_keys), + )?; let mut modules = HashMap::new(); for (path, ir) in &lowered_modules { @@ -982,6 +1130,10 @@ impl<'a> IrCodegen<'a> { inner.set_external_rust_functions(self.external_rust_functions.clone()); inner.set_qualify_union_types_from_crate(true); inner.set_emit_generated_union_definitions(false); + inner.set_canonical_function_registry(all_module_canonical_registry.clone()); + inner.set_callable_name_current_module_path(path.clone()); + inner.set_callable_name_resolutions(callable_name_resolutions.clone()); + inner.set_callable_name_used_signature_keys(callable_name_used_signature_keys.clone()); self.apply_ordinal_bridge_config(inner, &ordinal_bridge); for (_, dep_ir) in &lowered_modules { inner.seed_dependency_nominal_metadata_from_program(dep_ir); @@ -1001,6 +1153,10 @@ impl<'a> IrCodegen<'a> { emitter.set_external_rust_functions(self.external_rust_functions.clone()); emitter.set_qualify_union_types_from_crate(true); emitter.set_emit_generated_union_definitions(false); + emitter.set_canonical_function_registry(all_module_canonical_registry.clone()); + emitter.set_callable_name_current_module_path(path.clone()); + emitter.set_callable_name_resolutions(callable_name_resolutions.clone()); + emitter.set_callable_name_used_signature_keys(callable_name_used_signature_keys.clone()); self.apply_ordinal_bridge_config(&mut emitter, &ordinal_bridge); for (_, dep_ir) in &lowered_modules { emitter.seed_dependency_nominal_metadata_from_program(dep_ir); diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index bb7c82c2e..827266346 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -499,12 +499,10 @@ impl<'a> IrEmitter<'a> { _ => None, }; let callee_name = local_name.or(canonical_name); - let registry_signature = if canonical_path.is_some() { - canonical_name.and_then(|name| self.function_registry.get(name)) + let registry_signature = if let Some(path) = canonical_path { + self.canonical_function_registry().get_canonical_path(path) } else { - local_name - .and_then(|name| self.function_registry.get(name)) - .or_else(|| canonical_name.and_then(|name| self.function_registry.get(name))) + local_name.and_then(|name| self.function_registry.get(name)) }; let result_specialized_signature = callable_signature.or(registry_signature).and_then(|signature| { result_target_ty.and_then(|target_ty| Self::specialize_signature_by_result_target(signature, target_ty)) diff --git a/src/backend/ir/emit/expressions/indexing.rs b/src/backend/ir/emit/expressions/indexing.rs index f06cadf5b..81d538500 100644 --- a/src/backend/ir/emit/expressions/indexing.rs +++ b/src/backend/ir/emit/expressions/indexing.rs @@ -40,6 +40,78 @@ fn emit_dict_lookup_index_key(object: &TypedExpr, index: &TypedExpr, emitted: To } impl<'a> IrEmitter<'a> { + /// Emit the stable source name for a function-typed value when the value points at a registered generated + /// function. Decorator lowering passes undecorated originals such as `__incan_original_sample`, but source-facing + /// metadata should still report `sample`. + fn emit_callable_name_expr(&self, object: &TypedExpr) -> Result { + let IrType::Function { params, ret } = &object.ty else { + return Ok(quote! { "".to_string() }); + }; + let Some(signature_key) = Self::callable_name_signature_key(params, ret) else { + return Ok(quote! { "".to_string() }); + }; + let callable = self.emit_expr(object)?; + let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); + let ret_tokens = self.emit_type(ret); + let fn_ty = quote! { fn(#(#param_tokens),*) -> #ret_tokens }; + + let helper = Self::callable_name_helper_ident(&signature_key); + let mut helper_calls = Vec::new(); + if self.local_callable_name_signature_keys().contains(&signature_key) { + helper_calls.push(quote! { #helper(__incan_callable) }); + } + if let Some(resolution) = self.callable_name_resolutions.get(&signature_key) { + for module_path in &resolution.module_paths { + if module_path == &self.callable_name_current_module_path { + continue; + } + let helper_path = self.emit_callable_name_helper_path(module_path, &signature_key); + helper_calls.push(quote! { #helper_path(__incan_callable) }); + } + } + let fallback = proc_macro2::Literal::string(""); + let mut resolved = quote! { #fallback.to_string() }; + for helper_call in helper_calls.into_iter().rev() { + resolved = quote! { + if let Some(__incan_name) = #helper_call { + __incan_name.to_string() + } else { + #resolved + } + }; + } + + Ok(quote! {{ + let __incan_callable: #fn_ty = #callable; + #resolved + }}) + } + + fn emit_generic_callable_name_expr(&self, object: &TypedExpr) -> Result { + let object = self.emit_expr(object)?; + Ok(quote! { __IncanCallableName::__incan_callable_name(&#object) }) + } + + pub(in crate::backend::ir::emit) fn emit_callable_name_helper_path( + &self, + module_path: &[String], + signature_key: &str, + ) -> TokenStream { + let helper = Self::callable_name_helper_ident(signature_key); + if module_path.is_empty() { + return quote! { crate::#helper }; + } + let mut segments = vec![quote! { crate }]; + for segment in module_path { + let ident = Self::rust_ident(segment); + segments.push(quote! { #ident }); + } + segments.push(quote! { #helper }); + let mut iter = segments.into_iter(); + let first = iter.next().unwrap_or_else(|| quote! { crate }); + iter.fold(first, |acc, segment| quote! { #acc :: #segment }) + } + /// Build the fully-qualified generated-module path for a type imported from another emitted module. /// /// Default argument expressions can be expanded at a call site outside the module that declared the default. When @@ -218,6 +290,14 @@ impl<'a> IrEmitter<'a> { /// - Tuple field access (`tuple.0` → `tuple.0`) /// - Regular struct field access (`obj.field` → `obj.field`) pub(in super::super) fn emit_field_expr(&self, object: &TypedExpr, field: &str) -> Result { + if field == "__name__" { + return match object.ty { + IrType::Function { .. } => self.emit_callable_name_expr(object), + IrType::Generic(_) => self.emit_generic_callable_name_expr(object), + _ => Ok(quote! { "".to_string() }), + }; + } + if Self::expr_is_storage_rooted(object) { let rewritten = Self::rewrite_storage_root_expr( &TypedExpr::new( diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index c1f77dd2d..0ce789a0b 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -31,7 +31,7 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use super::decl::{IrDeclKind, IrEnumValue, IrEnumValueType, IrStruct, VariantFields, Visibility}; use super::expr::TypedExpr; @@ -67,6 +67,14 @@ pub(crate) struct ExternalOrdinalCustomKey { pub has_ordinal_bytes_equal: bool, } +/// Cross-module callable-name resolver metadata keyed by a concrete function-pointer signature. +#[derive(Debug, Clone)] +pub(crate) struct CallableNameResolution { + pub(super) params: Vec, + pub(super) ret: IrType, + pub(super) module_paths: Vec>, +} + /// Usage facts collected before Rust emission. /// /// This analysis is intentionally about generated Rust lints, not source-language reachability diagnostics. It records @@ -94,6 +102,10 @@ pub(super) struct GeneratedUseAnalysis { pub(super) result_observer_callable_types: HashSet, /// Top-level function values adapted to a borrowed function-pointer parameter. pub(super) borrowed_function_adapters: HashSet<(String, Vec)>, + /// Concrete function-pointer signatures whose values read `__name__`. + pub(super) callable_name_signature_keys: HashSet, + /// Whether a generic callable parameter reads `__name__` through the generated callable-name trait. + pub(super) uses_generic_callable_name_trait: bool, } impl GeneratedUseAnalysis { @@ -213,8 +225,10 @@ pub struct IrEmitter<'a> { emit_zen_in_main: bool, /// Whether serde is needed for emitted Rust derives or helpers. needs_serde: RefCell, - /// Function registry for call-site type checking + /// Function registry for module-local call-site default argument filling and type-aware argument conversion. function_registry: &'a FunctionRegistry, + /// Cross-module registry used only for IR calls that carry an explicit canonical callee path. + canonical_function_registry: Option, /// Track struct derives for generating serde methods in impl blocks struct_derives: std::collections::HashMap>, /// Current function's return type (for applying conversions in return statements) @@ -322,6 +336,15 @@ pub struct IrEmitter<'a> { emitted_result_observer_callable_helpers: RefCell>, /// Top-level function values adapted to a borrowed function-pointer parameter. borrowed_function_adapters: RefCell)>>, + /// Current generated Rust module path. The crate root uses an empty path. + callable_name_current_module_path: Vec, + /// Concrete callable-name helper modules available to this compilation unit. + callable_name_resolutions: HashMap, + /// Concrete callable-name signatures used somewhere in this compilation unit. + callable_name_used_signature_keys: HashSet, + /// Local callable registry used for module-local callable-name helpers when the main emitter has a unified + /// cross-module call registry. + callable_name_local_registry: Option, } impl<'a> IrEmitter<'a> { @@ -340,6 +363,7 @@ impl<'a> IrEmitter<'a> { emit_zen_in_main: false, needs_serde: RefCell::new(false), function_registry, + canonical_function_registry: None, struct_derives: std::collections::HashMap::new(), current_function_return_type: RefCell::new(None), external_rust_functions: std::collections::HashSet::new(), @@ -378,9 +402,155 @@ impl<'a> IrEmitter<'a> { result_observer_callable_types: RefCell::new(HashSet::new()), emitted_result_observer_callable_helpers: RefCell::new(HashSet::new()), borrowed_function_adapters: RefCell::new(HashSet::new()), + callable_name_current_module_path: Vec::new(), + callable_name_resolutions: HashMap::new(), + callable_name_used_signature_keys: HashSet::new(), + callable_name_local_registry: None, + } + } + + /// Configure the generated Rust module path for callable-name helper routing. + pub(crate) fn set_callable_name_current_module_path(&mut self, path: Vec) { + self.callable_name_current_module_path = path; + } + + /// Configure the canonical callable registry for explicit cross-module call paths. + pub(crate) fn set_canonical_function_registry(&mut self, registry: FunctionRegistry) { + self.canonical_function_registry = Some(registry); + } + + pub(super) fn canonical_function_registry(&self) -> &FunctionRegistry { + self.canonical_function_registry + .as_ref() + .unwrap_or(self.function_registry) + } + + /// Configure the concrete callable-name helper modules available to this emitter. + pub(crate) fn set_callable_name_resolutions(&mut self, resolutions: HashMap) { + self.callable_name_resolutions = resolutions; + } + + /// Configure the callable-name signatures that are used anywhere in this generated crate. + pub(crate) fn set_callable_name_used_signature_keys(&mut self, keys: HashSet) { + self.callable_name_used_signature_keys = keys; + } + + /// Configure the local callable registry used by generated callable-name helpers. + pub(crate) fn set_callable_name_local_registry(&mut self, registry: FunctionRegistry) { + self.callable_name_local_registry = Some(registry); + } + + /// Add every concrete function-pointer signature from one lowered program to the cross-module resolver map. + pub(crate) fn add_callable_name_resolutions_for_program( + out: &mut HashMap, + module_path: Vec, + program: &IrProgram, + ) { + for (_, signature) in program.function_registry.iter() { + let params = signature + .params + .iter() + .map(|param| param.ty.clone()) + .collect::>(); + let ret = signature.return_type.clone(); + let Some(key) = Self::callable_name_signature_key(¶ms, &ret) else { + continue; + }; + let resolution = out.entry(key).or_insert_with(|| CallableNameResolution { + params, + ret, + module_paths: Vec::new(), + }); + if !resolution.module_paths.contains(&module_path) { + resolution.module_paths.push(module_path.clone()); + } + } + for resolution in out.values_mut() { + resolution.module_paths.sort(); } } + /// Return the deterministic helper identifier for a concrete callable signature key. + pub(super) fn callable_name_helper_ident(key: &str) -> proc_macro2::Ident { + format_ident!( + "__incan_callable_name_{:016x}", + Self::stable_callable_name_hash(key.as_bytes()) + ) + } + + /// Return a stable signature key for callable-name helpers when the function-pointer type is concrete. + pub(super) fn callable_name_signature_key(params: &[IrType], ret: &IrType) -> Option { + if !params.iter().all(Self::callable_name_type_supported) || !Self::callable_name_type_supported(ret) { + return None; + } + let params = params.iter().map(IrType::rust_name).collect::>().join(", "); + Some(format!("fn({params}) -> {}", ret.rust_name())) + } + + fn callable_name_signature_key_from_signature(signature: &FunctionSignature) -> Option { + let params = signature + .params + .iter() + .map(|param| param.ty.clone()) + .collect::>(); + Self::callable_name_signature_key(¶ms, &signature.return_type) + } + + fn callable_name_type_supported(ty: &IrType) -> bool { + match ty { + IrType::Unknown | IrType::Generic(_) | IrType::ImplTrait(_) | IrType::SelfType => false, + IrType::List(inner) + | IrType::Set(inner) + | IrType::Option(inner) + | IrType::Ref(inner) + | IrType::RefMut(inner) => Self::callable_name_type_supported(inner), + IrType::Dict(key, value) | IrType::Result(key, value) => { + Self::callable_name_type_supported(key) && Self::callable_name_type_supported(value) + } + IrType::Tuple(items) => items.iter().all(Self::callable_name_type_supported), + IrType::NamedGeneric(_, args) => args.iter().all(Self::callable_name_type_supported), + IrType::Function { params, ret } => Self::callable_name_signature_key(params, ret).is_some(), + IrType::Unit + | IrType::Bool + | IrType::Int + | IrType::Float + | IrType::Decimal { .. } + | IrType::String + | IrType::StrRef + | IrType::StaticStr + | IrType::FrozenStr + | IrType::Bytes + | IrType::StaticBytes + | IrType::FrozenBytes + | IrType::Numeric(_) + | IrType::Struct(_) + | IrType::Enum(_) + | IrType::Trait(_) => true, + } + } + + fn stable_callable_name_hash(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash + } + + pub(super) fn local_callable_name_signature_keys(&self) -> HashSet { + self.callable_name_local_registry() + .iter() + .filter_map(|(_, signature)| Self::callable_name_signature_key_from_signature(signature)) + .collect() + } + + pub(super) fn callable_name_local_registry(&self) -> &FunctionRegistry { + self.callable_name_local_registry + .as_ref() + .unwrap_or(self.function_registry) + } + /// Resolve transparent type aliases before emission decisions that need structural type information. pub(in crate::backend::ir::emit) fn resolve_type_aliases_for_emit(&self, ty: &IrType) -> IrType { let mut visiting = HashSet::new(); diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index 40ea06cc0..d7219c5e3 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -620,6 +620,15 @@ impl<'program> GeneratedUseAnalyzer<'program> { } IrExprKind::Field { object, field } => { self.scan_expr(object); + if field == "__name__" + && let IrType::Function { params, ret } = &object.ty + && let Some(key) = IrEmitter::callable_name_signature_key(params, ret) + { + self.analysis.callable_name_signature_keys.insert(key); + } + if field == "__name__" && matches!(object.ty, IrType::Generic(_)) { + self.analysis.uses_generic_callable_name_trait = true; + } if let Some(type_name) = self.object_nominal_type_name(object) { let field = self .struct_field_aliases @@ -830,28 +839,36 @@ impl<'program> GeneratedUseAnalyzer<'program> { _ => None, }; let canonical_name = canonical_path.as_ref().and_then(|path| path.last()).map(String::as_str); - local_name - .and_then(|name| self.function_registry.get(name).cloned()) - .or_else(|| canonical_name.and_then(|name| self.function_registry.get(name).cloned())) - .or_else(|| callable_signature.cloned()) - .or_else(|| match &func.ty { - IrType::Function { params, ret } => Some(FunctionSignature { - params: params - .iter() - .enumerate() - .map(|(idx, ty)| super::super::decl::FunctionParam { - name: format!("__incan_arg_{idx}"), - ty: ty.clone(), - mutability: super::super::types::Mutability::Immutable, - is_self: false, - kind: crate::frontend::ast::ParamKind::Normal, - default: None, - }) - .collect(), - return_type: ret.as_ref().clone(), - }), - _ => None, + let registered_signature = if canonical_path.is_some() { + callable_signature.cloned().or_else(|| { + canonical_path + .as_ref() + .and_then(|path| self.function_registry.get_canonical_path(path).cloned()) }) + } else { + local_name + .and_then(|name| self.function_registry.get(name).cloned()) + .or_else(|| canonical_name.and_then(|name| self.function_registry.get(name).cloned())) + .or_else(|| callable_signature.cloned()) + }; + registered_signature.or_else(|| match &func.ty { + IrType::Function { params, ret } => Some(FunctionSignature { + params: params + .iter() + .enumerate() + .map(|(idx, ty)| super::super::decl::FunctionParam { + name: format!("__incan_arg_{idx}"), + ty: ty.clone(), + mutability: super::super::types::Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }) + .collect(), + return_type: ret.as_ref().clone(), + }), + _ => None, + }) } /// Record named function arguments that need private adapters for borrowed function-pointer parameters. @@ -2261,6 +2278,232 @@ impl<'a> IrEmitter<'a> { Ok(format!("{}{}", header, with_marker)) } + pub(crate) fn callable_name_signature_keys_for_program( + program: &IrProgram, + externally_reachable_items: &HashSet, + preserve_public_items: bool, + external_error_trait_types: &HashSet, + ) -> HashSet { + GeneratedUseAnalyzer::analyze( + program, + externally_reachable_items, + preserve_public_items, + external_error_trait_types, + ) + .callable_name_signature_keys + } + + pub(crate) fn generic_callable_name_trait_used_for_program( + program: &IrProgram, + externally_reachable_items: &HashSet, + preserve_public_items: bool, + external_error_trait_types: &HashSet, + ) -> bool { + GeneratedUseAnalyzer::analyze( + program, + externally_reachable_items, + preserve_public_items, + external_error_trait_types, + ) + .uses_generic_callable_name_trait + } + + fn callable_name_signature_for_key(&self, key: &str) -> Option<(Vec, IrType)> { + self.callable_name_local_registry() + .iter() + .find_map(|(_, signature)| { + let params = signature + .params + .iter() + .map(|param| param.ty.clone()) + .collect::>(); + (Self::callable_name_signature_key(¶ms, &signature.return_type).as_deref() == Some(key)) + .then(|| (params, signature.return_type.clone())) + }) + .or_else(|| { + self.callable_name_resolutions + .get(key) + .map(|resolution| (resolution.params.clone(), resolution.ret.clone())) + }) + } + + fn callable_name_helper_keys( + &self, + local_callable_name_signature_keys: &HashSet, + include_all_callable_signatures: bool, + ) -> Vec { + let mut keys = local_callable_name_signature_keys.clone(); + if include_all_callable_signatures { + for (_, signature) in self.callable_name_local_registry().iter() { + let params = signature + .params + .iter() + .map(|param| param.ty.clone()) + .collect::>(); + if let Some(key) = Self::callable_name_signature_key(¶ms, &signature.return_type) { + keys.insert(key); + } + } + keys.extend( + self.callable_name_resolutions + .iter() + .filter(|(_, resolution)| { + self.callable_name_current_module_path.is_empty() + || resolution.module_paths.iter().any(|path| !path.is_empty()) + }) + .map(|(key, _)| key.clone()), + ); + } + for (key, resolution) in &self.callable_name_resolutions { + if self.callable_name_used_signature_keys.contains(key) + && resolution + .module_paths + .contains(&self.callable_name_current_module_path) + { + keys.insert(key.clone()); + } + } + let mut keys = keys.into_iter().collect::>(); + keys.sort(); + keys + } + + fn callable_name_resolution_expr(&self, key: &str, callable_tokens: TokenStream) -> TokenStream { + let helper = Self::callable_name_helper_ident(key); + let mut helper_calls = Vec::new(); + helper_calls.push(quote! { #helper(#callable_tokens) }); + if let Some(resolution) = self.callable_name_resolutions.get(key) { + for module_path in &resolution.module_paths { + if module_path == &self.callable_name_current_module_path { + continue; + } + if module_path.is_empty() && !self.callable_name_current_module_path.is_empty() { + continue; + } + let helper_path = self.emit_callable_name_helper_path(module_path, key); + helper_calls.push(quote! { #helper_path(#callable_tokens) }); + } + } + let fallback = proc_macro2::Literal::string(""); + let mut resolved = quote! { #fallback.to_string() }; + for helper_call in helper_calls.into_iter().rev() { + resolved = quote! { + if let Some(__incan_name) = #helper_call { + __incan_name.to_string() + } else { + #resolved + } + }; + } + resolved + } + + fn emit_generic_callable_name_trait(&self, keys: &[String]) -> Option { + if keys.is_empty() { + return None; + } + let trait_ident = Self::rust_ident("__IncanCallableName"); + let impls = keys + .iter() + .filter_map(|key| { + let (params, ret) = self.callable_name_signature_for_key(key)?; + let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); + let ret_tokens = self.emit_type(&ret); + let fn_ty = quote! { fn(#(#param_tokens),*) -> #ret_tokens }; + let resolved = self.callable_name_resolution_expr(key, quote! { __incan_callable }); + Some(quote! { + impl #trait_ident for #fn_ty { + fn __incan_callable_name(&self) -> String { + let __incan_callable: #fn_ty = *self; + #resolved + } + } + }) + }) + .collect::>(); + if impls.is_empty() { + return None; + } + Some(quote! { + pub trait #trait_ident { + fn __incan_callable_name(&self) -> String; + } + + #(#impls)* + }) + } + + fn emit_callable_name_helpers( + &self, + emitted_callable_names: &HashSet, + keys: &[String], + ) -> Vec { + keys.iter() + .filter_map(|key| { + let (params, ret) = self.callable_name_signature_for_key(key)?; + let helper = Self::callable_name_helper_ident(key); + let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); + let ret_tokens = self.emit_type(&ret); + let fn_ty = quote! { fn(#(#param_tokens),*) -> #ret_tokens }; + let mut candidates = self + .callable_name_local_registry() + .iter() + .filter(|(name, signature)| { + emitted_callable_names.contains(*name) + && signature.params.len() == params.len() + && signature.params.iter().map(|param| ¶m.ty).eq(params.iter()) + && signature.return_type == ret + }) + .map(|(name, _)| { + let source_name = name.strip_prefix("__incan_original_").unwrap_or(name); + (name.clone(), source_name.to_string()) + }) + .collect::>(); + candidates.sort_by(|left, right| left.0.cmp(&right.0)); + let has_candidates = !candidates.is_empty(); + + let mut body = quote! { None }; + for (candidate, source_name) in candidates.into_iter().rev() { + let candidate_ident = Self::rust_ident(&candidate); + let source_literal = proc_macro2::Literal::string(&source_name); + body = quote! { + if std::ptr::fn_addr_eq(callable, #candidate_ident as #fn_ty) { + Some(#source_literal) + } else { + #body + } + }; + } + let callable_param = if has_candidates { + Self::rust_ident("callable") + } else { + Self::rust_ident("_callable") + }; + + let visibility = if self.callable_name_resolutions.get(key).is_some_and(|resolution| { + self.callable_name_used_signature_keys.contains(key) + && resolution + .module_paths + .contains(&self.callable_name_current_module_path) + }) { + quote! { pub(crate) } + } else { + quote! {} + }; + let private_interfaces_allow = (!visibility.is_empty()).then(|| { + quote! { #[allow(private_interfaces)] } + }); + + Some(quote! { + #private_interfaces_allow + #visibility fn #helper(#callable_param: #fn_ty) -> Option<&'static str> { + #body + } + }) + }) + .collect() + } + /// Emit a program to TokenStream (without formatting). pub fn emit_program_tokens(&self, program: &IrProgram) -> Result { let mut items = Vec::new(); @@ -2273,9 +2516,13 @@ impl<'a> IrEmitter<'a> { let uses_stdlib_error_trait = analysis.uses_stdlib_error_trait; let result_observer_callable_types = analysis.result_observer_callable_types.clone(); let borrowed_function_adapters = analysis.borrowed_function_adapters.clone(); + let local_callable_name_signature_keys = analysis.callable_name_signature_keys.clone(); + let uses_generic_callable_name_trait = analysis.uses_generic_callable_name_trait; self.set_result_observer_callable_types(result_observer_callable_types); self.set_borrowed_function_adapters(borrowed_function_adapters); self.set_generated_use_analysis(analysis); + let callable_name_helper_keys = + self.callable_name_helper_keys(&local_callable_name_signature_keys, uses_generic_callable_name_trait); let emitted_declarations: Vec<&IrDecl> = program .declarations @@ -2422,6 +2669,21 @@ impl<'a> IrEmitter<'a> { }); } + let emitted_callable_names: HashSet = emitted_declarations + .iter() + .filter_map(|decl| match &decl.kind { + IrDeclKind::Function(func) => Some(func.name.clone()), + IrDeclKind::SymbolAlias { name, .. } => Some(name.clone()), + _ => None, + }) + .collect(); + items.extend(self.emit_callable_name_helpers(&emitted_callable_names, &callable_name_helper_keys)); + if uses_generic_callable_name_trait + && let Some(trait_item) = self.emit_generic_callable_name_trait(&callable_name_helper_keys) + { + items.push(trait_item); + } + // Emit all declarations. let defines_ordinal_key_trait = Self::emitted_declarations_define_capability_trait( program, diff --git a/src/backend/ir/lower/decl/functions.rs b/src/backend/ir/lower/decl/functions.rs index 85e5adf9b..716f9362f 100644 --- a/src/backend/ir/lower/decl/functions.rs +++ b/src/backend/ir/lower/decl/functions.rs @@ -2,6 +2,8 @@ use super::super::super::Mutability; use super::super::super::decl::{FunctionParam, IrFunction, IrTraitBound, IrTraitBoundOrigin}; +use super::super::super::expr::{IrDictEntry, IrExprKind, IrGeneratorClause, IrListEntry}; +use super::super::super::stmt::{AssignTarget, IrStmt, IrStmtKind}; use super::super::super::types::IrType; use super::super::AstLowering; use super::super::errors::LoweringError; @@ -31,6 +33,269 @@ fn body_contains_yield(body: &[ast::Spanned]) -> bool { }) } +fn collect_generic_callable_name_type_params_from_expr(expr: &super::super::super::IrExpr, out: &mut Vec) { + match &expr.kind { + IrExprKind::Field { object, field } => { + if field == "__name__" + && let IrType::Generic(name) = &object.ty + && !out.contains(name) + { + out.push(name.clone()); + } + collect_generic_callable_name_type_params_from_expr(object, out); + } + IrExprKind::BinOp { left, right, .. } => { + collect_generic_callable_name_type_params_from_expr(left, out); + collect_generic_callable_name_type_params_from_expr(right, out); + } + IrExprKind::UnaryOp { operand, .. } + | IrExprKind::Await(operand) + | IrExprKind::Try(operand) + | IrExprKind::NumericResize { expr: operand, .. } + | IrExprKind::Cast { expr: operand, .. } + | IrExprKind::InteropCoerce { expr: operand, .. } => { + collect_generic_callable_name_type_params_from_expr(operand, out); + } + IrExprKind::Call { func, args, .. } => { + collect_generic_callable_name_type_params_from_expr(func, out); + for arg in args { + collect_generic_callable_name_type_params_from_expr(&arg.expr, out); + } + } + IrExprKind::BuiltinCall { args, .. } => { + for arg in args { + collect_generic_callable_name_type_params_from_expr(arg, out); + } + } + IrExprKind::KnownMethodCall { args, .. } => { + for arg in args { + collect_generic_callable_name_type_params_from_expr(&arg.expr, out); + } + } + IrExprKind::MethodCall { receiver, args, .. } => { + collect_generic_callable_name_type_params_from_expr(receiver, out); + for arg in args { + collect_generic_callable_name_type_params_from_expr(&arg.expr, out); + } + } + IrExprKind::Index { object, index } => { + collect_generic_callable_name_type_params_from_expr(object, out); + collect_generic_callable_name_type_params_from_expr(index, out); + } + IrExprKind::Slice { + target, + start, + end, + step, + } => { + collect_generic_callable_name_type_params_from_expr(target, out); + for expr in [start, end, step].into_iter().flatten() { + collect_generic_callable_name_type_params_from_expr(expr, out); + } + } + IrExprKind::ListComp { + element, + iterable, + filter, + .. + } => { + collect_generic_callable_name_type_params_from_expr(element, out); + collect_generic_callable_name_type_params_from_expr(iterable, out); + if let Some(filter) = filter { + collect_generic_callable_name_type_params_from_expr(filter, out); + } + } + IrExprKind::DictComp { + key, + value, + iterable, + filter, + .. + } => { + collect_generic_callable_name_type_params_from_expr(key, out); + collect_generic_callable_name_type_params_from_expr(value, out); + collect_generic_callable_name_type_params_from_expr(iterable, out); + if let Some(filter) = filter { + collect_generic_callable_name_type_params_from_expr(filter, out); + } + } + IrExprKind::Generator { element, clauses } => { + collect_generic_callable_name_type_params_from_expr(element, out); + for clause in clauses { + match clause { + IrGeneratorClause::For { iterable, .. } => { + collect_generic_callable_name_type_params_from_expr(iterable, out); + } + IrGeneratorClause::If(condition) => { + collect_generic_callable_name_type_params_from_expr(condition, out); + } + } + } + } + IrExprKind::List(items) => { + for item in items { + match item { + IrListEntry::Element(value) | IrListEntry::Spread(value) => { + collect_generic_callable_name_type_params_from_expr(value, out); + } + } + } + } + IrExprKind::Dict(items) => { + for item in items { + match item { + IrDictEntry::Pair(key, value) => { + collect_generic_callable_name_type_params_from_expr(key, out); + collect_generic_callable_name_type_params_from_expr(value, out); + } + IrDictEntry::Spread(value) => { + collect_generic_callable_name_type_params_from_expr(value, out); + } + } + } + } + IrExprKind::Set(items) | IrExprKind::Tuple(items) => { + for item in items { + collect_generic_callable_name_type_params_from_expr(item, out); + } + } + IrExprKind::Struct { fields, .. } => { + for (_, value) in fields { + collect_generic_callable_name_type_params_from_expr(value, out); + } + } + IrExprKind::If { + condition, + then_branch, + else_branch, + } => { + collect_generic_callable_name_type_params_from_expr(condition, out); + collect_generic_callable_name_type_params_from_expr(then_branch, out); + if let Some(else_branch) = else_branch { + collect_generic_callable_name_type_params_from_expr(else_branch, out); + } + } + IrExprKind::Match { scrutinee, arms } => { + collect_generic_callable_name_type_params_from_expr(scrutinee, out); + for arm in arms { + if let Some(guard) = &arm.guard { + collect_generic_callable_name_type_params_from_expr(guard, out); + } + collect_generic_callable_name_type_params_from_expr(&arm.body, out); + } + } + IrExprKind::Closure { body, .. } => { + collect_generic_callable_name_type_params_from_expr(body, out); + } + IrExprKind::Block { stmts, value } => { + collect_generic_callable_name_type_params_from_stmts(stmts, out); + if let Some(value) = value { + collect_generic_callable_name_type_params_from_expr(value, out); + } + } + IrExprKind::Loop { body } => collect_generic_callable_name_type_params_from_stmts(body, out), + IrExprKind::Race { arms, .. } => { + for arm in arms { + collect_generic_callable_name_type_params_from_expr(&arm.awaitable, out); + collect_generic_callable_name_type_params_from_expr(&arm.body, out); + } + } + IrExprKind::Range { start, end, .. } => { + for expr in [start, end].into_iter().flatten() { + collect_generic_callable_name_type_params_from_expr(expr, out); + } + } + IrExprKind::Format { parts } => { + for part in parts { + if let super::super::super::expr::FormatPart::Expr { expr, .. } = part { + collect_generic_callable_name_type_params_from_expr(expr, out); + } + } + } + IrExprKind::Var { .. } + | IrExprKind::StaticRead { .. } + | IrExprKind::StaticBinding { .. } + | IrExprKind::AssociatedFunction { .. } + | IrExprKind::Unit + | IrExprKind::None + | IrExprKind::Bool(_) + | IrExprKind::Int(_) + | IrExprKind::IntLiteral(_) + | IrExprKind::Float(_) + | IrExprKind::Decimal(_) + | IrExprKind::String(_) + | IrExprKind::Bytes(_) + | IrExprKind::Literal(_) + | IrExprKind::FieldsList(_) + | IrExprKind::SerdeToJson + | IrExprKind::SerdeFromJson(_) => {} + } +} + +fn collect_generic_callable_name_type_params_from_stmts(stmts: &[IrStmt], out: &mut Vec) { + for stmt in stmts { + match &stmt.kind { + IrStmtKind::Expr(expr) + | IrStmtKind::Yield(expr) + | IrStmtKind::Let { value: expr, .. } + | IrStmtKind::CompoundAssign { value: expr, .. } => { + collect_generic_callable_name_type_params_from_expr(expr, out); + } + IrStmtKind::Assign { target, value } => { + collect_generic_callable_name_type_params_from_assign_target(target, out); + collect_generic_callable_name_type_params_from_expr(value, out); + } + IrStmtKind::Return(Some(expr)) => collect_generic_callable_name_type_params_from_expr(expr, out), + IrStmtKind::Break { value: Some(expr), .. } => { + collect_generic_callable_name_type_params_from_expr(expr, out); + } + IrStmtKind::While { condition, body, .. } => { + collect_generic_callable_name_type_params_from_expr(condition, out); + collect_generic_callable_name_type_params_from_stmts(body, out); + } + IrStmtKind::For { iterable, body, .. } => { + collect_generic_callable_name_type_params_from_expr(iterable, out); + collect_generic_callable_name_type_params_from_stmts(body, out); + } + IrStmtKind::Loop { body, .. } | IrStmtKind::Block(body) => { + collect_generic_callable_name_type_params_from_stmts(body, out); + } + IrStmtKind::If { + condition, + then_branch, + else_branch, + } => { + collect_generic_callable_name_type_params_from_expr(condition, out); + collect_generic_callable_name_type_params_from_stmts(then_branch, out); + if let Some(else_branch) = else_branch { + collect_generic_callable_name_type_params_from_stmts(else_branch, out); + } + } + IrStmtKind::Match { scrutinee, arms } => { + collect_generic_callable_name_type_params_from_expr(scrutinee, out); + for arm in arms { + if let Some(guard) = &arm.guard { + collect_generic_callable_name_type_params_from_expr(guard, out); + } + collect_generic_callable_name_type_params_from_expr(&arm.body, out); + } + } + IrStmtKind::Return(None) | IrStmtKind::Break { value: None, .. } | IrStmtKind::Continue(_) => {} + } + } +} + +fn collect_generic_callable_name_type_params_from_assign_target(target: &AssignTarget, out: &mut Vec) { + match target { + AssignTarget::Field { object, .. } => collect_generic_callable_name_type_params_from_expr(object, out), + AssignTarget::Index { object, index } => { + collect_generic_callable_name_type_params_from_expr(object, out); + collect_generic_callable_name_type_params_from_expr(index, out); + } + AssignTarget::Var(_) | AssignTarget::StaticBinding(_) | AssignTarget::Static(_) => {} + } +} + impl AstLowering { /// Lower a function declaration. /// @@ -133,6 +398,21 @@ impl AstLowering { let mut all_type_params = Self::lower_type_params(&f.type_params); all_type_params.extend(hidden_type_params); + let mut callable_name_type_params = Vec::new(); + collect_generic_callable_name_type_params_from_stmts(&body, &mut callable_name_type_params); + for type_param_name in callable_name_type_params { + if let Some(type_param) = all_type_params + .iter_mut() + .find(|type_param| type_param.name == type_param_name) + && !type_param.bounds.iter().any(|bound| { + bound.trait_path == "__IncanCallableName" + && bound.type_args.is_empty() + && bound.assoc_types.is_empty() + }) + { + type_param.bounds.push(IrTraitBound::simple("__IncanCallableName")); + } + } if is_generator { for type_param in &mut all_type_params { for trait_path in ["Send", "Static"] { diff --git a/src/backend/ir/mod.rs b/src/backend/ir/mod.rs index 0fa6d1f16..fe5095110 100644 --- a/src/backend/ir/mod.rs +++ b/src/backend/ir/mod.rs @@ -71,16 +71,42 @@ impl FunctionRegistry { Self::default() } + /// Build the registry key used for a canonical module path such as `helpers.normalize`. + pub fn canonical_key(path: &[String]) -> Option { + if path.len() < 2 { + return None; + } + Some(path.join("::")) + } + /// Register a function signature pub fn register(&mut self, name: String, params: Vec, return_type: IrType) { self.signatures.insert(name, FunctionSignature { params, return_type }); } + /// Register a function signature under its canonical module path. + pub fn register_canonical_path(&mut self, path: &[String], params: Vec, return_type: IrType) { + if let Some(key) = Self::canonical_key(path) { + self.register(key, params, return_type); + } + } + /// Look up a function signature by name pub fn get(&self, name: &str) -> Option<&FunctionSignature> { self.signatures.get(name) } + /// Look up a function signature by canonical module path. + pub fn get_canonical_path(&self, path: &[String]) -> Option<&FunctionSignature> { + let key = Self::canonical_key(path)?; + self.signatures.get(&key) + } + + /// Iterate over registered function signatures. + pub fn iter(&self) -> impl Iterator { + self.signatures.iter() + } + /// Merge another registry into this one pub fn merge(&mut self, other: &FunctionRegistry) { for (name, sig) in &other.signatures { diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index d55adb91b..0ac9dd38d 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -2178,6 +2178,7 @@ fn expr_type_param_name( fn type_param_name_from_ir_type(ty: &IrType, type_params: &HashSet<&str>) -> Option { match ty { IrType::Generic(name) if type_params.contains(name.as_str()) => Some(name.clone()), + IrType::Struct(name) if type_params.contains(name.as_str()) => Some(name.clone()), _ => None, } } @@ -2703,6 +2704,9 @@ fn collect_calls_in_expr( recurse_stmt(stmt, result); } } + IrExprKind::Closure { body, .. } => { + recurse_expr(body, result); + } IrExprKind::Race { arms, .. } => { for arm in arms { recurse_expr(&arm.awaitable, result); diff --git a/src/frontend/module.rs b/src/frontend/module.rs index 1130b9104..978c05fd6 100644 --- a/src/frontend/module.rs +++ b/src/frontend/module.rs @@ -455,6 +455,11 @@ pub fn exported_symbols(ast: &Program) -> Vec { exports.push(ExportedSymbol::Function(f.name.clone())); } } + Declaration::Partial(p) => { + if matches!(p.visibility, Visibility::Public) { + exports.push(ExportedSymbol::Function(p.name.clone())); + } + } Declaration::Import(import) => { // Both `from module import X` and `from rust::crate import X` are treated as re-exports. This lets // stdlib files like `response.incn` expose axum types (`from rust::axum import Json`) to importers @@ -472,7 +477,7 @@ pub fn exported_symbols(ast: &Program) -> Vec { } } } - Declaration::Partial(_) | Declaration::Docstring(_) | Declaration::TestModule(_) => {} + Declaration::Docstring(_) | Declaration::TestModule(_) => {} } } @@ -1091,6 +1096,26 @@ source-root = "library" } } + #[test] + fn test_exported_symbols_partial() -> Result<(), Vec> { + let source = r#" +pub def route(method: str, path: str) -> str: + return path + +pub get = partial route(method="GET") +"#; + let tokens = lexer::lex(source).map_err(|e| e.iter().map(|x| x.message.clone()).collect::>())?; + let ast = parser::parse(&tokens).map_err(|e| e.iter().map(|x| x.message.clone()).collect::>())?; + let exports = exported_symbols(&ast); + assert!( + exports + .iter() + .any(|export| matches!(export, ExportedSymbol::Function(name) if name == "get")), + "expected public partial callable export, got {exports:?}" + ); + Ok(()) + } + #[test] fn test_exported_symbols_ignores_module_imports() { let import = ImportDecl { diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 07fb8682f..35f8ec9b0 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -2489,6 +2489,9 @@ impl TypeChecker { } let resolve_on = |checker: &mut Self, ty: &ResolvedType| -> ResolvedType { + if field == "__name__" && checker.is_generic_placeholder_type(ty) { + return ResolvedType::Str; + } match ty { ResolvedType::Unknown => ResolvedType::Unknown, // Trait default methods typecheck against `Self`, but field access must be declared via @@ -2512,6 +2515,7 @@ impl TypeChecker { checker.errors.push(errors::missing_field(&ty.to_string(), field, span)); ResolvedType::Unknown } + ResolvedType::Function(_, _) if field == "__name__" => ResolvedType::Str, ResolvedType::Named(type_name) => { if let Some(field_ty) = checker.resolve_nominal_field_type(type_name, None, field, span) { return field_ty; @@ -2537,6 +2541,9 @@ impl TypeChecker { ResolvedType::Unknown } ResolvedType::TypeVar(name) => { + if field == "__name__" { + return ResolvedType::Str; + } if let Some(property_ty) = checker.resolve_generic_placeholder_property(name, field, span) { return property_ty; } diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index aa9259243..4535c495f 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -529,6 +529,37 @@ def use() -> str: .unwrap_or_else(|errs| panic!("consumer should import public partial callable: {errs:?}")); } +#[test] +fn test_from_import_accepts_public_partial_export() { + let library = parse_program( + r#" +pub model Spec: + namespace: str + policy: str + klass: str + lifecycle: str + +pub core_spec = partial Spec(namespace="core", policy="portable") +"#, + "partial import library", + ); + let consumer = parse_program( + r#" +from presets import core_spec + +def use() -> str: + spec = core_spec(klass="scalar", lifecycle="v1") + return spec.namespace +"#, + "partial from-import consumer", + ); + + let mut checker = TypeChecker::new(); + checker + .check_with_imports(&consumer, &[("presets", &library)]) + .unwrap_or_else(|errs| panic!("consumer should import public partial callable by name: {errs:?}")); +} + #[test] fn test_method_partial_presets_project_as_defaults_for_trait_and_model() { let source = r#" @@ -5044,6 +5075,23 @@ def main() -> int: Ok(()) } +#[test] +fn test_function_callable_name_metadata_typechecks_issue694() { + let source = r#" +def capture(func: (int) -> int) -> ((int) -> int): + name: str = func.__name__ + return func + +def registered() -> (((int) -> int) -> ((int) -> int)): + return capture + +@registered() +pub def sample(value: int) -> int: + return value + 1 +"#; + assert_check_ok(source); +} + #[test] fn test_user_defined_decorator_factory_and_stacking_apply_bottom_up() { let source = r#" diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 650a4a3ab..2c65436cf 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1829,6 +1829,222 @@ def test_alias() -> None: Ok(()) } +#[test] +fn test_imported_public_partial_presets_keep_projected_call_surface_issue698() -> Result<(), Box> +{ + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "imported_public_partial_preset", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("presets.incn"), + r#"pub model Spec: + pub namespace: str + pub policy: str + pub klass: str + pub lifecycle: str + + +"""Build a core portable spec.""" +pub core_spec = partial Spec(namespace="core", policy="portable") +"#, + )?; + fs::write( + tests_dir.join("test_imported_partial.incn"), + r#"from presets import core_spec + + +def test_imported_partial_preset_keeps_presets() -> None: + spec = core_spec(klass="scalar", lifecycle="v1") + assert spec.namespace == "core" + assert spec.policy == "portable" + assert spec.klass == "scalar" + assert spec.lifecycle == "v1" +"#, + )?; + + let test_path = tests_dir.join("test_imported_partial.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success(&test_output, "incan test for imported public partial issue698"); + Ok(()) +} + +#[test] +fn test_imported_partial_preset_defaults_survive_decorator_argument_issue698() -> Result<(), Box> +{ + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "imported_partial_decorator_argument", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("function_registry.incn"), + r#"pub model FunctionSpec: + pub namespace: str + pub deterministic: bool + pub lifecycle: str + + +pub static registered_names: list[str] = [] +pub static registered_namespaces: list[str] = [] + + +pub def capture(func: (int) -> int) -> ((int) -> int): + registered_names.append(func.__name__) + return func + + +pub def add(spec: FunctionSpec) -> (((int) -> int) -> ((int) -> int)): + registered_namespaces.append(spec.namespace) + return capture + + +pub deterministic_spec = partial FunctionSpec(namespace="core", deterministic=true) +"#, + )?; + fs::write( + src_dir.join("helpers.incn"), + r#"from function_registry import add, deterministic_spec + + +@add(deterministic_spec(lifecycle="stable")) +pub def normalize(value: int) -> int: + return value +"#, + )?; + fs::write( + tests_dir.join("test_registry_intent.incn"), + r#"from function_registry import registered_names, registered_namespaces +from helpers import normalize + + +def test_decorator_can_infer_name_with_imported_partial_spec() -> None: + assert normalize(7) == 7 + assert registered_names[0] == "normalize" + assert registered_namespaces[0] == "core" +"#, + )?; + + let test_path = tests_dir.join("test_registry_intent.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success( + &test_output, + "incan test for imported partial in decorator argument issue698", + ); + Ok(()) +} + +#[test] +fn test_decorator_callable_exposes_source_name_issue694() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "decorator_callable_name", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + &main_path, + r#"def main() -> None: + pass +"#, + )?; + fs::write( + src_dir.join("registry.incn"), + r#"pub static names: list[str] = [] + + +pub def capture(func: (int) -> int) -> ((int) -> int): + names.append(func.__name__) + return func + + +pub def registered() -> (((int) -> int) -> ((int) -> int)): + return capture +"#, + )?; + fs::write( + tests_dir.join("test_callable_name.incn"), + r#"from registry import names, registered + + +@registered() +pub def sample(value: int) -> int: + return value + 1 + + +def test_decorator_can_read_specific_callable_name() -> None: + assert sample(1) == 2 + assert names[0] == "sample" +"#, + )?; + + let test_path = tests_dir.join("test_callable_name.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success(&test_output, "incan test for decorator callable name issue694"); + Ok(()) +} + +#[test] +fn test_generic_decorator_callable_exposes_source_name_issue694() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "generic_decorator_callable_name", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("registry.incn"), + r#"pub static names: list[str] = [] + + +pub def capture[F](func: F) -> F: + names.append(func.__name__) + return func + + +pub def registered[F]() -> ((F) -> F): + return (func) => capture[F](func) +"#, + )?; + fs::write( + src_dir.join("helpers.incn"), + r#"from registry import names, registered + + +@registered[(int) -> int]() +pub def sample(value: int) -> int: + return value + 1 +"#, + )?; + fs::write( + tests_dir.join("test_generic_callable_name.incn"), + r#"from registry import names +from helpers import sample + + +def test_generic_decorator_can_read_callable_name() -> None: + assert sample(1) == 2 + assert names[0] == "sample" +"#, + )?; + + let test_path = tests_dir.join("test_generic_callable_name.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success(&test_output, "incan test for generic decorator callable name issue694"); + Ok(()) +} + #[test] fn build_frozen_uses_existing_lockfile_without_network() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/fixtures/vocab_guardrails/semantic_string_audit.json b/tests/fixtures/vocab_guardrails/semantic_string_audit.json index 7ad8d79ef..401b471e5 100644 --- a/tests/fixtures/vocab_guardrails/semantic_string_audit.json +++ b/tests/fixtures/vocab_guardrails/semantic_string_audit.json @@ -102,6 +102,12 @@ "expected_count": 1, "expected_fingerprint": "0x79dfdfd491f691d1" }, + { + "path": "src/backend/ir/emit/expressions/indexing.rs", + "category": "callable source-name metadata emission", + "expected_count": 1, + "expected_fingerprint": "0x87dd19d1c7e84652" + }, { "path": "src/backend/ir/emit/expressions/methods.rs", "category": "quarantined metadata-free method compatibility", @@ -126,6 +132,12 @@ "expected_count": 1, "expected_fingerprint": "0x90822765d714d957" }, + { + "path": "src/backend/ir/emit/program.rs", + "category": "callable source-name metadata use analysis", + "expected_count": 2, + "expected_fingerprint": "0x497886b639dd72c9" + }, { "path": "src/backend/ir/emit/types.rs", "category": "Rust path and static trait emission compatibility", @@ -138,6 +150,12 @@ "expected_count": 23, "expected_fingerprint": "0x5c7ee976092c9c9a" }, + { + "path": "src/backend/ir/lower/decl/functions.rs", + "category": "generic callable source-name lowering compatibility", + "expected_count": 2, + "expected_fingerprint": "0xc94cea987d050f33" + }, { "path": "src/backend/ir/lower/decl/helpers.rs", "category": "primitive, derive, and Rust namespace lowering compatibility", @@ -231,8 +249,8 @@ { "path": "src/frontend/typechecker/check_expr/access.rs", "category": "method/type access surface classification", - "expected_count": 40, - "expected_fingerprint": "0xd87e478e91056a9f" + "expected_count": 43, + "expected_fingerprint": "0x8ea35141db935e1c" }, { "path": "src/frontend/typechecker/check_expr/basics.rs", diff --git a/workspaces/docs-site/docs/language/reference/feature_inventory.md b/workspaces/docs-site/docs/language/reference/feature_inventory.md index 23f4f1ab5..355577557 100644 --- a/workspaces/docs-site/docs/language/reference/feature_inventory.md +++ b/workspaces/docs-site/docs/language/reference/feature_inventory.md @@ -39,7 +39,7 @@ Use it when deciding whether code should use an existing Incan surface before ad | Symbol, method, and variant aliases | Syntax | 0.3 | None. | `pub average = alias avg`
`mean = avg`
`WARNING = alias WARN` | Aliases expose another resolved name for the same declaration, method, or enum variant without duplicating behavior. | Wrapper functions or duplicated enum variants used only for compatibility names. | [Symbol aliases](symbol_aliases.md), [Imports and modules](imports_and_modules.md), [Release 0.3](../../release_notes/0_3.md) | | Callable presets with `partial` | Syntax | 0.3 | None. | `pub get = partial route(method="GET")`
`set_alive = partial set_state(state=true)` | `partial` creates a callable surface from an existing callable by supplying named preset values. | Hand-written wrappers whose only job is to pass the same keyword defaults. | [Callable presets](callable_presets.md), [Callable presets explained](../explanation/callable_presets.md), [Release 0.3](../../release_notes/0_3.md) | | Rest parameters, unpacking, and spreads | Syntax | 0.3 | None. | `def log(*items: str, **fields: str) -> None:`
`f(*xs, **kw)`
`[*prefix, item]`
`{**base, "x": 1}` | Functions can capture `*args` / `**kwargs`; calls and literals support typed unpack/spread forms. | Manually spelling every forwarding arity or merging collections one element at a time. | [Functions and calls](functions.md), [Release 0.3](../../release_notes/0_3.md) | -| User-defined decorators | Syntax | 0.3 | None for user-defined decorators; compiler-owned decorators keep their documented imports. | `@logged`
`@registered("catalog.ref")`
`@registered[(str) -> ColumnExpr]("catalog.ref")` | Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type. | Boilerplate wrapper declarations around every function that needs the same callable transform. | [Language reference](language.md#decorators), [Derives and traits](derives_and_traits.md), [Release 0.3](../../release_notes/0_3.md) | +| User-defined decorators | Syntax | 0.3 | None for user-defined decorators; compiler-owned decorators keep their documented imports. | `@logged`
`@registered("catalog.ref")`
`func.__name__`
`@registered[(str) -> ColumnExpr]("catalog.ref")` | Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type and decorator helpers that expose `func.__name__`. | Boilerplate wrapper declarations around every function that needs the same callable transform. | [Language reference](language.md#decorators), [Derives and traits](derives_and_traits.md), [Release 0.3](../../release_notes/0_3.md) | | Generators | Syntax | 0.3 | None. | `def numbers() -> Generator[int]:`
`yield value`
`(x * 2 for x in values)` | `yield`-based functions and generator expressions produce lazy `Generator[T]` values. | Eager list construction when callers only need lazy iteration. | [Generators](generators.md), [Generators how-to](../how-to/generators.md), [Release 0.3](../../release_notes/0_3.md) | | Iterator adapters and terminal consumers | Stdlib | 0.3 | Use iterator values. | `values.iter().map(parse).filter(valid).collect()`
`items.enumerate().take(10)`
`numbers.fold(0, add)` | Iterator pipelines expose lazy adapters and explicit terminal consumers. | Manual loop accumulators for ordinary map/filter/fold pipeline shapes. | [Collection protocols](stdlib_traits/collection_protocols.md), [Release 0.3](../../release_notes/0_3.md) | | `Result[T, E]` combinators | Stdlib | 0.3 | Use `Result[T, E]` values. | `result.map(transform)`
`result.and_then(validate)`
`result.inspect(log_success)` | `Result` values support branch-local transforms, fallible chaining, recovery, and inspection taps. | Nested matches that only rewrap `Ok` / `Err` around one transformed branch. | [std.result](stdlib/result.md), [Fallible and infallible paths](../tutorials/fallible_and_infallible_paths.md), [Release 0.3](../../release_notes/0_3.md) | @@ -464,12 +464,13 @@ Canonical forms: - **Use instead of:** Boilerplate wrapper declarations around every function that needs the same callable transform. - **References:** [Language reference](language.md#decorators), [Derives and traits](derives_and_traits.md), [Release 0.3](../../release_notes/0_3.md) -Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type. +Decorators are ordinary callable values applied to functions and methods, including generic decorator factories that infer or accept the decorated function type and decorator helpers that expose `func.__name__`. Canonical forms: - `@logged` - `@registered("catalog.ref")` +- `func.__name__` - `@registered[(str) -> ColumnExpr]("catalog.ref")` ### Generators diff --git a/workspaces/docs-site/docs/language/reference/language.md b/workspaces/docs-site/docs/language/reference/language.md index 4eae0ce04..c9f1da930 100644 --- a/workspaces/docs-site/docs/language/reference/language.md +++ b/workspaces/docs-site/docs/language/reference/language.md @@ -332,6 +332,21 @@ pub def col(name: str) -> ColumnExpr: The post-decoration binding keeps the concrete callable signature of the decorated function unless the decorator deliberately returns a different callable shape. Checked API metadata and imports observe that concrete signature, not the generic helper's `F`. +The callable value passed into a decorator exposes `__name__` as the source callable name. Registry and catalog decorators can use this from concrete decorator helpers and from generic `(F) -> F` helpers, so a decorator can record `func.__name__` without requiring the decorated declaration to repeat its own public name in a string argument. + +```incan +def capture[F](func: F) -> F: + registry_names.append(func.__name__) + return func + +def registered[F]() -> ((F) -> F): + return (func) => capture[F](func) + +@registered() +pub def sample(value: int) -> int: + return value + 1 +``` + Method decorators receive an unbound callable shape with the receiver first. A decorator on `def label(self, value: int) -> str` sees `(&Box, int) -> str`; a decorator on `def bump(mut self, value: int) -> int` sees `(&mut Box, int) -> int`. The wrapper passes the actual receiver borrow through to the decorated callable, so method decorators do not require cloning the receiver. Class, model, trait, enum, newtype, field, alias, and module decorators remain limited to compiler-owned decorators. Compiler-owned decorators such as `@derive`, `@route`, `@rust.extern`, `@rust.allow`, `@staticmethod`, `@classmethod`, and `@requires` keep their existing special behavior. diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 9e4dfcabf..489145d02 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -39,7 +39,7 @@ Use this section as the map. The release note names each larger feature, says wh - **Value enums**: Keep enum type safety while exposing canonical `str` or `int` representations for external values. Read [Enums](../language/explanation/enums.md) and [Modeling with enums](../language/how-to/modeling_with_enums.md) ([RFC 032], #317). - **Enum methods and trait adoption**: Put enum-owned behavior on the enum and let enums adopt the same trait protocols as other source types. Read [Enums](../language/explanation/enums.md) and [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) ([RFC 050], #334). - **Computed properties and protocol hooks**: Define property-like readers and dunder-backed operator/protocol behavior without pushing users into Rust-shaped wrappers. Read [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) and [Derives and traits](../language/reference/derives_and_traits.md) ([RFC 046], [RFC 068], [RFC 028], #86, #162, #203). -- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape. Read [Functions](../language/reference/functions.md) and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640). +- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape, and concrete decorated callable values expose `__name__` for registry-style decorators. Read [Decorators](../language/reference/language.md#decorators), [Functions](../language/reference/functions.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640, #694). - **Symbol aliases**: Export an existing callable or type-like symbol under another name without pretending it is a hand-written wrapper. Read [Symbol aliases](../language/reference/symbol_aliases.md) ([RFC 083], #437). - **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). - **Variadics and call unpacking**: Describe call shapes that accept or forward flexible argument lists without losing static checks. Read [Functions](../language/reference/functions.md) ([RFC 038], #83). @@ -105,8 +105,10 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Cross-module codegen is more predictable**: Imported defaults qualify correctly, same-shaped union wrappers are shared, wide union narrowing lowers fully, keyword-named modules escape consistently, and public submodule reexports work under `src/` (#395, #457, #461, #458, #122, #287). - **Web registration keeps private internals private**: Private route handlers and models are retained for web registration without making them user-visible public API (#117). -- **Package exports match ordinary builds**: Public aliases, package-boundary alias consumption, lowercase exported statics, imported static decorator strings, and keyword-named public symbols follow the same rules across build modes (#617, #631, #633, #658, #659). +- **Package exports match ordinary builds**: Public aliases, public partial presets, package-boundary alias consumption, lowercase exported statics, imported static decorator strings, and keyword-named public symbols follow the same rules across build modes (#617, #631, #633, #658, #659, #698). +- **Partial presets keep their defaults in decorators**: Imported public partials now retain their projected default arguments when used inside decorator factory arguments, matching ordinary runtime calls (#698). - **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, method-call decorator factories, and reexport-only facade projections are represented in checked metadata more reliably (#636, #638, #640, #669, #694, #695). +- **Decorator helpers can inspect generic callables**: `func.__name__` works in generic `(F) -> F` decorator helpers, so registry decorators can infer the decorated helper name instead of repeating it as a string (#694). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). ### Formatter And Test Runner From 6bcec3a16b60d8b550bcc5947825633700a3e29e Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Tue, 26 May 2026 21:23:04 +0200 Subject: [PATCH 43/58] bugfix - scope generic callable-name helper planning (#701) (#702) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/codegen.rs | 115 ++++------ src/backend/ir/codegen/dependency_metadata.rs | 48 +++- src/backend/ir/emit/expressions/indexing.rs | 27 +-- src/backend/ir/emit/expressions/mod.rs | 5 + src/backend/ir/emit/mod.rs | 57 +++++ src/backend/ir/emit/program.rs | 184 ++++++++------- src/backend/ir/emit/types.rs | 19 +- tests/cli_integration.rs | 209 ++++++++++++++++++ .../docs-site/docs/release_notes/0_3.md | 4 +- 11 files changed, 499 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51627a401..18474cd7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc18" +version = "0.3.0-rc19" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index b193ff9dc..645780cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc18" +version = "0.3.0-rc19" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 899bd3afd..7fe7b7aeb 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -49,8 +49,8 @@ mod ordinal_bridge; mod serde_activation; use dependency_metadata::{ - collect_dependency_type_metadata, collect_externally_reachable_items_by_module, collect_model_field_aliases, - should_preserve_dependency_public_items, + DependencySymbolMetadata, collect_dependency_symbol_metadata, collect_externally_reachable_items_by_module, + collect_model_field_aliases, should_preserve_dependency_public_items, }; use ordinal_bridge::{OrdinalBridgeConfig, compilation_imports_std_ordinal_contract, imports_std_ordinal_contract}; use serde_activation::{add_serde_to_newtypes, collect_serde_derives}; @@ -219,6 +219,16 @@ impl<'a> IrCodegen<'a> { registry } + fn apply_dependency_symbol_metadata(emitter: &mut IrEmitter<'_>, metadata: &DependencySymbolMetadata) { + emitter.set_type_module_paths(metadata.module_paths.clone(), metadata.ambiguous_type_names.clone()); + emitter.set_value_module_paths( + metadata.value_module_paths.clone(), + metadata.ambiguous_value_names.clone(), + ); + emitter.set_dependency_enum_types(metadata.enum_type_names.clone()); + emitter.set_external_error_trait_types(metadata.error_trait_type_names.clone()); + } + /// Enable strict generated Rust lint validation for `--emit-rust --strict`. pub fn set_strict_generated_lints(&mut self, enabled: bool) { self.strict_generated_lints = enabled; @@ -501,7 +511,7 @@ impl<'a> IrCodegen<'a> { // RFC 021: Make alias-aware lowering work across module boundaries by seeding alias maps // for models declared in dependency modules as well. let global_aliases = collect_model_field_aliases(program, &deps); - let dependency_type_metadata = collect_dependency_type_metadata(&self.dependency_modules); + let dependency_symbol_metadata = collect_dependency_symbol_metadata(&self.dependency_modules); let uses_std_ordinal_contract = compilation_imports_std_ordinal_contract(program, &self.dependency_modules); let ordinal_bridge = self.ordinal_bridge_config(uses_std_ordinal_contract); let (needs_serialize, needs_deserialize) = collect_serde_derives(program, &deps); @@ -536,13 +546,17 @@ impl<'a> IrCodegen<'a> { // RFC 023: Infer trait bounds for generic functions. super::trait_bound_inference::infer_trait_bounds(&mut ir_program); + let callable_name_use_facts = IrEmitter::callable_name_use_facts_for_program( + &ir_program, + &self.externally_reachable_items, + true, + &dependency_symbol_metadata.error_trait_type_names, + ); if let Some(used_keys) = callable_name_used_signature_keys.as_deref_mut() { - used_keys.extend(IrEmitter::callable_name_signature_keys_for_program( - &ir_program, - &self.externally_reachable_items, - true, - &dependency_type_metadata.error_trait_type_names, - )); + used_keys.extend(callable_name_use_facts.signature_keys.iter().cloned()); + if callable_name_use_facts.generic_trait_used { + used_keys.extend(callable_name_use_facts.function_arg_signature_keys.iter().cloned()); + } } if let Some(resolutions) = callable_name_resolutions.as_deref_mut() { IrEmitter::add_callable_name_resolutions_for_program(resolutions, Vec::new(), &ir_program); @@ -551,10 +565,13 @@ impl<'a> IrCodegen<'a> { .as_ref() .map(|resolutions| (**resolutions).clone()) .unwrap_or_default(); - let callable_name_used_signature_keys_for_emit = callable_name_used_signature_keys + let mut callable_name_used_signature_keys_for_emit = callable_name_used_signature_keys .as_ref() .map(|used_keys| (**used_keys).clone()) .unwrap_or_default(); + if callable_name_use_facts.generic_trait_used { + callable_name_used_signature_keys_for_emit.extend(callable_name_use_facts.function_arg_signature_keys); + } let mut canonical_registry = FunctionRegistry::new(); let mut dependency_ir_programs = Vec::new(); @@ -595,12 +612,7 @@ impl<'a> IrCodegen<'a> { if self.emit_zen_in_main { inner.set_emit_zen(true); } - inner.set_type_module_paths( - dependency_type_metadata.module_paths.clone(), - dependency_type_metadata.ambiguous_type_names.clone(), - ); - inner.set_dependency_enum_types(dependency_type_metadata.enum_type_names.clone()); - inner.set_external_error_trait_types(dependency_type_metadata.error_trait_type_names.clone()); + Self::apply_dependency_symbol_metadata(inner, &dependency_symbol_metadata); inner.set_needs_serde(self.needs_serde); inner.set_external_rust_functions(self.external_rust_functions.clone()); inner.set_strict_generated_lints(self.strict_generated_lints); @@ -623,12 +635,7 @@ impl<'a> IrCodegen<'a> { if self.emit_zen_in_main { emitter.set_emit_zen(true); } - emitter.set_type_module_paths( - dependency_type_metadata.module_paths.clone(), - dependency_type_metadata.ambiguous_type_names.clone(), - ); - emitter.set_dependency_enum_types(dependency_type_metadata.enum_type_names.clone()); - emitter.set_external_error_trait_types(dependency_type_metadata.error_trait_type_names.clone()); + Self::apply_dependency_symbol_metadata(&mut emitter, &dependency_symbol_metadata); emitter.set_needs_serde(self.needs_serde); emitter.set_external_rust_functions(self.external_rust_functions.clone()); emitter.set_strict_generated_lints(self.strict_generated_lints); @@ -766,7 +773,7 @@ impl<'a> IrCodegen<'a> { .map(|(name, ast, _)| (*name, *ast)) .collect(); let global_aliases = collect_model_field_aliases(program, &deps); - let dependency_type_metadata = collect_dependency_type_metadata(&self.dependency_modules); + let dependency_symbol_metadata = collect_dependency_symbol_metadata(&self.dependency_modules); let uses_std_ordinal_contract = compilation_imports_std_ordinal_contract(program, &self.dependency_modules); let ordinal_bridge = OrdinalBridgeConfig::for_internal_module(uses_std_ordinal_contract); let dependency_reachable_items = @@ -832,6 +839,7 @@ impl<'a> IrCodegen<'a> { // Generate main file after dependency lowering so it can own shared crate-root union wrappers. let mut callable_name_resolutions = HashMap::new(); let mut callable_name_used_signature_keys = HashSet::new(); + let mut callable_name_function_arg_signature_keys = HashSet::new(); let mut generic_callable_name_trait_used = false; for (_, module_path, ir) in &lowered_modules { IrEmitter::add_callable_name_resolutions_for_program( @@ -845,21 +853,18 @@ impl<'a> IrCodegen<'a> { } let preserve_public_items = should_preserve_dependency_public_items(module_path, self.preserve_dependency_public_items); - callable_name_used_signature_keys.extend(IrEmitter::callable_name_signature_keys_for_program( - ir, - &reachable_items, - preserve_public_items, - &dependency_type_metadata.error_trait_type_names, - )); - generic_callable_name_trait_used |= IrEmitter::generic_callable_name_trait_used_for_program( + let callable_name_use_facts = IrEmitter::callable_name_use_facts_for_program( ir, &reachable_items, preserve_public_items, - &dependency_type_metadata.error_trait_type_names, + &dependency_symbol_metadata.error_trait_type_names, ); + callable_name_used_signature_keys.extend(callable_name_use_facts.signature_keys); + callable_name_function_arg_signature_keys.extend(callable_name_use_facts.function_arg_signature_keys); + generic_callable_name_trait_used |= callable_name_use_facts.generic_trait_used; } if generic_callable_name_trait_used { - callable_name_used_signature_keys.extend(callable_name_resolutions.keys().cloned()); + callable_name_used_signature_keys.extend(callable_name_function_arg_signature_keys); } let main_code = self.try_generate_via_ir_with_union_config( @@ -886,12 +891,7 @@ impl<'a> IrCodegen<'a> { inner.set_internal_module_roots(internal_roots.clone()); inner.set_preserve_public_items(preserve_public_items); inner.set_externally_reachable_items(reachable_items.clone()); - inner.set_type_module_paths( - dependency_type_metadata.module_paths.clone(), - dependency_type_metadata.ambiguous_type_names.clone(), - ); - inner.set_dependency_enum_types(dependency_type_metadata.enum_type_names.clone()); - inner.set_external_error_trait_types(dependency_type_metadata.error_trait_type_names.clone()); + Self::apply_dependency_symbol_metadata(inner, &dependency_symbol_metadata); inner.set_external_rust_functions(self.external_rust_functions.clone()); inner.set_qualify_union_types_from_crate(true); inner.set_emit_generated_union_definitions(false); @@ -909,12 +909,7 @@ impl<'a> IrCodegen<'a> { emitter.set_internal_module_roots(internal_roots.clone()); emitter.set_preserve_public_items(preserve_public_items); emitter.set_externally_reachable_items(reachable_items); - emitter.set_type_module_paths( - dependency_type_metadata.module_paths.clone(), - dependency_type_metadata.ambiguous_type_names.clone(), - ); - emitter.set_dependency_enum_types(dependency_type_metadata.enum_type_names.clone()); - emitter.set_external_error_trait_types(dependency_type_metadata.error_trait_type_names.clone()); + Self::apply_dependency_symbol_metadata(&mut emitter, &dependency_symbol_metadata); emitter.set_external_rust_functions(self.external_rust_functions.clone()); emitter.set_qualify_union_types_from_crate(true); emitter.set_emit_generated_union_definitions(false); @@ -1008,7 +1003,7 @@ impl<'a> IrCodegen<'a> { .map(|(name, ast, _)| (*name, *ast)) .collect(); let global_aliases = collect_model_field_aliases(program, &deps); - let dependency_type_metadata = collect_dependency_type_metadata(&self.dependency_modules); + let dependency_symbol_metadata = collect_dependency_symbol_metadata(&self.dependency_modules); let uses_std_ordinal_contract = compilation_imports_std_ordinal_contract(program, &self.dependency_modules); let ordinal_bridge = OrdinalBridgeConfig::for_internal_module(uses_std_ordinal_contract); let dependency_reachable_items = @@ -1071,6 +1066,7 @@ impl<'a> IrCodegen<'a> { // Generate main file after dependency lowering so it can own shared crate-root union wrappers. let mut callable_name_resolutions = HashMap::new(); let mut callable_name_used_signature_keys = HashSet::new(); + let mut callable_name_function_arg_signature_keys = HashSet::new(); let mut generic_callable_name_trait_used = false; for (path, ir) in &lowered_modules { IrEmitter::add_callable_name_resolutions_for_program(&mut callable_name_resolutions, path.clone(), ir); @@ -1080,21 +1076,18 @@ impl<'a> IrCodegen<'a> { } let preserve_public_items = should_preserve_dependency_public_items(path, self.preserve_dependency_public_items); - callable_name_used_signature_keys.extend(IrEmitter::callable_name_signature_keys_for_program( + let callable_name_use_facts = IrEmitter::callable_name_use_facts_for_program( ir, &reachable_items, preserve_public_items, - &dependency_type_metadata.error_trait_type_names, - )); - generic_callable_name_trait_used |= IrEmitter::generic_callable_name_trait_used_for_program( - ir, - &reachable_items, - preserve_public_items, - &dependency_type_metadata.error_trait_type_names, + &dependency_symbol_metadata.error_trait_type_names, ); + callable_name_used_signature_keys.extend(callable_name_use_facts.signature_keys); + callable_name_function_arg_signature_keys.extend(callable_name_use_facts.function_arg_signature_keys); + generic_callable_name_trait_used |= callable_name_use_facts.generic_trait_used; } if generic_callable_name_trait_used { - callable_name_used_signature_keys.extend(callable_name_resolutions.keys().cloned()); + callable_name_used_signature_keys.extend(callable_name_function_arg_signature_keys); } let main_code = self.try_generate_via_ir_with_union_config( @@ -1121,12 +1114,7 @@ impl<'a> IrCodegen<'a> { inner.set_internal_module_roots(internal_roots.clone()); inner.set_preserve_public_items(preserve_public_items); inner.set_externally_reachable_items(reachable_items.clone()); - inner.set_type_module_paths( - dependency_type_metadata.module_paths.clone(), - dependency_type_metadata.ambiguous_type_names.clone(), - ); - inner.set_dependency_enum_types(dependency_type_metadata.enum_type_names.clone()); - inner.set_external_error_trait_types(dependency_type_metadata.error_trait_type_names.clone()); + Self::apply_dependency_symbol_metadata(inner, &dependency_symbol_metadata); inner.set_external_rust_functions(self.external_rust_functions.clone()); inner.set_qualify_union_types_from_crate(true); inner.set_emit_generated_union_definitions(false); @@ -1144,12 +1132,7 @@ impl<'a> IrCodegen<'a> { emitter.set_internal_module_roots(internal_roots.clone()); emitter.set_preserve_public_items(preserve_public_items); emitter.set_externally_reachable_items(reachable_items); - emitter.set_type_module_paths( - dependency_type_metadata.module_paths.clone(), - dependency_type_metadata.ambiguous_type_names.clone(), - ); - emitter.set_dependency_enum_types(dependency_type_metadata.enum_type_names.clone()); - emitter.set_external_error_trait_types(dependency_type_metadata.error_trait_type_names.clone()); + Self::apply_dependency_symbol_metadata(&mut emitter, &dependency_symbol_metadata); emitter.set_external_rust_functions(self.external_rust_functions.clone()); emitter.set_qualify_union_types_from_crate(true); emitter.set_emit_generated_union_definitions(false); diff --git a/src/backend/ir/codegen/dependency_metadata.rs b/src/backend/ir/codegen/dependency_metadata.rs index 541e4cb13..f0519d146 100644 --- a/src/backend/ir/codegen/dependency_metadata.rs +++ b/src/backend/ir/codegen/dependency_metadata.rs @@ -209,21 +209,25 @@ pub(super) fn collect_externally_reachable_items_by_module( reachable } -/// Dependency type facts gathered during codegen setup and reused by module emission. +/// Dependency symbol facts gathered during codegen setup and reused by module emission. #[derive(Debug, Clone, Default)] -pub(super) struct DependencyTypeMetadata { +pub(super) struct DependencySymbolMetadata { pub(super) module_paths: HashMap>, pub(super) ambiguous_type_names: HashSet, + pub(super) value_module_paths: HashMap>, + pub(super) ambiguous_value_names: HashSet, pub(super) enum_type_names: HashSet, pub(super) error_trait_type_names: HashSet, } -/// Collect dependency type metadata needed by IR emission for cross-module nominal types. -pub(super) fn collect_dependency_type_metadata( +/// Collect dependency symbol metadata needed by IR emission for cross-module nominal types and values. +pub(super) fn collect_dependency_symbol_metadata( deps: &[(&str, &Program, Option>)], -) -> DependencyTypeMetadata { +) -> DependencySymbolMetadata { let mut paths: HashMap> = HashMap::new(); let mut ambiguous: HashSet = HashSet::new(); + let mut value_paths: HashMap> = HashMap::new(); + let mut ambiguous_values: HashSet = HashSet::new(); let mut enum_type_names: HashSet = HashSet::new(); let mut non_enum_type_names: HashSet = HashSet::new(); let mut error_trait_type_names: HashSet = HashSet::new(); @@ -231,6 +235,33 @@ pub(super) fn collect_dependency_type_metadata( for (_name, program, path_segments) in deps { for decl in &program.declarations { + if let Some(segs) = path_segments.as_ref() + && let Some(name) = match &decl.node { + Declaration::Const(c) => Some(&c.name), + Declaration::Static(s) => Some(&s.name), + Declaration::Function(f) => Some(&f.name), + Declaration::Partial(p) => Some(&p.name), + Declaration::Alias(a) => Some(&a.name), + Declaration::Import(_) + | Declaration::Model(_) + | Declaration::Class(_) + | Declaration::Trait(_) + | Declaration::TypeAlias(_) + | Declaration::Newtype(_) + | Declaration::Enum(_) + | Declaration::TestModule(_) + | Declaration::Docstring(_) => None, + } + { + if let Some(existing) = value_paths.get(name) { + if existing != segs { + ambiguous_values.insert(name.clone()); + } + } else { + value_paths.insert(name.clone(), segs.clone()); + } + } + let type_name = match &decl.node { Declaration::Model(m) => { if m.traits.iter().any(|bound| bound.node.name == error_trait_name) { @@ -276,11 +307,16 @@ pub(super) fn collect_dependency_type_metadata( for name in &ambiguous { paths.remove(name); } + for name in &ambiguous_values { + value_paths.remove(name); + } enum_type_names.retain(|name| !ambiguous.contains(name) && !non_enum_type_names.contains(name)); - DependencyTypeMetadata { + DependencySymbolMetadata { module_paths: paths, ambiguous_type_names: ambiguous, + value_module_paths: value_paths, + ambiguous_value_names: ambiguous_values, enum_type_names, error_trait_type_names, } diff --git a/src/backend/ir/emit/expressions/indexing.rs b/src/backend/ir/emit/expressions/indexing.rs index 81d538500..d7725d804 100644 --- a/src/backend/ir/emit/expressions/indexing.rs +++ b/src/backend/ir/emit/expressions/indexing.rs @@ -51,9 +51,7 @@ impl<'a> IrEmitter<'a> { return Ok(quote! { "".to_string() }); }; let callable = self.emit_expr(object)?; - let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); - let ret_tokens = self.emit_type(ret); - let fn_ty = quote! { fn(#(#param_tokens),*) -> #ret_tokens }; + let fn_ty = self.emit_callable_fn_type(params, ret); let helper = Self::callable_name_helper_ident(&signature_key); let mut helper_calls = Vec::new(); @@ -112,29 +110,6 @@ impl<'a> IrEmitter<'a> { iter.fold(first, |acc, segment| quote! { #acc :: #segment }) } - /// Build the fully-qualified generated-module path for a type imported from another emitted module. - /// - /// Default argument expressions can be expanded at a call site outside the module that declared the default. When - /// the default names an enum variant from that declaring module, the generated Rust must qualify the enum type - /// through the dependency module path instead of assuming the type name is locally imported. - fn emit_dependency_type_path(&self, name: &str) -> Option { - if name.contains("::") || self.ambiguous_type_names.contains(name) { - return None; - } - let module_path = self.type_module_paths.get(name)?; - let mut segments = vec![quote! { crate }]; - for segment in module_path { - let ident = Self::rust_ident(segment); - segments.push(quote! { #ident }); - } - let name_ident = Self::rust_ident(name); - segments.push(quote! { #name_ident }); - - let mut iter = segments.into_iter(); - let first = iter.next()?; - Some(iter.fold(first, |acc, segment| quote! { #acc :: #segment })) - } - /// Emit an index expression. /// /// Handles `list[i]` and `dict[k]` access with: diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 3f929b966..4e1c30976 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -688,6 +688,11 @@ impl<'a> IrEmitter<'a> { Ok(quote! { #n.get() }) } IrExprKind::Var { name, access: _, .. } => { + if *self.qualify_internal_canonical_paths.borrow() + && let Some(path) = self.emit_dependency_value_path(name) + { + return Ok(path); + } let n = Self::rust_ident(name); Ok(quote! { #n }) } diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 0ce789a0b..a677203d7 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -75,6 +75,14 @@ pub(crate) struct CallableNameResolution { pub(super) module_paths: Vec>, } +/// Callable-name usage facts collected from one lowered program. +#[derive(Debug, Clone, Default)] +pub(crate) struct CallableNameUseFacts { + pub(crate) signature_keys: HashSet, + pub(crate) function_arg_signature_keys: HashSet, + pub(crate) generic_trait_used: bool, +} + /// Usage facts collected before Rust emission. /// /// This analysis is intentionally about generated Rust lints, not source-language reachability diagnostics. It records @@ -104,6 +112,8 @@ pub(super) struct GeneratedUseAnalysis { pub(super) borrowed_function_adapters: HashSet<(String, Vec)>, /// Concrete function-pointer signatures whose values read `__name__`. pub(super) callable_name_signature_keys: HashSet, + /// Concrete top-level function signatures passed through reachable calls. + pub(super) callable_name_function_arg_signature_keys: HashSet, /// Whether a generic callable parameter reads `__name__` through the generated callable-name trait. pub(super) uses_generic_callable_name_trait: bool, } @@ -269,6 +279,10 @@ pub struct IrEmitter<'a> { type_module_paths: HashMap>, /// Type names that are declared in multiple modules (ambiguous). ambiguous_type_names: HashSet, + /// Map of value name -> module path segments for dependency modules. + value_module_paths: HashMap>, + /// Value names that are declared in multiple modules (ambiguous). + ambiguous_value_names: HashSet, /// Imported enum type names discovered from dependency modules. /// /// Imported enums usually lower to `IrType::Struct(name)` in consumer modules, so for-loop emission needs this @@ -384,6 +398,8 @@ impl<'a> IrEmitter<'a> { const_string_literals: std::collections::HashMap::new(), type_module_paths: HashMap::new(), ambiguous_type_names: HashSet::new(), + value_module_paths: HashMap::new(), + ambiguous_value_names: HashSet::new(), dependency_enum_types: HashSet::new(), external_error_trait_types: HashSet::new(), internal_module_roots: HashSet::new(), @@ -907,6 +923,47 @@ impl<'a> IrEmitter<'a> { self.ambiguous_type_names = ambiguous; } + /// Set value-to-module path mappings for dependency expressions that must be emitted outside their defining + /// module. + pub fn set_value_module_paths(&mut self, paths: HashMap>, ambiguous: HashSet) { + self.value_module_paths = paths; + self.ambiguous_value_names = ambiguous; + } + + pub(in crate::backend::ir::emit) fn emit_dependency_item_path( + &self, + module_path: &[String], + name: &str, + ) -> Option { + let mut segments = vec![quote! { crate }]; + for segment in module_path { + let ident = Self::rust_ident(segment); + segments.push(quote! { #ident }); + } + let ident = Self::rust_ident(name); + segments.push(quote! { #ident }); + + let mut iter = segments.into_iter(); + let first = iter.next()?; + Some(iter.fold(first, |acc, segment| quote! { #acc :: #segment })) + } + + pub(in crate::backend::ir::emit) fn emit_dependency_type_path(&self, name: &str) -> Option { + if name.contains("::") || self.ambiguous_type_names.contains(name) { + return None; + } + let module_path = self.type_module_paths.get(name)?; + self.emit_dependency_item_path(module_path, name) + } + + pub(in crate::backend::ir::emit) fn emit_dependency_value_path(&self, name: &str) -> Option { + if name.contains("::") || self.ambiguous_value_names.contains(name) { + return None; + } + let module_path = self.value_module_paths.get(name)?; + self.emit_dependency_item_path(module_path, name) + } + /// Set imported enum type names discovered during codegen setup. pub fn set_dependency_enum_types(&mut self, enum_type_names: HashSet) { self.dependency_enum_types = enum_type_names; diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index d7219c5e3..9947d01fb 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -20,7 +20,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use incan_core::lang::surface::result_methods::ResultMethodId; use incan_core::lang::traits::{self as core_traits, TraitId}; @@ -36,7 +36,7 @@ use super::super::expr::{ use super::super::stmt::AssignTarget; use super::super::types::{IR_UNION_TYPE_NAME, IrType}; use super::super::{FunctionRegistry, FunctionSignature, IrDecl, IrProgram, IrStmt, IrStmtKind, TypedExpr}; -use super::{EmitError, GeneratedUseAnalysis, IrEmitter}; +use super::{CallableNameUseFacts, EmitError, GeneratedUseAnalysis, IrEmitter}; struct OrdinalValueEnumBridgeSpec { type_path: TokenStream, @@ -571,6 +571,9 @@ impl<'program> GeneratedUseAnalyzer<'program> { self.scan_type(ty); } for arg in args { + for key in self.callable_name_function_arg_signature_keys(&arg.expr) { + self.analysis.callable_name_function_arg_signature_keys.insert(key); + } self.scan_expr(&arg.expr); } } @@ -791,6 +794,52 @@ impl<'program> GeneratedUseAnalyzer<'program> { } } + fn callable_name_function_arg_signature_keys(&self, expr: &TypedExpr) -> Vec { + match &expr.kind { + IrExprKind::Var { name, .. } => { + let mut keys = HashSet::new(); + if let IrType::Function { params, ret } = &expr.ty + && let Some(key) = IrEmitter::callable_name_signature_key(params, ret) + { + keys.insert(key); + } + if let Some(signature) = self.function_registry.get(name) { + let params = signature + .params + .iter() + .map(|param| param.ty.clone()) + .collect::>(); + if let Some(key) = IrEmitter::callable_name_signature_key(¶ms, &signature.return_type) { + keys.insert(key); + } + } + let Some(IrDecl { + kind: IrDeclKind::Function(func), + .. + }) = self.declarations_by_name.get(name).copied() + else { + let mut keys = keys.into_iter().collect::>(); + keys.sort(); + return keys; + }; + if func.is_async || !func.type_params.is_empty() { + return Vec::new(); + } + let params = func.params.iter().map(|param| param.ty.clone()).collect::>(); + if let Some(key) = IrEmitter::callable_name_signature_key(¶ms, &func.return_type) { + keys.insert(key); + } + let mut keys = keys.into_iter().collect::>(); + keys.sort(); + keys + } + IrExprKind::InteropCoerce { expr, .. } + | IrExprKind::NumericResize { expr, .. } + | IrExprKind::Cast { expr, .. } => self.callable_name_function_arg_signature_keys(expr), + _ => Vec::new(), + } + } + /// Record non-Copy observer callbacks that need generated borrowed helper items. fn record_result_observer_callback( &mut self, @@ -2070,13 +2119,13 @@ impl<'a> IrEmitter<'a> { fn emit_generated_union_member_type(&self, ty: &IrType) -> TokenStream { match ty { IrType::Struct(name) | IrType::Enum(name) | IrType::Trait(name) => self - .emit_dependency_nominal_type_path(name) + .emit_dependency_type_path(name) .unwrap_or_else(|| self.emit_type(ty)), IrType::NamedGeneric(name, args) if name == super::super::types::IR_UNION_TYPE_NAME => { self.emit_union_type_path(ty) } IrType::NamedGeneric(name, args) => { - let base = self.emit_dependency_nominal_type_path(name).unwrap_or_else(|| { + let base = self.emit_dependency_type_path(name).unwrap_or_else(|| { let ident = Self::rust_ident(name); quote! { #ident } }); @@ -2151,25 +2200,6 @@ impl<'a> IrEmitter<'a> { } } - /// Emit a crate-qualified path for an unambiguous nominal type declared in a dependency module. - fn emit_dependency_nominal_type_path(&self, name: &str) -> Option { - if name.contains("::") || self.ambiguous_type_names.contains(name) { - return None; - } - let module_path = self.type_module_paths.get(name)?; - let mut segments = vec![quote! { crate }]; - for segment in module_path { - let ident = Self::rust_ident(segment); - segments.push(quote! { #ident }); - } - let name_ident = Self::rust_ident(name); - segments.push(quote! { #name_ident }); - - let mut iter = segments.into_iter(); - let first = iter.next()?; - Some(iter.fold(first, |acc, segment| quote! { #acc :: #segment })) - } - /// Emit a complete IR program to formatted Rust code. #[tracing::instrument(skip_all, fields(decl_count = program.declarations.len()))] pub fn emit_program(&mut self, program: &IrProgram) -> Result { @@ -2278,34 +2308,23 @@ impl<'a> IrEmitter<'a> { Ok(format!("{}{}", header, with_marker)) } - pub(crate) fn callable_name_signature_keys_for_program( + pub(crate) fn callable_name_use_facts_for_program( program: &IrProgram, externally_reachable_items: &HashSet, preserve_public_items: bool, external_error_trait_types: &HashSet, - ) -> HashSet { - GeneratedUseAnalyzer::analyze( - program, - externally_reachable_items, - preserve_public_items, - external_error_trait_types, - ) - .callable_name_signature_keys - } - - pub(crate) fn generic_callable_name_trait_used_for_program( - program: &IrProgram, - externally_reachable_items: &HashSet, - preserve_public_items: bool, - external_error_trait_types: &HashSet, - ) -> bool { - GeneratedUseAnalyzer::analyze( + ) -> CallableNameUseFacts { + let analysis = GeneratedUseAnalyzer::analyze( program, externally_reachable_items, preserve_public_items, external_error_trait_types, - ) - .uses_generic_callable_name_trait + ); + CallableNameUseFacts { + signature_keys: analysis.callable_name_signature_keys, + function_arg_signature_keys: analysis.callable_name_function_arg_signature_keys, + generic_trait_used: analysis.uses_generic_callable_name_trait, + } } fn callable_name_signature_for_key(&self, key: &str) -> Option<(Vec, IrType)> { @@ -2330,29 +2349,15 @@ impl<'a> IrEmitter<'a> { fn callable_name_helper_keys( &self, local_callable_name_signature_keys: &HashSet, - include_all_callable_signatures: bool, + include_generic_callable_signatures: bool, ) -> Vec { let mut keys = local_callable_name_signature_keys.clone(); - if include_all_callable_signatures { - for (_, signature) in self.callable_name_local_registry().iter() { - let params = signature - .params - .iter() - .map(|param| param.ty.clone()) - .collect::>(); - if let Some(key) = Self::callable_name_signature_key(¶ms, &signature.return_type) { - keys.insert(key); - } - } - keys.extend( - self.callable_name_resolutions - .iter() - .filter(|(_, resolution)| { - self.callable_name_current_module_path.is_empty() - || resolution.module_paths.iter().any(|path| !path.is_empty()) - }) - .map(|(key, _)| key.clone()), - ); + if include_generic_callable_signatures { + keys.extend(self.callable_name_used_signature_keys.iter().filter_map(|key| { + self.callable_name_signature_for_key(key) + .is_some() + .then_some(key.clone()) + })); } for (key, resolution) in &self.callable_name_resolutions { if self.callable_name_used_signature_keys.contains(key) @@ -2368,7 +2373,12 @@ impl<'a> IrEmitter<'a> { keys } - fn callable_name_resolution_expr(&self, key: &str, callable_tokens: TokenStream) -> TokenStream { + fn callable_name_resolution_expr_with_fallback( + &self, + key: &str, + callable_tokens: TokenStream, + fallback: TokenStream, + ) -> TokenStream { let helper = Self::callable_name_helper_ident(key); let mut helper_calls = Vec::new(); helper_calls.push(quote! { #helper(#callable_tokens) }); @@ -2384,8 +2394,7 @@ impl<'a> IrEmitter<'a> { helper_calls.push(quote! { #helper_path(#callable_tokens) }); } } - let fallback = proc_macro2::Literal::string(""); - let mut resolved = quote! { #fallback.to_string() }; + let mut resolved = fallback; for helper_call in helper_calls.into_iter().rev() { resolved = quote! { if let Some(__incan_name) = #helper_call { @@ -2403,14 +2412,35 @@ impl<'a> IrEmitter<'a> { return None; } let trait_ident = Self::rust_ident("__IncanCallableName"); - let impls = keys - .iter() - .filter_map(|key| { - let (params, ret) = self.callable_name_signature_for_key(key)?; - let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); - let ret_tokens = self.emit_type(&ret); - let fn_ty = quote! { fn(#(#param_tokens),*) -> #ret_tokens }; - let resolved = self.callable_name_resolution_expr(key, quote! { __incan_callable }); + let mut grouped_keys: BTreeMap> = BTreeMap::new(); + for key in keys { + let Some((params, ret)) = self.callable_name_signature_for_key(key) else { + continue; + }; + let resolved_params = params + .iter() + .map(|param| self.resolve_type_aliases_for_emit(param)) + .collect::>(); + let resolved_ret = self.resolve_type_aliases_for_emit(&ret); + let Some(resolved_key) = Self::callable_name_signature_key(&resolved_params, &resolved_ret) else { + continue; + }; + grouped_keys.entry(resolved_key).or_default().push(key.clone()); + } + + let impls = grouped_keys + .values_mut() + .filter_map(|keys| { + keys.sort(); + let primary_key = keys.first()?; + let (params, ret) = self.callable_name_signature_for_key(primary_key)?; + let fn_ty = self.emit_callable_fn_type(¶ms, &ret); + let fallback = proc_macro2::Literal::string(""); + let mut resolved = quote! { #fallback.to_string() }; + for key in keys.iter().rev() { + resolved = + self.callable_name_resolution_expr_with_fallback(key, quote! { __incan_callable }, resolved); + } Some(quote! { impl #trait_ident for #fn_ty { fn __incan_callable_name(&self) -> String { @@ -2442,9 +2472,7 @@ impl<'a> IrEmitter<'a> { .filter_map(|key| { let (params, ret) = self.callable_name_signature_for_key(key)?; let helper = Self::callable_name_helper_ident(key); - let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); - let ret_tokens = self.emit_type(&ret); - let fn_ty = quote! { fn(#(#param_tokens),*) -> #ret_tokens }; + let fn_ty = self.emit_callable_fn_type(¶ms, &ret); let mut candidates = self .callable_name_local_registry() .iter() diff --git a/src/backend/ir/emit/types.rs b/src/backend/ir/emit/types.rs index 66876efad..6604a6401 100644 --- a/src/backend/ir/emit/types.rs +++ b/src/backend/ir/emit/types.rs @@ -122,6 +122,11 @@ impl<'a> IrEmitter<'a> { if name == surface_types::as_str(SurfaceTypeId::ValidationError) { return quote! { incan_stdlib::validation::ValidationError }; } + if *self.qualify_internal_canonical_paths.borrow() + && let Some(path) = self.emit_dependency_type_path(name) + { + return path; + } Self::emit_path_ident(name) } IrType::NamedGeneric(name, _) if name == super::super::types::IR_UNION_TYPE_NAME => { @@ -135,11 +140,15 @@ impl<'a> IrEmitter<'a> { Some(CollectionTypeId::Generator) => Some(quote! { incan_stdlib::iter::Generator }), _ => None, }; - let n = Self::emit_path_ident(name); let ts: Vec<_> = args.iter().map(|t| self.emit_type(t)).collect(); if let Some(n) = frozen_name { quote! { #n < #(#ts),* > } + } else if *self.qualify_internal_canonical_paths.borrow() + && let Some(n) = self.emit_dependency_type_path(name) + { + quote! { #n < #(#ts),* > } } else { + let n = Self::emit_path_ident(name); quote! { #n < #(#ts),* > } } } @@ -171,6 +180,14 @@ impl<'a> IrEmitter<'a> { } } + pub(in crate::backend::ir::emit) fn emit_callable_fn_type(&self, params: &[IrType], ret: &IrType) -> TokenStream { + let previous = self.qualify_internal_canonical_paths.replace(true); + let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); + let ret_tokens = self.emit_type(ret); + self.qualify_internal_canonical_paths.replace(previous); + quote! { fn(#(#param_tokens),*) -> #ret_tokens } + } + // ======================================================================== // RFC 023: Type parameter emission with trait bounds // ======================================================================== diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 2c65436cf..a8f51397f 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1941,6 +1941,78 @@ def test_decorator_can_infer_name_with_imported_partial_spec() -> None: Ok(()) } +#[test] +fn test_imported_partial_default_symbols_survive_decorator_argument_issue701() -> Result<(), Box> +{ + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "imported_partial_default_symbols_decorator", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("registry.incn"), + r#"pub const DEFAULT_NAMESPACE: str = "core" + + +pub enum Policy(str): + Portable = "portable" + + +pub model Spec: + pub namespace: str + pub policy: Policy + pub lifecycle: str + + +pub static namespaces: list[str] = [] +pub static names: list[str] = [] + + +pub spec = partial Spec(namespace=DEFAULT_NAMESPACE, policy=Policy.Portable) + + +pub def capture(func: (int) -> int) -> ((int) -> int): + names.append(func.__name__) + return func + + +pub def add(spec_value: Spec) -> (((int) -> int) -> ((int) -> int)): + namespaces.append(spec_value.namespace) + return capture +"#, + )?; + fs::write( + src_dir.join("helpers.incn"), + r#"from registry import add, spec + + +@add(spec(lifecycle="v1")) +pub def sample(value: int) -> int: + return value + 1 +"#, + )?; + fs::write( + tests_dir.join("test_partial_default_symbols.incn"), + r#"from helpers import sample +from registry import names, namespaces + + +def test_partial_default_symbols_in_decorator() -> None: + assert sample(1) == 2 + assert names[0] == "sample" + assert namespaces[0] == "core" +"#, + )?; + + let test_path = tests_dir.join("test_partial_default_symbols.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success(&test_output, "incan test for imported partial default symbols issue701"); + Ok(()) +} + #[test] fn test_decorator_callable_exposes_source_name_issue694() -> Result<(), Box> { let tmp = tempfile::tempdir()?; @@ -2045,6 +2117,143 @@ def test_generic_decorator_can_read_callable_name() -> None: Ok(()) } +#[test] +fn test_generic_decorator_callable_name_accepts_imported_alias_union_issue701() -> Result<(), Box> +{ + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "generic_callable_name_imported_alias_union", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("types.incn"), + r#"pub model A: + pub value: int + + +pub model B: + pub value: int + + +pub type Expr = Union[A, B] +"#, + )?; + fs::write( + src_dir.join("registry.incn"), + r#"pub static names: list[str] = [] + + +pub def capture[F](func: F) -> F: + names.append(func.__name__) + return func + + +pub def register[F]() -> ((F) -> F): + return (func) => capture[F](func) +"#, + )?; + fs::write( + src_dir.join("helpers.incn"), + r#"from registry import register +from types import Expr + + +@register[(Expr) -> Expr]() +pub def identity_expr(value: Expr) -> Expr: + return value +"#, + )?; + fs::write( + tests_dir.join("test_alias_union_callable_name.incn"), + r#"from helpers import identity_expr +from registry import names +from types import A + + +def test_alias_union_callable_name() -> None: + identity_expr(A(value=1)) + assert names[0] == "identity_expr" +"#, + )?; + + let test_path = tests_dir.join("test_alias_union_callable_name.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success( + &test_output, + "incan test for alias/union generic callable name issue701", + ); + Ok(()) +} + +#[test] +fn test_generic_callable_name_planning_ignores_unrelated_async_signatures_issue701() +-> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "generic_callable_name_with_async_noise", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + src_dir.join("registry.incn"), + r#"pub static names: list[str] = [] + + +pub def capture[F](func: F) -> F: + names.append(func.__name__) + return func + + +pub def register[F]() -> ((F) -> F): + return (func) => capture[F](func) +"#, + )?; + fs::write( + src_dir.join("helpers.incn"), + r#"from registry import register + + +@register[(int) -> int]() +pub def sample(value: int) -> int: + return value + 1 +"#, + )?; + fs::write( + src_dir.join("noise.incn"), + r#"pub async def unrelated_async(delay: float) -> None: + return + + +pub def unrelated_generic[T](value: T) -> T: + return value +"#, + )?; + fs::write( + tests_dir.join("test_scoped_callable_name_planning.incn"), + r#"from helpers import sample +from registry import names + + +def test_generic_callable_name_ignores_unrelated_signatures() -> None: + assert sample(1) == 2 + assert names[0] == "sample" +"#, + )?; + + let test_path = tests_dir.join("test_scoped_callable_name_planning.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success( + &test_output, + "incan test for scoped generic callable-name planning issue701", + ); + Ok(()) +} + #[test] fn build_frozen_uses_existing_lockfile_without_network() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 489145d02..cf27f9c4f 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -106,9 +106,9 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Cross-module codegen is more predictable**: Imported defaults qualify correctly, same-shaped union wrappers are shared, wide union narrowing lowers fully, keyword-named modules escape consistently, and public submodule reexports work under `src/` (#395, #457, #461, #458, #122, #287). - **Web registration keeps private internals private**: Private route handlers and models are retained for web registration without making them user-visible public API (#117). - **Package exports match ordinary builds**: Public aliases, public partial presets, package-boundary alias consumption, lowercase exported statics, imported static decorator strings, and keyword-named public symbols follow the same rules across build modes (#617, #631, #633, #658, #659, #698). -- **Partial presets keep their defaults in decorators**: Imported public partials now retain their projected default arguments when used inside decorator factory arguments, matching ordinary runtime calls (#698). +- **Partial presets keep their defaults in decorators**: Imported public partials now retain their projected default arguments and module-owned default symbols when used inside decorator factory arguments, matching ordinary runtime calls (#698, #701). - **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, method-call decorator factories, and reexport-only facade projections are represented in checked metadata more reliably (#636, #638, #640, #669, #694, #695). -- **Decorator helpers can inspect generic callables**: `func.__name__` works in generic `(F) -> F` decorator helpers, so registry decorators can infer the decorated helper name instead of repeating it as a string (#694). +- **Decorator helpers can inspect generic callables**: `func.__name__` works in generic `(F) -> F` decorator helpers, including imported alias and union callable signatures, so registry decorators can infer the decorated helper name instead of repeating it as a string (#694, #701). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). ### Formatter And Test Runner From 345aaab199cde51bf5404ed266216ca4443c004a Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Wed, 27 May 2026 14:53:06 +0200 Subject: [PATCH 44/58] bugfix - preserve decorated callable defaults across aliases (#703) (#704) --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/codegen.rs | 86 +++-- src/backend/ir/emit/expressions/calls.rs | 32 +- src/backend/ir/emit/expressions/methods.rs | 20 +- src/backend/ir/emit/mod.rs | 5 + src/backend/ir/emit/program.rs | 39 +- src/backend/ir/lower/decl/methods.rs | 19 +- src/backend/ir/lower/expr/calls.rs | 21 +- src/backend/ir/lower/mod.rs | 317 ++++++++++++---- src/backend/ir/mod.rs | 136 +++++++ src/backend/ir/trait_bound_inference.rs | 2 + src/frontend/typechecker/check_decl.rs | 7 +- src/frontend/typechecker/collect.rs | 9 +- src/frontend/typechecker/mod.rs | 9 +- src/frontend/typechecker/type_info.rs | 16 + tests/cli_integration.rs | 339 ++++++++++++++++++ .../docs-site/docs/release_notes/0_3.md | 3 +- 18 files changed, 893 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18474cd7e..44e46622a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc19" +version = "0.3.0-rc20" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 645780cfe..c3b452e45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc19" +version = "0.3.0-rc20" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 7fe7b7aeb..16efcea4b 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -204,10 +204,11 @@ impl<'a> IrCodegen<'a> { fn canonical_registry_for_programs<'program>( programs: impl IntoIterator, ) -> FunctionRegistry { + let programs: Vec<_> = programs.into_iter().collect(); let mut registry = FunctionRegistry::new(); - for (module_path, program) in programs { + for (module_path, program) in &programs { for (name, signature) in program.function_registry.iter() { - let mut canonical_path = module_path.to_vec(); + let mut canonical_path = (*module_path).to_vec(); canonical_path.push(name.clone()); registry.register_canonical_path( &canonical_path, @@ -216,6 +217,39 @@ impl<'a> IrCodegen<'a> { ); } } + + let mut pending_reexports = Vec::new(); + for (module_path, program) in &programs { + for reexport in &program.function_reexports { + let mut alias_path = (*module_path).to_vec(); + alias_path.push(reexport.name.clone()); + pending_reexports.push((alias_path, reexport.target_path.clone())); + } + } + while !pending_reexports.is_empty() { + let mut unresolved = Vec::new(); + let mut made_progress = false; + for (alias_path, target_path) in pending_reexports { + if registry.get_canonical_path(&alias_path).is_some() { + made_progress = true; + continue; + } + if let Some(signature) = registry.get_canonical_path(&target_path).cloned() { + registry.register_canonical_path( + &alias_path, + signature.params.clone(), + signature.return_type.clone(), + ); + made_progress = true; + } else { + unresolved.push((alias_path, target_path)); + } + } + if !made_progress { + break; + } + pending_reexports = unresolved; + } registry } @@ -573,34 +607,42 @@ impl<'a> IrCodegen<'a> { callable_name_used_signature_keys_for_emit.extend(callable_name_use_facts.function_arg_signature_keys); } - let mut canonical_registry = FunctionRegistry::new(); let mut dependency_ir_programs = Vec::new(); for (dep_name, dep_ast, dep_path_segments) in &self.dependency_modules { - // For dependencies, use best-effort lowering without type info to - // preserve prior behavior and avoid redundant typechecking. - let mut dep_lowering = AstLowering::new(); + let dep_type_info = { + use crate::frontend::typechecker::TypeChecker; + let mut tc = TypeChecker::new(); + self.configure_typechecker(&mut tc); + match tc.check_with_imports_allow_private(dep_ast, &deps) { + Ok(()) => tc.type_info().clone(), + Err(errs) => return Err(GenerationError::TypeCheck(errs)), + } + }; + let mut dep_lowering = AstLowering::new_with_type_info(dep_type_info); dep_lowering.set_current_source_module_name( - dep_ast - .source_path - .as_deref() - .and_then(crate::frontend::module::logical_module_name_from_source_path), + dep_path_segments + .clone() + .map(|segments| segments.join(".")) + .or_else(|| { + dep_ast + .source_path + .as_deref() + .and_then(crate::frontend::module::logical_module_name_from_source_path) + }), ); + dep_lowering.seed_dependency_trait_decls(&self.dependency_modules); dep_lowering.seed_struct_field_aliases(global_aliases.clone()); let dep_ir = dep_lowering.lower_program(dep_ast)?; let module_path = dep_path_segments .clone() .unwrap_or_else(|| vec![(*dep_name).to_string()]); - for (name, signature) in dep_ir.function_registry.iter() { - let mut canonical_path = module_path.clone(); - canonical_path.push(name.clone()); - canonical_registry.register_canonical_path( - &canonical_path, - signature.params.clone(), - signature.return_type.clone(), - ); - } - dependency_ir_programs.push(dep_ir); + dependency_ir_programs.push((module_path, dep_ir)); } + let canonical_registry = Self::canonical_registry_for_programs( + dependency_ir_programs + .iter() + .map(|(module_path, dep_ir)| (module_path.as_slice(), dep_ir)), + ); // Emit IR to Rust code let use_emit_service = env::var("INCAN_EMIT_SERVICE").ok().as_deref() == Some("1"); @@ -625,7 +667,7 @@ impl<'a> IrCodegen<'a> { inner.set_callable_name_resolutions(callable_name_resolutions_for_emit); inner.set_callable_name_used_signature_keys(callable_name_used_signature_keys_for_emit); inner.set_callable_name_local_registry(ir_program.function_registry.clone()); - for dep_ir in &dependency_ir_programs { + for (_, dep_ir) in &dependency_ir_programs { inner.seed_dependency_nominal_metadata_from_program(dep_ir); } Ok(svc.emit_program(&ir_program)?) @@ -648,7 +690,7 @@ impl<'a> IrCodegen<'a> { emitter.set_callable_name_resolutions(callable_name_resolutions_for_emit); emitter.set_callable_name_used_signature_keys(callable_name_used_signature_keys_for_emit); emitter.set_callable_name_local_registry(ir_program.function_registry.clone()); - for dep_ir in &dependency_ir_programs { + for (_, dep_ir) in &dependency_ir_programs { emitter.seed_dependency_nominal_metadata_from_program(dep_ir); } Ok(emitter.emit_program(&ir_program)?) diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index 827266346..e499cc056 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -7,12 +7,12 @@ mod testing_asserts; use proc_macro2::TokenStream; use quote::quote; -use super::super::super::FunctionSignature; use super::super::super::conversions::{BinOpEmitKind, determine_binop_plan}; use super::super::super::decl::FunctionParam; use super::super::super::expr::{BinOp, IrCallArg, IrCallArgKind, IrExprKind, TypedExpr, VarRefKind}; use super::super::super::ownership::{ArgumentPassingPlan, ValueUseSite}; use super::super::super::types::IrType; +use super::super::super::{FunctionRegistry, FunctionSignature}; use super::super::{EmitError, IrEmitter}; use crate::frontend::ast::ParamKind; use incan_core::lang::stdlib; @@ -499,25 +499,21 @@ impl<'a> IrEmitter<'a> { _ => None, }; let callee_name = local_name.or(canonical_name); - let registry_signature = if let Some(path) = canonical_path { - self.canonical_function_registry().get_canonical_path(path) - } else { - local_name.and_then(|name| self.function_registry.get(name)) - }; - let result_specialized_signature = callable_signature.or(registry_signature).and_then(|signature| { + let merged_signature = FunctionRegistry::effective_call_signature_by( + self.function_registry, + self.canonical_function_registry(), + local_name, + canonical_path, + callable_signature, + Some(&func.ty), + |left, right| self.call_signature_type_matches(left, right), + ); + let result_specialized_signature = merged_signature.as_ref().and_then(|signature| { result_target_ty.and_then(|target_ty| Self::specialize_signature_by_result_target(signature, target_ty)) }); - let function_sig = associated_signature.as_ref().or_else(|| { - if canonical_path.is_some() { - result_specialized_signature - .as_ref() - .or(callable_signature.or(registry_signature)) - } else { - result_specialized_signature - .as_ref() - .or(registry_signature.or(callable_signature)) - } - }); + let function_sig = associated_signature + .as_ref() + .or_else(|| result_specialized_signature.as_ref().or(merged_signature.as_ref())); // The checked-newtype lowering path emits a compiler-internal panic marker call. This remains the narrow, // explicitly-tracked generated `panic!` exemption that issue #351 left to a separate follow-up. Render it as // the Rust `panic!` macro so generated code stays valid without colliding with user-defined functions that may diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index e1c5093f0..c43d7cec1 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -272,17 +272,11 @@ impl<'a> IrEmitter<'a> { .method_signature_for_receiver(&receiver.ty, method) .or(specialized_signature.as_ref()); let has_incan_receiver_signature = receiver_signature.is_some(); - let callable_signature = match (callable_signature, receiver_signature) { - (Some(call_sig), Some(method_sig)) - if call_sig.params.iter().all(|param| param.default.is_none()) - && method_sig.params.iter().any(|param| param.default.is_some()) => - { - Some(method_sig) - } - (Some(call_sig), _) => Some(call_sig), - (None, method_sig) => method_sig, - }; - if let Some(sig) = callable_signature + let callable_signature = + FunctionSignature::merge_default_source_by(callable_signature, receiver_signature, |left, right| { + self.call_signature_type_matches(left, right) + }); + if let Some(sig) = callable_signature.as_ref() && sig .params .iter() @@ -291,7 +285,7 @@ impl<'a> IrEmitter<'a> { return self.emit_rest_aware_call_args(receiver, args, sig); } - let ordered_args: Vec<(TypedExpr, bool)> = if let Some(sig) = callable_signature { + let ordered_args: Vec<(TypedExpr, bool)> = if let Some(sig) = callable_signature.as_ref() { if args.iter().any(|arg| arg.name.is_some()) { let mut positional: Vec = Vec::new(); let mut named: std::collections::HashMap<&str, TypedExpr> = std::collections::HashMap::new(); @@ -335,7 +329,7 @@ impl<'a> IrEmitter<'a> { .iter() .enumerate() .map(|(idx, (arg, from_default))| { - let param = callable_signature.and_then(|sig| sig.params.get(idx)); + let param = callable_signature.as_ref().and_then(|sig| sig.params.get(idx)); let external_method_shape = matches!( base_use_site, ValueUseSite::ExternalCallArg { .. } | ValueUseSite::MethodArg diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index a677203d7..9d47cde1b 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -567,6 +567,11 @@ impl<'a> IrEmitter<'a> { .unwrap_or(self.function_registry) } + /// Return whether two call-signature types describe the same emitted surface after transparent aliases expand. + pub(in crate::backend::ir::emit) fn call_signature_type_matches(&self, left: &IrType, right: &IrType) -> bool { + left == right || self.resolve_type_aliases_for_emit(left) == self.resolve_type_aliases_for_emit(right) + } + /// Resolve transparent type aliases before emission decisions that need structural type information. pub(in crate::backend::ir::emit) fn resolve_type_aliases_for_emit(&self, ty: &IrType) -> IrType { let mut visiting = HashSet::new(); diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index 9947d01fb..9ba889be0 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -887,37 +887,14 @@ impl<'program> GeneratedUseAnalyzer<'program> { IrExprKind::Var { name, .. } => Some(name.as_str()), _ => None, }; - let canonical_name = canonical_path.as_ref().and_then(|path| path.last()).map(String::as_str); - let registered_signature = if canonical_path.is_some() { - callable_signature.cloned().or_else(|| { - canonical_path - .as_ref() - .and_then(|path| self.function_registry.get_canonical_path(path).cloned()) - }) - } else { - local_name - .and_then(|name| self.function_registry.get(name).cloned()) - .or_else(|| canonical_name.and_then(|name| self.function_registry.get(name).cloned())) - .or_else(|| callable_signature.cloned()) - }; - registered_signature.or_else(|| match &func.ty { - IrType::Function { params, ret } => Some(FunctionSignature { - params: params - .iter() - .enumerate() - .map(|(idx, ty)| super::super::decl::FunctionParam { - name: format!("__incan_arg_{idx}"), - ty: ty.clone(), - mutability: super::super::types::Mutability::Immutable, - is_self: false, - kind: crate::frontend::ast::ParamKind::Normal, - default: None, - }) - .collect(), - return_type: ret.as_ref().clone(), - }), - _ => None, - }) + FunctionRegistry::effective_call_signature( + self.function_registry, + self.function_registry, + local_name, + canonical_path.as_deref(), + callable_signature, + Some(&func.ty), + ) } /// Record named function arguments that need private adapters for borrowed function-pointer parameters. diff --git a/src/backend/ir/lower/decl/methods.rs b/src/backend/ir/lower/decl/methods.rs index b40224e01..f65960751 100644 --- a/src/backend/ir/lower/decl/methods.rs +++ b/src/backend/ir/lower/decl/methods.rs @@ -354,7 +354,7 @@ impl AstLowering { type_param_names, )?; let adapter = self.decorated_method_original_adapter(owner, method)?; - let wrapper = self.lower_decorated_method_wrapper(owner, method)?; + let wrapper = self.lower_decorated_method_wrapper(owner, method, type_param_names)?; Ok(vec![original, adapter, wrapper]) } else { Ok(vec![self.lower_method_with_type_params(method, type_param_names)?]) @@ -366,6 +366,7 @@ impl AstLowering { &mut self, owner: &str, method: &ast::MethodDecl, + owner_type_param_names: Option<&HashSet<&str>>, ) -> Result { let Some(binding) = self.type_info.as_ref().and_then(|info| { info.declarations @@ -373,15 +374,23 @@ impl AstLowering { .get(&(owner.to_string(), method.name.clone())) .cloned() }) else { - return self.lower_method_with_type_params(method, None); + return self.lower_method_with_type_params(method, owner_type_param_names); }; let crate::frontend::symbols::ResolvedType::Function(params, ret) = binding.unbound_ty else { - return self.lower_method_with_type_params(method, None); + return self.lower_method_with_type_params(method, owner_type_param_names); }; let Some((receiver_param, surface_params)) = params.split_first() else { - return self.lower_method_with_type_params(method, None); + return self.lower_method_with_type_params(method, owner_type_param_names); }; let receiver_ty = self.lower_resolved_type(&receiver_param.ty); + let original_surface_params = match binding.original_unbound_ty { + crate::frontend::symbols::ResolvedType::Function(original_params, _) => { + original_params.into_iter().skip(1).collect::>() + } + _ => Vec::new(), + }; + let defaults = + self.decorated_param_defaults_for_surface(surface_params, &original_surface_params, &method.params); let mut wrapper_params = Vec::with_capacity(surface_params.len() + 1); let receiver = method.receiver.unwrap_or(ast::Receiver::Immutable); wrapper_params.push(FunctionParam { @@ -404,7 +413,7 @@ impl AstLowering { mutability: Mutability::Immutable, is_self: false, kind: param.kind, - default: None, + default: defaults.get(idx).cloned().flatten(), } })); let return_type = self.lower_resolved_type(&ret); diff --git a/src/backend/ir/lower/expr/calls.rs b/src/backend/ir/lower/expr/calls.rs index d5bbea002..32d102605 100644 --- a/src/backend/ir/lower/expr/calls.rs +++ b/src/backend/ir/lower/expr/calls.rs @@ -1206,25 +1206,6 @@ impl AstLowering { } } - /// Build a synthetic callable signature from an already-lowered function type. - fn function_signature_from_ir_type(params: &[IrType], ret: &IrType) -> FunctionSignature { - FunctionSignature { - params: params - .iter() - .enumerate() - .map(|(idx, ty)| FunctionParam { - name: format!("__incan_arg_{idx}"), - ty: ty.clone(), - mutability: super::super::super::types::Mutability::Immutable, - is_self: false, - kind: ast::ParamKind::Normal, - default: None, - }) - .collect(), - return_type: ret.clone(), - } - } - /// Return whether passing `arg` to a callable parameter should refine that parameter to a shared borrow. fn callable_arg_needs_implicit_borrow(arg: &TypedExpr, target_ty: &IrType) -> bool { if arg.ty.is_copy() || matches!(target_ty, IrType::Ref(_) | IrType::RefMut(_)) { @@ -1263,7 +1244,7 @@ impl AstLowering { return callable_signature; }; let mut signature = - callable_signature.unwrap_or_else(|| Self::function_signature_from_ir_type(params, ret.as_ref())); + callable_signature.unwrap_or_else(|| FunctionSignature::from_function_type(params, ret.as_ref())); let mut changed = false; for (idx, arg) in args.iter().enumerate() { diff --git a/src/backend/ir/lower/mod.rs b/src/backend/ir/lower/mod.rs index 53a1ef6fe..698f67a4b 100644 --- a/src/backend/ir/lower/mod.rs +++ b/src/backend/ir/lower/mod.rs @@ -39,10 +39,10 @@ use super::decl::{FunctionParam, IrDecl, IrDeclKind, IrImportOrigin, IrImportQua use super::expr::{IrCallArg, IrCallArgKind, IrExprKind, VarAccess, VarRefKind}; use super::stmt::{IrStmt, IrStmtKind}; use super::types::IrType; -use super::{FunctionSignature, IrProgram, Mutability}; +use super::{FunctionReexport, FunctionSignature, IrProgram, Mutability}; use crate::frontend::ast; use crate::frontend::decorator_resolution; -use crate::frontend::symbols::NewtypePrimitiveConstraint; +use crate::frontend::symbols::{CallableParam, NewtypePrimitiveConstraint}; use crate::frontend::typechecker::TypeCheckInfo; use crate::frontend::typechecker::stdlib_loader::StdlibAstCache; use incan_core::lang::conventions; @@ -258,6 +258,68 @@ impl AstLowering { self.current_source_module_name = name; } + /// Lower one typechecker-resolved callable surface into IR parameters, attaching an already-planned default + /// expression for each parameter when present. + fn function_params_from_callable_surface( + &mut self, + callable_params: &[CallableParam], + defaults: &[Option], + ) -> Vec { + callable_params + .iter() + .enumerate() + .map(|(idx, param)| { + let base_ty = self.lower_resolved_type(¶m.ty); + FunctionParam { + name: param.name.clone().unwrap_or_else(|| format!("__incan_arg_{idx}")), + ty: Self::lower_param_container_type(param.kind, base_ty), + mutability: Mutability::Immutable, + is_self: false, + kind: param.kind, + default: defaults.get(idx).cloned().flatten(), + } + }) + .collect() + } + + fn function_params_from_source_callable_surface( + &mut self, + callable_params: &[CallableParam], + source_params: &[ast::Spanned], + ) -> Vec { + callable_params + .iter() + .enumerate() + .map(|(idx, param)| { + let source_idx = param + .name + .as_deref() + .and_then(|name| source_params.iter().position(|source| source.node.name == name)) + .unwrap_or(idx); + let source_param = source_params.get(source_idx); + let default = if param.has_default { + source_param + .and_then(|source| source.node.default.as_ref()) + .and_then(|default_expr| self.lower_expr_spanned(default_expr).ok()) + } else { + None + }; + FunctionParam { + name: param.name.clone().unwrap_or_else(|| format!("__incan_arg_{idx}")), + ty: Self::lower_param_container_type(param.kind, self.lower_resolved_type(¶m.ty)), + mutability: if source_param.is_some_and(|source| source.node.is_mut) { + Mutability::Mutable + } else { + Mutability::Immutable + }, + is_self: false, + kind: param.kind, + default, + } + }) + .collect() + } + /// Return the logger name supplied to default `std.logging.get_logger()` calls. pub(super) fn current_default_logger_name(&self) -> String { self.current_source_module_name @@ -895,6 +957,50 @@ impl AstLowering { } } + fn collect_function_reexports(&self, program: &ast::Program) -> Vec { + let mut reexports = Vec::new(); + for decl in &program.declarations { + let ast::Declaration::Import(import) = &decl.node else { + continue; + }; + if !matches!(import.visibility, ast::Visibility::Public) { + continue; + } + let ast::ImportKind::From { module, items } = &import.kind else { + continue; + }; + + let module_path = self.canonical_source_import_module_segments(module); + for item in items { + let mut target_path = module_path.clone(); + target_path.push(item.name.clone()); + reexports.push(FunctionReexport { + name: item.alias.as_ref().unwrap_or(&item.name).clone(), + target_path, + }); + } + } + reexports + } + + fn canonical_source_import_module_segments(&self, module: &ast::ImportPath) -> Vec { + let segments = if module.parent_levels > 0 && !module.is_absolute { + let mut base = self + .current_source_module_name + .as_deref() + .map(|module_name| module_name.split('.').map(str::to_string).collect::>()) + .unwrap_or_default(); + for _ in 0..module.parent_levels { + base.pop(); + } + base.extend(module.segments.iter().cloned()); + base + } else { + module.segments.clone() + }; + crate::frontend::module::canonicalize_source_module_segments(&segments) + } + /// Lower a complete AST program to IR. /// /// This is the main entry point for the lowering pass. It performs: @@ -922,6 +1028,7 @@ impl AstLowering { let mut errors: Vec = Vec::new(); self.import_aliases = decorator_resolution::collect_import_aliases(program); self.rust_import_aliases = decorator_resolution::collect_rust_import_aliases(program); + ir_program.function_reexports = self.collect_function_reexports(program); self.imported_alias_targets = self.collect_imported_alias_targets(program); self.seed_imported_stdlib_trait_decls(program); self.alias_imported_dependency_trait_decls(); @@ -1061,55 +1168,78 @@ impl AstLowering { if let ast::Declaration::Function(ref f) = decl.node { let type_param_names: std::collections::HashSet<&str> = f.type_params.iter().map(|tp| tp.name.as_str()).collect(); - let params: Vec = f - .params - .iter() - .map(|p| { - let base_ty = self.lower_type_with_type_params(&p.node.ty.node, Some(&type_param_names)); - let param_ty = Self::lower_param_container_type(p.node.kind, base_ty); - FunctionParam { - name: p.node.name.clone(), - ty: param_ty, - mutability: if p.node.is_mut { - Mutability::Mutable - } else { - Mutability::Immutable - }, - is_self: false, - kind: p.node.kind, - default: match &p.node.default { - Some(default_expr) => self.lower_expr_spanned(default_expr).ok(), - None => None, - }, - } - }) - .collect(); - let return_type = self + let function_binding = self .type_info .as_ref() - .and_then(|info| info.declarations.decorated_function_bindings.get(&f.name)) - .and_then(|binding| match &binding.ty { - crate::frontend::symbols::ResolvedType::Function(_, ret) => Some(self.lower_resolved_type(ret)), - _ => None, - }) - .unwrap_or_else(|| self.lower_type_with_type_params(&f.return_type.node, Some(&type_param_names))); - ir_program - .function_registry - .register(f.name.clone(), params.clone(), return_type.clone()); - if let Some(signature) = ir_program.function_registry.get(&f.name).cloned() { - self.update_root_function_binding(&f.name, &signature.params, &signature.return_type); - } - if self + .and_then(|info| info.declarations.function_bindings.get(&f.name).cloned()); + let source_params: Vec = function_binding + .as_ref() + .map(|binding| self.function_params_from_source_callable_surface(&binding.params, &f.params)) + .unwrap_or_else(|| { + f.params + .iter() + .map(|p| { + let base_ty = + self.lower_type_with_type_params(&p.node.ty.node, Some(&type_param_names)); + let param_ty = Self::lower_param_container_type(p.node.kind, base_ty); + FunctionParam { + name: p.node.name.clone(), + ty: param_ty, + mutability: if p.node.is_mut { + Mutability::Mutable + } else { + Mutability::Immutable + }, + is_self: false, + kind: p.node.kind, + default: match &p.node.default { + Some(default_expr) => self.lower_expr_spanned(default_expr).ok(), + None => None, + }, + } + }) + .collect() + }); + if let Some(binding) = self .type_info .as_ref() - .is_some_and(|info| info.declarations.decorated_function_bindings.contains_key(&f.name)) + .and_then(|info| info.declarations.decorated_function_bindings.get(&f.name).cloned()) + && let crate::frontend::symbols::ResolvedType::Function(callable_params, callable_ret) = binding.ty { + let original_params = match &binding.original_ty { + crate::frontend::symbols::ResolvedType::Function(params, _) => params.as_slice(), + _ => &[], + }; + let defaults = + self.decorated_param_defaults_for_surface(&callable_params, original_params, &f.params); + let params = self.function_params_from_callable_surface(&callable_params, &defaults); + let return_type = self.lower_resolved_type(&callable_ret); + ir_program + .function_registry + .register(f.name.clone(), params.clone(), return_type.clone()); + self.update_root_function_binding(&f.name, ¶ms, &return_type); + let original_name = Self::decorator_original_function_name(&f.name); - let original_return_type = - self.lower_type_with_type_params(&f.return_type.node, Some(&type_param_names)); + let original_return_type = function_binding + .as_ref() + .map(|binding| self.lower_resolved_type(&binding.return_type)) + .unwrap_or_else(|| { + self.lower_type_with_type_params(&f.return_type.node, Some(&type_param_names)) + }); ir_program .function_registry - .register(original_name, params, original_return_type); + .register(original_name, source_params, original_return_type); + continue; + } + let return_type = function_binding + .as_ref() + .map(|binding| self.lower_resolved_type(&binding.return_type)) + .unwrap_or_else(|| self.lower_type_with_type_params(&f.return_type.node, Some(&type_param_names))); + ir_program + .function_registry + .register(f.name.clone(), source_params.clone(), return_type.clone()); + if let Some(signature) = ir_program.function_registry.get(&f.name).cloned() { + self.update_root_function_binding(&f.name, &signature.params, &signature.return_type); } } else if let ast::Declaration::Alias(ref alias) = decl.node && let [target] = alias.target.segments.as_slice() @@ -1599,6 +1729,10 @@ impl AstLowering { span: ast::Span::default().into(), }); }; + let original_params = match binding.original_ty { + crate::frontend::symbols::ResolvedType::Function(params, _) => params, + _ => Vec::new(), + }; let original_name = Self::decorator_original_function_name(&f.name); let original = self.lower_function_named(f, original_name.clone(), super::decl::Visibility::Private)?; @@ -1614,7 +1748,13 @@ impl AstLowering { let mut value = self.lower_expr_spanned(&decorator_expr)?; value.ty = decorated_ty.clone(); let static_name = Self::decorator_static_binding_name(&f.name); - let wrapper = self.decorated_function_wrapper(f, &static_name, &callable_params, callable_ret.as_ref()); + let wrapper = self.decorated_function_wrapper( + f, + &static_name, + &callable_params, + &original_params, + callable_ret.as_ref(), + ); Ok(vec![ IrDecl::new(IrDeclKind::Function(original)), @@ -1633,24 +1773,12 @@ impl AstLowering { &mut self, f: &ast::FunctionDecl, static_name: &str, - callable_params: &[crate::frontend::symbols::CallableParam], + callable_params: &[CallableParam], + original_params: &[CallableParam], callable_ret: &crate::frontend::symbols::ResolvedType, ) -> super::decl::IrFunction { - let params: Vec = callable_params - .iter() - .enumerate() - .map(|(idx, param)| { - let base_ty = self.lower_resolved_type(¶m.ty); - FunctionParam { - name: param.name.clone().unwrap_or_else(|| format!("__incan_arg_{idx}")), - ty: Self::lower_param_container_type(param.kind, base_ty), - mutability: Mutability::Immutable, - is_self: false, - kind: param.kind, - default: None, - } - }) - .collect(); + let defaults = self.decorated_param_defaults_for_surface(callable_params, original_params, &f.params); + let params = self.function_params_from_callable_surface(callable_params, &defaults); let return_type = self.lower_resolved_type(callable_ret); let static_func = TypedExpr::new( IrExprKind::StaticRead { @@ -1702,6 +1830,75 @@ impl AstLowering { } } + /// Lower source defaults for a decorated callable wrapper when the final callable surface still maps to the + /// original typechecker-resolved parameters. + /// + /// Function types can describe parameter types but not default expressions. User-defined decorators often return an + /// explicit function type such as `(int) -> int`, which erases the declaration's richer call-site defaults even + /// when the decorator keeps the same callable surface. This helper rebuilds one default plan from source parameter + /// metadata only after the final decorator surface still matches the original callable shape. The comparison uses + /// typechecker-resolved parameter types so transparent aliases like `type Expr = Union[...]` do not split lowering + /// behavior across import or alias boundaries. + pub(super) fn decorated_param_defaults_for_surface( + &mut self, + surface_params: &[CallableParam], + original_params: &[CallableParam], + source_params: &[ast::Spanned], + ) -> Vec> { + let positional_shapes_match = Self::decorated_positional_param_shapes_match(surface_params, original_params); + + surface_params + .iter() + .enumerate() + .map(|(idx, surface_param)| { + let default_expr = if let Some(name) = surface_param.name.as_deref() { + original_params + .iter() + .position(|original_param| { + original_param.name.as_deref() == Some(name) + && Self::decorated_param_shape_matches(surface_param, original_param) + }) + .and_then(|source_idx| { + original_params + .get(source_idx) + .is_some_and(|original_param| original_param.has_default) + .then(|| source_params.get(source_idx)) + .flatten() + }) + .and_then(|source_param| source_param.node.default.clone()) + } else if positional_shapes_match { + original_params + .get(idx) + .is_some_and(|original_param| original_param.has_default) + .then(|| source_params.get(idx)) + .flatten() + .and_then(|source_param| source_param.node.default.clone()) + } else { + None + }; + + default_expr.and_then(|expr| self.lower_expr_spanned(&expr).ok()) + }) + .collect() + } + + fn decorated_positional_param_shapes_match( + surface_params: &[CallableParam], + original_params: &[CallableParam], + ) -> bool { + surface_params.len() == original_params.len() + && surface_params + .iter() + .zip(original_params) + .all(|(surface_param, original_param)| { + Self::decorated_param_shape_matches(surface_param, original_param) + }) + } + + fn decorated_param_shape_matches(surface_param: &CallableParam, original_param: &CallableParam) -> bool { + surface_param.kind == original_param.kind && surface_param.ty == original_param.ty + } + /// Add alias-qualified dependency trait declarations so default methods can expand for imported derive aliases. fn alias_imported_dependency_trait_decls(&mut self) { let existing = self.trait_decls.clone(); diff --git a/src/backend/ir/mod.rs b/src/backend/ir/mod.rs index fe5095110..fb5ce1131 100644 --- a/src/backend/ir/mod.rs +++ b/src/backend/ir/mod.rs @@ -59,6 +59,84 @@ pub struct FunctionSignature { pub return_type: IrType, } +impl FunctionSignature { + /// Build a positional callable signature from a lowered function type. + pub fn from_function_type(params: &[IrType], ret: &IrType) -> Self { + Self { + params: params + .iter() + .enumerate() + .map(|(idx, ty)| FunctionParam { + name: format!("__incan_arg_{idx}"), + ty: ty.clone(), + mutability: Mutability::Immutable, + is_self: false, + kind: crate::frontend::ast::ParamKind::Normal, + default: None, + }) + .collect(), + return_type: ret.clone(), + } + } + + /// Return the effective call signature when one source carries precise callable type metadata and another carries + /// source defaults for the same callable surface. + pub fn merge_default_source( + primary: Option<&FunctionSignature>, + default_source: Option<&FunctionSignature>, + ) -> Option { + Self::merge_default_source_by(primary, default_source, |left, right| left == right) + } + + /// Return the effective call signature using a caller-supplied type equivalence rule for default inheritance. + pub fn merge_default_source_by( + primary: Option<&FunctionSignature>, + default_source: Option<&FunctionSignature>, + types_match: impl Fn(&IrType, &IrType) -> bool, + ) -> Option { + let Some(primary) = primary else { + return default_source.cloned(); + }; + let Some(default_source) = default_source else { + return Some(primary.clone()); + }; + let mut merged = primary.clone(); + if Self::params_match_for_default_inheritance(primary, default_source, &types_match) { + for (param, default_param) in merged.params.iter_mut().zip(&default_source.params) { + if param.default.is_none() { + param.default = default_param.default.clone(); + } + } + } + Some(merged) + } + + fn params_match_for_default_inheritance( + left: &FunctionSignature, + right: &FunctionSignature, + types_match: &impl Fn(&IrType, &IrType) -> bool, + ) -> bool { + left.params.len() == right.params.len() + && left + .params + .iter() + .zip(&right.params) + .all(|(left, right)| Self::param_matches_for_default_inheritance(left, right, types_match)) + } + + fn param_matches_for_default_inheritance( + left: &FunctionParam, + right: &FunctionParam, + types_match: &impl Fn(&IrType, &IrType) -> bool, + ) -> bool { + left.kind == right.kind + && types_match(&left.ty, &right.ty) + && (left.name == right.name + || left.name.starts_with("__incan_arg_") + || right.name.starts_with("__incan_arg_")) + } +} + /// Registry of all function signatures in the program #[derive(Debug, Clone, Default)] pub struct FunctionRegistry { @@ -113,6 +191,61 @@ impl FunctionRegistry { self.signatures.insert(name.clone(), sig.clone()); } } + + /// Resolve the effective function-call signature for one IR call site. + /// + /// This is the single merge point for callable metadata during emission. Typechecker/lowering metadata can carry a + /// precise callable surface, while the source registry can carry default expressions. Canonical paths resolve + /// through the cross-module registry, local names resolve through the module registry, and lowered function types + /// are only a final fallback. + pub fn effective_call_signature( + local_registry: &FunctionRegistry, + canonical_registry: &FunctionRegistry, + local_name: Option<&str>, + canonical_path: Option<&[String]>, + callable_signature: Option<&FunctionSignature>, + callee_ty: Option<&IrType>, + ) -> Option { + Self::effective_call_signature_by( + local_registry, + canonical_registry, + local_name, + canonical_path, + callable_signature, + callee_ty, + |left, right| left == right, + ) + } + + /// Resolve the effective function-call signature using a caller-supplied type equivalence rule. + pub fn effective_call_signature_by( + local_registry: &FunctionRegistry, + canonical_registry: &FunctionRegistry, + local_name: Option<&str>, + canonical_path: Option<&[String]>, + callable_signature: Option<&FunctionSignature>, + callee_ty: Option<&IrType>, + types_match: impl Fn(&IrType, &IrType) -> bool, + ) -> Option { + let registry_signature = if let Some(path) = canonical_path { + canonical_registry.get_canonical_path(path) + } else { + local_name.and_then(|name| local_registry.get(name)) + }; + FunctionSignature::merge_default_source_by(callable_signature, registry_signature, types_match).or_else(|| { + match callee_ty { + Some(IrType::Function { params, ret }) => Some(FunctionSignature::from_function_type(params, ret)), + _ => None, + } + }) + } +} + +/// Public source import re-export that should behave like the imported callable for metadata lookups. +#[derive(Debug, Clone)] +pub struct FunctionReexport { + pub name: String, + pub target_path: Vec, } /// A complete IR program @@ -126,6 +259,8 @@ pub struct IrProgram { pub entry_point: Option, /// Function signature registry for call-site type checking pub function_registry: FunctionRegistry, + /// Public source-function re-exports keyed by local exported name and canonical target path. + pub function_reexports: Vec, /// RFC 023: The `rust.module("path::to::module")` Rust backing path, if declared. /// /// When present, `@rust.extern` functions in this program emit delegation calls to this Rust module path instead @@ -146,6 +281,7 @@ impl IrProgram { source_module_name: None, entry_point: None, function_registry: FunctionRegistry::new(), + function_reexports: Vec::new(), rust_module_path: None, newtype_checked_ctor: std::collections::HashMap::new(), } diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index 0ac9dd38d..a9238ef80 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -2782,6 +2782,7 @@ mod tests { source_module_name: None, entry_point: None, function_registry: FunctionRegistry::new(), + function_reexports: Vec::new(), rust_module_path: None, newtype_checked_ctor: Default::default(), } @@ -2829,6 +2830,7 @@ mod tests { source_module_name: None, entry_point: None, function_registry: FunctionRegistry::new(), + function_reexports: Vec::new(), rust_module_path: None, newtype_checked_ctor: Default::default(), }; diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index cede65bd0..cfb384965 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -3778,7 +3778,7 @@ impl TypeChecker { return; }; - let mut binding_ty = original_ty; + let mut binding_ty = original_ty.clone(); for decorator in func.decorators.iter().rev() { if self.is_user_defined_decorator_candidate(&decorator.node) { binding_ty = self.apply_user_defined_decorator(decorator, binding_ty, &func.name); @@ -3790,7 +3790,10 @@ impl TypeChecker { { self.type_info.declarations.decorated_function_bindings.insert( func.name.clone(), - DecoratedFunctionBindingInfo { ty: binding_ty.clone() }, + DecoratedFunctionBindingInfo { + ty: binding_ty.clone(), + original_ty, + }, ); symbol.kind = SymbolKind::Variable(VariableInfo { ty: binding_ty, diff --git a/src/frontend/typechecker/collect.rs b/src/frontend/typechecker/collect.rs index ac71acdfe..d2f3eb541 100644 --- a/src/frontend/typechecker/collect.rs +++ b/src/frontend/typechecker/collect.rs @@ -10,7 +10,7 @@ use crate::frontend::symbols::*; use crate::frontend::typechecker::helpers::freeze_const_type; use incan_core::lang::decorators::{self as core_decorators, DecoratorId}; -use super::TypeChecker; +use super::{FunctionBindingInfo, TypeChecker}; mod decl_helpers; pub(super) mod decorators; @@ -1255,6 +1255,13 @@ impl TypeChecker { }) .collect(); let return_type = self.resolve_type_checked(&func.return_type); + self.type_info.declarations.function_bindings.insert( + func.name.clone(), + FunctionBindingInfo { + params: params.clone(), + return_type: return_type.clone(), + }, + ); self.symbols.define(Symbol { name: func.name.clone(), diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 13db28081..c0b1ab53a 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -55,10 +55,11 @@ mod validate_rust_module; pub use const_eval::ConstValue; pub use type_info::{ - ComputedPropertyAccessInfo, DecoratedFunctionBindingInfo, DecoratedMethodBindingInfo, FixedUnpackPlan, IdentKind, - ProtocolIterationInfo, ResolvedMethodCall, ResolvedMethodDispatch, ResolvedOperatorCall, ResolvedOperatorKind, - RustArgCoercionInfo, RustArgCoercionKind, StaticBindingInfo, TestingFixtureInfo, TypeCheckInfo, - ValidatedNewtypeCoercionInfo, ValidatedNewtypeCoercionMode, ValidatedNewtypeCoercionStep, + ComputedPropertyAccessInfo, DecoratedFunctionBindingInfo, DecoratedMethodBindingInfo, FixedUnpackPlan, + FunctionBindingInfo, IdentKind, ProtocolIterationInfo, ResolvedMethodCall, ResolvedMethodDispatch, + ResolvedOperatorCall, ResolvedOperatorKind, RustArgCoercionInfo, RustArgCoercionKind, StaticBindingInfo, + TestingFixtureInfo, TypeCheckInfo, ValidatedNewtypeCoercionInfo, ValidatedNewtypeCoercionMode, + ValidatedNewtypeCoercionStep, }; #[cfg(test)] mod tests; diff --git a/src/frontend/typechecker/type_info.rs b/src/frontend/typechecker/type_info.rs index cfc480998..7dd05d86f 100644 --- a/src/frontend/typechecker/type_info.rs +++ b/src/frontend/typechecker/type_info.rs @@ -177,6 +177,11 @@ pub struct RustInteropArtifacts { /// Declaration-level binding rewrites and visibility facts consumed by lowering. #[derive(Debug, Default, Clone)] pub struct DeclarationArtifacts { + /// Module-local function declarations keyed by source name after annotation resolution. + /// + /// Lowering consumes this instead of re-lowering raw AST annotations so aliases such as + /// `type Expr = Union[...]` do not produce a different callable surface from typechecked call sites. + pub function_bindings: HashMap, /// Module-visible static bindings keyed by local name for lowering/runtime emission. pub static_bindings: HashMap, /// Same-type method aliases keyed by nominal type name (`alias -> target_method`). @@ -427,11 +432,22 @@ pub struct StaticBindingInfo { pub is_imported: bool, } +/// Lowering metadata for one source function declaration. +#[derive(Debug, Clone, PartialEq)] +pub struct FunctionBindingInfo { + /// Typechecker-resolved source parameters, including default-presence markers. + pub params: Vec, + /// Typechecker-resolved source return type. + pub return_type: ResolvedType, +} + /// Lowering metadata for one RFC 036 decorated function binding. #[derive(Debug, Clone, PartialEq)] pub struct DecoratedFunctionBindingInfo { /// Final type of the module-visible binding after applying all user-defined decorators. pub ty: ResolvedType, + /// Original callable type before decorators are applied. + pub original_ty: ResolvedType, } /// Lowering metadata for one RFC 036 decorated method binding. diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index a8f51397f..271743cd0 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1914,18 +1914,40 @@ pub deterministic_spec = partial FunctionSpec(namespace="core", deterministic=tr @add(deterministic_spec(lifecycle="stable")) pub def normalize(value: int) -> int: return value +"#, + )?; + fs::write( + src_dir.join("registry_facade.incn"), + r#"pub from function_registry import add, deterministic_spec +"#, + )?; + fs::write( + src_dir.join("facade_helpers.incn"), + r#"from registry_facade import add, deterministic_spec + + +@add(deterministic_spec(lifecycle="stable")) +pub def facade_normalize(value: int) -> int: + return value "#, )?; fs::write( tests_dir.join("test_registry_intent.incn"), r#"from function_registry import registered_names, registered_namespaces from helpers import normalize +from facade_helpers import facade_normalize def test_decorator_can_infer_name_with_imported_partial_spec() -> None: assert normalize(7) == 7 assert registered_names[0] == "normalize" assert registered_namespaces[0] == "core" + + +def test_decorator_can_use_reexported_partial_spec() -> None: + assert facade_normalize(8) == 8 + assert registered_names[1] == "facade_normalize" + assert registered_namespaces[1] == "core" "#, )?; @@ -2013,6 +2035,310 @@ def test_partial_default_symbols_in_decorator() -> None: Ok(()) } +#[test] +fn test_decorated_functions_preserve_default_argument_calls_issue703() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "decorated_default_argument_calls", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + fs::write( + src_dir.join("columns.incn"), + r#"pub model ColumnExpr: + pub value: str + + +pub model Ref: + pub name: str + + +pub model Literal: + pub value: int + + +pub type Expr = Union[Ref, Literal] + + +pub def col(value: str) -> ColumnExpr: + return ColumnExpr(value=value) + + +pub def union_col(name: str) -> Expr: + return Ref(name=name) +"#, + )?; + fs::write( + src_dir.join("defaults.incn"), + r#"pub model Ref: + pub name: str + + +pub model Literal: + pub value: int + + +pub type Expr = Union[Ref, Literal] + + +pub def col(name: str) -> Expr: + return Ref(name=name) + + +def identity(func: (Expr) -> int) -> (Expr) -> int: + return func + + +@identity +pub def decorated_default(expr: Expr = col("")) -> int: + return 1 +"#, + )?; + fs::write( + src_dir.join("test_consumer.incn"), + r#"from defaults import decorated_default + + +def test_imported_decorated_default_call() -> None: + assert decorated_default() == 1 +"#, + )?; + fs::write( + src_dir.join("facade.incn"), + r#"pub from defaults import decorated_default +"#, + )?; + fs::write( + src_dir.join("facade_chain.incn"), + r#"pub from facade import decorated_default +"#, + )?; + fs::write( + src_dir.join("facade_alias.incn"), + r#"pub from defaults import decorated_default as public_decorated_default +"#, + )?; + fs::write( + src_dir.join("test_facade_consumer.incn"), + r#"from facade import decorated_default + + +def test_reexported_decorated_default_call() -> None: + assert decorated_default() == 1 +"#, + )?; + fs::write( + src_dir.join("test_facade_chain_consumer.incn"), + r#"from facade_chain import decorated_default + + +def test_chained_reexported_decorated_default_call() -> None: + assert decorated_default() == 1 +"#, + )?; + fs::write( + src_dir.join("test_facade_alias_consumer.incn"), + r#"from facade_alias import public_decorated_default + + +def test_aliased_reexported_decorated_default_call() -> None: + assert public_decorated_default() == 1 +"#, + )?; + let functions_dir = src_dir.join("functions"); + let aggregates_dir = functions_dir.join("aggregates"); + fs::create_dir_all(&aggregates_dir)?; + fs::write( + aggregates_dir.join("count.incn"), + r#"from defaults import Expr, col + + +def identity(func: (Expr) -> int) -> (Expr) -> int: + return func + + +@identity +pub def count(expr: Expr = col("")) -> int: + return 1 +"#, + )?; + fs::write( + functions_dir.join("mod.incn"), + r#"pub from functions.aggregates.count import count +"#, + )?; + fs::write( + src_dir.join("test_nested_facade_consumer.incn"), + r#"from functions import count + + +def test_nested_reexported_decorated_default_call() -> None: + assert count() == 1 +"#, + )?; + let tests_dir = tmp.path().join("tests"); + fs::create_dir_all(&tests_dir)?; + fs::write( + tests_dir.join("test_decorated_default_probe.incn"), + r#"from columns import ColumnExpr, Expr, col, union_col + + +def identity(func: (int) -> int) -> ((int) -> int): + return func + + +class Box: + value: int + + @method_identity + def decorated_method_default(self, value: int = 11) -> int: + return value + + +def method_identity(func: (&Box, int) -> int) -> ((&Box, int) -> int): + return func + + +@identity +def decorated_default(value: int = 7) -> int: + return value + + +def count_identity(func: (ColumnExpr) -> int) -> ((ColumnExpr) -> int): + return func + + +@count_identity +def count(expr: ColumnExpr = col("")) -> int: + return 1 + + +def union_count_identity(func: (Expr) -> int) -> ((Expr) -> int): + return func + + +@union_count_identity +def union_count(expr: Expr = union_col("")) -> int: + return 1 + + +def adapted_impl(value: str) -> int: + return 7 + + +def string_adapter(func: (int) -> int) -> ((str) -> int): + return adapted_impl + + +@string_adapter +def surface_changed(value: int = 7) -> int: + return value + + +def plain_default(value: int = 7) -> int: + return value + + +def plain_union_default(expr: Expr = union_col("")) -> int: + return 1 + + +def test_decorated_default_probe() -> None: + assert plain_default() == 7 + assert plain_union_default() == 1 + assert plain_union_default(union_col("orders")) == 1 + assert decorated_default() == 7 + assert decorated_default(3) == 3 + box = Box(value=1) + assert box.decorated_method_default() == 11 + assert box.decorated_method_default(5) == 5 + assert count() == 1 + assert count(col("orders")) == 1 + assert union_count() == 1 + assert union_count(union_col("orders")) == 1 + assert surface_changed("changed") == 7 +"#, + )?; + + let test_path = tmp.path().join("tests/test_decorated_default_probe.incn"); + let test_output = run_incan( + tmp.path(), + &["test", test_path.to_str().ok_or("test path was not valid UTF-8")?], + )?; + assert_success(&test_output, "incan test for decorated default arguments issue703"); + + let consumer_path = src_dir.join("test_consumer.incn"); + let consumer_output = run_incan( + tmp.path(), + &[ + "test", + consumer_path.to_str().ok_or("consumer path was not valid UTF-8")?, + ], + )?; + assert_success( + &consumer_output, + "incan test for imported decorated default arguments issue703", + ); + + let facade_consumer_path = src_dir.join("test_facade_consumer.incn"); + let facade_consumer_output = run_incan( + tmp.path(), + &[ + "test", + facade_consumer_path + .to_str() + .ok_or("facade consumer path was not valid UTF-8")?, + ], + )?; + assert_success( + &facade_consumer_output, + "incan test for re-exported decorated default arguments issue703", + ); + + let facade_chain_consumer_path = src_dir.join("test_facade_chain_consumer.incn"); + let facade_chain_consumer_output = run_incan( + tmp.path(), + &[ + "test", + facade_chain_consumer_path + .to_str() + .ok_or("facade chain consumer path was not valid UTF-8")?, + ], + )?; + assert_success( + &facade_chain_consumer_output, + "incan test for chained re-exported decorated default arguments issue703", + ); + + let facade_alias_consumer_path = src_dir.join("test_facade_alias_consumer.incn"); + let facade_alias_consumer_output = run_incan( + tmp.path(), + &[ + "test", + facade_alias_consumer_path + .to_str() + .ok_or("facade alias consumer path was not valid UTF-8")?, + ], + )?; + assert_success( + &facade_alias_consumer_output, + "incan test for aliased re-exported decorated default arguments issue703", + ); + + let nested_facade_consumer_path = src_dir.join("test_nested_facade_consumer.incn"); + let nested_facade_consumer_output = run_incan( + tmp.path(), + &[ + "test", + nested_facade_consumer_path + .to_str() + .ok_or("nested facade consumer path was not valid UTF-8")?, + ], + )?; + assert_success( + &nested_facade_consumer_output, + "incan test for nested re-exported decorated default arguments issue703", + ); + Ok(()) +} + #[test] fn test_decorator_callable_exposes_source_name_issue694() -> Result<(), Box> { let tmp = tempfile::tempdir()?; @@ -2038,11 +2364,17 @@ pub def capture(func: (int) -> int) -> ((int) -> int): pub def registered() -> (((int) -> int) -> ((int) -> int)): return capture +"#, + )?; + fs::write( + src_dir.join("registry_facade.incn"), + r#"pub from registry import names, registered "#, )?; fs::write( tests_dir.join("test_callable_name.incn"), r#"from registry import names, registered +from registry_facade import registered as facade_registered @registered() @@ -2050,9 +2382,16 @@ pub def sample(value: int) -> int: return value + 1 +@facade_registered() +pub def facade_sample(value: int) -> int: + return value + 2 + + def test_decorator_can_read_specific_callable_name() -> None: assert sample(1) == 2 assert names[0] == "sample" + assert facade_sample(1) == 3 + assert names[1] == "facade_sample" "#, )?; diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index cf27f9c4f..4ad5e64cb 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -39,7 +39,7 @@ Use this section as the map. The release note names each larger feature, says wh - **Value enums**: Keep enum type safety while exposing canonical `str` or `int` representations for external values. Read [Enums](../language/explanation/enums.md) and [Modeling with enums](../language/how-to/modeling_with_enums.md) ([RFC 032], #317). - **Enum methods and trait adoption**: Put enum-owned behavior on the enum and let enums adopt the same trait protocols as other source types. Read [Enums](../language/explanation/enums.md) and [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) ([RFC 050], #334). - **Computed properties and protocol hooks**: Define property-like readers and dunder-backed operator/protocol behavior without pushing users into Rust-shaped wrappers. Read [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) and [Derives and traits](../language/reference/derives_and_traits.md) ([RFC 046], [RFC 068], [RFC 028], #86, #162, #203). -- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape, and concrete decorated callable values expose `__name__` for registry-style decorators. Read [Decorators](../language/reference/language.md#decorators), [Functions](../language/reference/functions.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640, #694). +- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape, concrete decorated callable values expose `__name__` for registry-style decorators, and decorated wrappers preserve source default-argument calls when the callable surface is unchanged. Read [Decorators](../language/reference/language.md#decorators), [Functions](../language/reference/functions.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640, #694, #703). - **Symbol aliases**: Export an existing callable or type-like symbol under another name without pretending it is a hand-written wrapper. Read [Symbol aliases](../language/reference/symbol_aliases.md) ([RFC 083], #437). - **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). - **Variadics and call unpacking**: Describe call shapes that accept or forward flexible argument lists without losing static checks. Read [Functions](../language/reference/functions.md) ([RFC 038], #83). @@ -109,6 +109,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Partial presets keep their defaults in decorators**: Imported public partials now retain their projected default arguments and module-owned default symbols when used inside decorator factory arguments, matching ordinary runtime calls (#698, #701). - **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, method-call decorator factories, and reexport-only facade projections are represented in checked metadata more reliably (#636, #638, #640, #669, #694, #695). - **Decorator helpers can inspect generic callables**: `func.__name__` works in generic `(F) -> F` decorator helpers, including imported alias and union callable signatures, so registry decorators can infer the decorated helper name instead of repeating it as a string (#694, #701). +- **Decorated wrappers preserve defaults**: Decorated functions and methods keep source default-argument call behavior when the final decorated callable surface still matches the original declaration, including direct imports and public facade re-exports (#703). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). ### Formatter And Test Runner From 03d4230e080f271fcac91e8ebf86796fec7b48b7 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 28 May 2026 06:30:19 +0200 Subject: [PATCH 45/58] bugfix - preserve Rust bridge identity across reexports (#705) (#706) --- Cargo.lock | 18 +-- Cargo.toml | 2 +- crates/rust_inspect/src/cache.rs | 99 ++++++++++++++-- crates/rust_inspect/src/cache_tests.rs | 45 +++++++ .../check_expr/calls/rust_boundary.rs | 111 ++++++++++++++++-- src/frontend/typechecker/mod.rs | 17 ++- tests/cli_integration.rs | 69 ++++++++++- .../docs-site/docs/release_notes/0_3.md | 2 +- 8 files changed, 327 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44e46622a..b7190e7c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc20" +version = "0.3.0-rc21" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index c3b452e45..505da8ea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc20" +version = "0.3.0-rc21" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/rust_inspect/src/cache.rs b/crates/rust_inspect/src/cache.rs index b3c8954d0..de5d651bc 100644 --- a/crates/rust_inspect/src/cache.rs +++ b/crates/rust_inspect/src/cache.rs @@ -341,6 +341,43 @@ fn canonical_path_candidates(canonical_path: &str) -> Vec { } } +/// Return whether two Rust paths may name the same item after cache-supported spelling aliases. +fn cache_path_aliases_match(left: &str, right: &str) -> bool { + let right_candidates = canonical_path_candidates(right); + canonical_path_candidates(left).iter().any(|left_candidate| { + right_candidates + .iter() + .any(|right_candidate| left_candidate == right_candidate) + }) +} + +/// Re-key a cached item for a query path while preserving the extracted Rust metadata. +fn insert_aliased_item( + inner: &mut CacheInner, + root: &Path, + canonical_path: &str, + hit: &Arc, +) -> Arc { + let mut aliased = (*hit.as_ref()).clone(); + aliased.canonical_path = canonical_path.to_owned(); + let arc = Arc::new(aliased); + let key_item = (root.to_path_buf(), canonical_path.to_owned()); + inner.failed_items.remove(&key_item); + inner.items.insert(key_item, Arc::clone(&arc)); + arc +} + +/// Look up cached public aliases whose recorded definition path matches the requested path. +fn cached_definition_alias(inner: &CacheInner, root: &Path, canonical_path: &str) -> Option> { + inner.items.iter().find_map(|((item_root, _), cached)| { + if item_root != root { + return None; + } + let definition = cached.definition_path.as_deref()?; + cache_path_aliases_match(definition, canonical_path).then(|| Arc::clone(cached)) + }) +} + /// Attempt extraction through primary workspace, out-dirs workspace, then resolved dependency workspace. fn extract_in_workspace_set( inner: &mut CacheInner, @@ -584,7 +621,7 @@ impl RustMetadataCache { /// Return metadata for `canonical_path`, loading/extracting on cache miss. /// /// Lookup order is: - /// 1. in-memory exact/alias hits + /// 1. in-memory exact, definition-path, and spelling-alias hits /// 2. workspace extraction using canonical-path candidates /// 3. dependency-workspace extraction fallback /// 4. persisted disk-cache update for future sessions @@ -620,6 +657,29 @@ impl RustMetadataCache { trace.set_outcome("hit.memory.exact"); return Ok(Arc::clone(hit)); } + if let Some(hit) = cached_definition_alias(&inner, &root, canonical_path) { + let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); + let persist_started = Instant::now(); + if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) + && timing_enabled + { + eprintln!( + "[rust-inspect-timing] root={} query={} stage=disk_cache.persist.definition_alias_hit status=error err={err}", + root.display(), + canonical_path + ); + } + log_timing_stage( + timing_enabled, + &root, + canonical_path, + "disk_cache.persist.definition_alias_hit", + persist_started.elapsed(), + "", + ); + trace.set_outcome("hit.memory.definition_alias"); + return Ok(arc); + } if let Some(miss) = inner.failed_items.get(&key_item) { trace.set_outcome("hit.memory.negative"); return Err(miss.to_error()); @@ -629,11 +689,8 @@ impl RustMetadataCache { let mut meta = None; for candidate in canonical_path_candidates(canonical_path) { let candidate_key = (root.clone(), candidate.clone()); - if let Some(hit) = inner.items.get(&candidate_key) { - let mut aliased = (*hit.as_ref()).clone(); - aliased.canonical_path = canonical_path.to_owned(); - let arc = Arc::new(aliased); - inner.items.insert(key_item.clone(), Arc::clone(&arc)); + if let Some(hit) = inner.items.get(&candidate_key).cloned() { + let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); let persist_started = Instant::now(); if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) && timing_enabled @@ -761,13 +818,33 @@ impl RustMetadataCache { })); } + if let Some(hit) = cached_definition_alias(&inner, &root, canonical_path) { + let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); + if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) { + tracing::warn!( + root = %root.display(), + query = %canonical_path, + error = %err, + "failed to persist rust-inspect disk cache after definition alias hit" + ); + if rust_inspect_timing_enabled() { + eprintln!( + "[rust-inspect-timing] root={} query={} stage=disk_cache.persist.cached_definition_alias status=error err={err}", + root.display(), + canonical_path + ); + } + } + return Ok(Some(CacheLookupHit { + metadata: arc, + alias_used: true, + })); + } + for candidate in canonical_path_candidates(canonical_path) { let candidate_key = (root.clone(), candidate.clone()); - if let Some(hit) = inner.items.get(&candidate_key) { - let mut aliased = (*hit.as_ref()).clone(); - aliased.canonical_path = canonical_path.to_owned(); - let arc = Arc::new(aliased); - inner.items.insert(key_item.clone(), Arc::clone(&arc)); + if let Some(hit) = inner.items.get(&candidate_key).cloned() { + let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) { tracing::warn!( root = %root.display(), diff --git a/crates/rust_inspect/src/cache_tests.rs b/crates/rust_inspect/src/cache_tests.rs index d197e5563..48bf6da32 100644 --- a/crates/rust_inspect/src/cache_tests.rs +++ b/crates/rust_inspect/src/cache_tests.rs @@ -17,6 +17,21 @@ fn dummy_type_metadata(path: &str) -> RustItemMetadata { } } +/// Build minimal public Rust type metadata that records its defining module path. +fn dummy_reexported_type_metadata(path: &str, definition_path: &str) -> RustItemMetadata { + RustItemMetadata { + canonical_path: path.to_string(), + definition_path: Some(definition_path.to_string()), + visibility: RustVisibility::Public, + kind: RustItemKind::Type(RustTypeInfo { + methods: Vec::new(), + implemented_traits: Vec::new(), + fields: Vec::new(), + variants: Vec::new(), + }), + } +} + #[test] fn lockfile_registry_fallback_resolves_hyphenated_package_for_underscored_crate_name() -> Result<(), Box> { @@ -165,6 +180,36 @@ fn raw_identifier_alias_hits_existing_cached_item() -> Result<(), Box Result<(), Box> { + let tmp = tempfile::tempdir()?; + fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + )?; + + let cache = RustMetadataCache::new(); + cache.insert_test_item( + tmp.path(), + dummy_reexported_type_metadata("bridge::ScalarUDF", "bridge::udf::ScalarUDF"), + )?; + + let hit = cache + .get_cached(tmp.path(), "bridge::udf::ScalarUDF")? + .ok_or_else(|| std::io::Error::other("expected definition-path cache alias hit"))?; + assert_eq!(hit.metadata.canonical_path, "bridge::udf::ScalarUDF"); + assert_eq!( + hit.metadata.definition_path.as_deref(), + Some("bridge::udf::ScalarUDF") + ); + assert!(hit.alias_used); + + let extracted = cache.get_or_extract(tmp.path(), "bridge::udf::ScalarUDF", &|_| ())?; + assert_eq!(extracted.canonical_path, "bridge::udf::ScalarUDF"); + Ok(()) +} + #[test] fn repeated_missing_lookup_hits_negative_cache_without_new_workspace_load() -> Result<(), Box> { diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index c7b134e31..1739e1975 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -28,24 +28,45 @@ impl TypeChecker { /// signature is known lets the next method call validate against its real signature instead of falling back to /// permissive unknown-method lowering. fn prewarm_rust_return_type_metadata(&self, ty: &ResolvedType) { + self.prewarm_rust_type_identity_metadata(ty); + } + + /// Eagerly cache Rust identity metadata for nominal paths nested inside Rust display types. + /// + /// rust-inspect can report public signatures such as `Arc` while another API returns the same type + /// through its defining module, for example `Arc`. The outer generic wrapper is not the + /// semantic identity; the nested Rust path is. Prewarming those nested paths lets compatibility use cache-only + /// definition metadata instead of treating the two displays as unrelated nominal types. + fn prewarm_rust_type_identity_metadata(&self, ty: &ResolvedType) { match ty { ResolvedType::RustPath(path) => { - let _ = self.rust_item_metadata_for_path_blocking(path); + let (base, args) = self.rust_path_base_and_args(path); + if base.contains("::") { + let _ = self.rust_item_metadata_for_path_blocking(base.as_str()); + } + for arg in args { + self.prewarm_rust_type_identity_metadata(&arg); + } } - ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => self.prewarm_rust_return_type_metadata(inner), + ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => self.prewarm_rust_type_identity_metadata(inner), ResolvedType::Generic(_, args) | ResolvedType::Tuple(args) => { for arg in args { - self.prewarm_rust_return_type_metadata(arg); + self.prewarm_rust_type_identity_metadata(arg); } } ResolvedType::FrozenList(inner) | ResolvedType::FrozenSet(inner) => { - self.prewarm_rust_return_type_metadata(inner); + self.prewarm_rust_type_identity_metadata(inner); } ResolvedType::FrozenDict(key, value) => { - self.prewarm_rust_return_type_metadata(key); - self.prewarm_rust_return_type_metadata(value); + self.prewarm_rust_type_identity_metadata(key); + self.prewarm_rust_type_identity_metadata(value); + } + ResolvedType::Function(params, ret) => { + for param in params { + self.prewarm_rust_type_identity_metadata(¶m.ty); + } + self.prewarm_rust_type_identity_metadata(ret); } - ResolvedType::Function(_, ret) => self.prewarm_rust_return_type_metadata(ret), _ => {} } } @@ -414,6 +435,8 @@ impl TypeChecker { let arg_expr = Self::call_arg_expr(arg); let normalized = param.type_display.replace(' ', ""); let target_ty = self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()); + self.prewarm_rust_type_identity_metadata(arg_ty); + self.prewarm_rust_type_identity_metadata(&target_ty); if preserves_lookup_arg_shape && self.rust_lookup_probe_boundary_match(arg_ty, &target_ty) { continue; } @@ -467,6 +490,8 @@ impl TypeChecker { let arg_expr = Self::call_arg_expr(arg); let normalized = param.type_display.replace(' ', ""); let target_ty = self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()); + self.prewarm_rust_type_identity_metadata(arg_ty); + self.prewarm_rust_type_identity_metadata(&target_ty); match self.rust_arg_boundary_match(arg_ty, param.type_display.as_str()) { RustArgBoundaryMatch::Exact => {} RustArgBoundaryMatch::Coercion(kind) => { @@ -504,6 +529,23 @@ mod validate_rust_function_call_tests { use incan_core::lang::types::numerics::NumericTypeId; use std::collections::HashMap; + #[cfg(feature = "rust_inspect")] + fn scalar_udf_metadata(path: &str, definition_path: &str) -> incan_core::interop::RustItemMetadata { + use incan_core::interop::{RustItemKind, RustTypeInfo, RustVisibility}; + + incan_core::interop::RustItemMetadata { + canonical_path: path.to_string(), + definition_path: Some(definition_path.to_string()), + visibility: RustVisibility::Public, + kind: RustItemKind::Type(RustTypeInfo { + methods: Vec::new(), + implemented_traits: Vec::new(), + fields: Vec::new(), + variants: Vec::new(), + }), + } + } + #[test] fn zero_parameter_rust_sig_rejects_extra_arguments() { let mut checker = TypeChecker::new(); @@ -530,6 +572,61 @@ mod validate_rust_function_call_tests { ); } + #[cfg(feature = "rust_inspect")] + #[test] + fn rust_function_call_matches_reexported_identity_inside_generic_wrapper() -> Result<(), Box> + { + let mut checker = TypeChecker::new(); + let tmp = tempfile::tempdir()?; + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"incan_test_rust_generic_identity\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + )?; + let manifest_dir = tmp.path().to_path_buf(); + checker.set_rust_inspect_manifest_dir(manifest_dir.clone()); + checker.rust_inspect_cache.insert_test_item( + &manifest_dir, + scalar_udf_metadata("bridge::ScalarUDF", "bridge::udf::ScalarUDF"), + )?; + checker.rust_inspect_cache.insert_test_item( + &manifest_dir, + scalar_udf_metadata("bridge::udf::ScalarUDF", "bridge::udf::ScalarUDF"), + )?; + + let span = Span::new(10, 20); + checker.symbols.define(Symbol { + name: "udf".to_string(), + kind: SymbolKind::Variable(VariableInfo { + ty: ResolvedType::RustPath("rust::Arc".to_string()), + is_mutable: false, + is_used: false, + }), + span, + scope: 0, + }); + + let arg_expr = Spanned::new(Expr::Ident("udf".to_string()), span); + let args = [CallArg::Positional(arg_expr)]; + let sig = RustFunctionSig { + params: vec![RustParam { + name: Some("udf".to_string()), + type_display: "Arc".to_string(), + }], + return_type: "()".to_string(), + is_async: false, + is_unsafe: false, + }; + + let _ = checker.validate_rust_function_call("rust::bridge::consume_udf", &sig, &args, span); + + assert!( + checker.errors.is_empty(), + "expected Rust re-export identity to match inside generic wrapper, errors={:?}", + checker.errors + ); + Ok(()) + } + #[test] fn zero_parameter_rust_sig_allows_no_arguments() { let mut checker = TypeChecker::new(); diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index c0b1ab53a..bd8732443 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -543,9 +543,9 @@ impl TypeChecker { (Self::normalize_rust_namespace_path(trimmed).to_string(), Vec::new()) } - fn attached_rust_definition_for_path(&self, canonical_path: &str) -> Option { + fn rust_definition_for_path(&self, canonical_path: &str) -> Option { let canonical_path = Self::normalize_rust_namespace_path(canonical_path); - self.symbols.all_symbols().iter().find_map(|sym| { + if let Some(definition) = self.symbols.all_symbols().iter().find_map(|sym| { let SymbolKind::RustItem(info) = &sym.kind else { return None; }; @@ -553,19 +553,24 @@ impl TypeChecker { return None; } info.metadata.as_ref().and_then(|meta| meta.definition_path.clone()) - }) + }) { + return Some(definition); + } + self.rust_item_metadata_for_path(canonical_path) + .and_then(|metadata| metadata.definition_path) } /// Extract the cheap Rust identity already known to the checker for compatibility checks. /// /// This must stay metadata-light. `types_compatible(...)` calls it frequently, so it may only use symbol-local - /// metadata already attached during import collection. Fresh rust-inspect extraction from this path would leak a - /// heavy workspace/indexing concern into ordinary semantic checks. + /// metadata already attached during import collection plus cache-only Rust ABI/rust-inspect reads. Fresh + /// rust-inspect extraction from this path would leak a heavy workspace/indexing concern into ordinary semantic + /// checks. fn rust_identity_for_type(&self, ty: &ResolvedType) -> Option<(String, Option, Vec)> { match ty { ResolvedType::RustPath(path) => { let (base, args) = self.rust_path_base_and_args(path); - let definition = self.attached_rust_definition_for_path(base.as_str()); + let definition = self.rust_definition_for_path(base.as_str()); Some((base, definition, args)) } ResolvedType::Named(name) => { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 271743cd0..d5f843879 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -632,6 +632,7 @@ decode_helper = { path = "rust/decode_helper" } decode_trait_helper = { path = "rust/decode_trait_helper" } prost = { path = "rust/prost" } prost-types = { path = "rust/prost-types" } +reexport_identity = { path = "rust/reexport_identity" } "#, )?; fs::write( @@ -639,6 +640,7 @@ prost-types = { path = "rust/prost-types" } r#"from borrowed_generic import borrowed_generic_case from by_value_decode import by_value_decode_case from cross_crate_decode import cross_crate_decode_case +from reexport_identity import reexport_identity_case from trait_by_value_decode import trait_by_value_decode_case def main() -> None: @@ -646,6 +648,7 @@ def main() -> None: println(by_value_decode_case()) println(trait_by_value_decode_case()) println(cross_crate_decode_case()) + println(reexport_identity_case()) "#, )?; fs::write( @@ -696,6 +699,18 @@ pub def cross_crate_decode_case() -> str: Err(_) => return "cross_crate:err" "#, )?; + fs::write( + tmp.path().join("src").join("reexport_identity.incn"), + r#"from rust::reexport_identity import Expr as RustExpr, ScalarFunction as RustScalarFunction, registry + +pub def reexport_identity_case() -> str: + state = registry() + udf = state.udf() + args: list[RustExpr] = [] + _ = RustExpr.ScalarFunction(RustScalarFunction.new_udf(udf, args)) + return "reexport_identity:ok" +"#, + )?; let helper_src = tmp.path().join("rust").join("borrow_helper").join("src"); fs::create_dir_all(&helper_src)?; @@ -838,6 +853,58 @@ impl prost::Message for FileDescriptorSet { Ok(Self) } } +"#, + )?; + let reexport_identity_src = tmp.path().join("rust").join("reexport_identity").join("src"); + fs::create_dir_all(&reexport_identity_src)?; + fs::write( + reexport_identity_src + .parent() + .ok_or("reexport_identity src has no parent")? + .join("Cargo.toml"), + r#"[package] +name = "reexport_identity" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + reexport_identity_src.join("lib.rs"), + r#"use std::sync::Arc; + +pub mod udf { + pub struct ScalarUDF; +} + +pub use udf::ScalarUDF; + +pub struct FunctionRegistry; + +pub fn registry() -> FunctionRegistry { + FunctionRegistry +} + +impl FunctionRegistry { + pub fn udf(&self) -> Arc { + Arc::new(udf::ScalarUDF) + } +} + +pub struct Expr; +pub struct ScalarFunction; + +impl ScalarFunction { + pub fn new_udf(_udf: Arc, _args: Vec) -> Self { + Self + } +} + +impl Expr { + #[allow(non_snake_case)] + pub fn ScalarFunction(_function: ScalarFunction) -> Self { + Self + } +} "#, )?; @@ -850,7 +917,7 @@ impl prost::Message for FileDescriptorSet { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), - "borrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok", + "borrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok\nreexport_identity:ok", "expected batched generic Rust param output, got:\n{stdout}" ); Ok(()) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 4ad5e64cb..4094d0ec7 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -94,7 +94,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n ### Rust Interop And Generated Rust - **Borrowing decisions are metadata-backed**: Borrowed `str`/bytes calls, method fallback borrowing, and retained generated imports now follow the same decisions across typechecking, lowering, and emission. -- **Rust bridge identity is preserved**: Inspected methods with unknown generic or lifetime placeholders and re-exported Rust argument displays keep stable bridge identity (#645, #630). +- **Rust bridge identity is preserved**: Inspected methods with unknown generic or lifetime placeholders and re-exported Rust argument displays keep stable bridge identity, including nested generic wrappers such as `Arc` (#645, #630, #705). - **Collection adaptation is less manual**: Owned Incan values can flow to shared borrowed generic Rust parameters, and `list[T]` can adapt to `Vec` where metadata proves the boundary (#506, #128). - **Protobuf-style APIs need fewer workarounds**: Prost-style inherent and trait-provided `decode(buf: T)` calls lower correctly (#609, #612). - **Generated Rust pruning is safer**: Enum-pattern imports and metadata-derived extension-trait imports survive pruning, while unused generated Rust is pruned without broad `allow` attributes (#459, #447, #214). From c85fbc9d5866073dd5698456007bf6208c73a24f Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Thu, 28 May 2026 22:30:08 +0200 Subject: [PATCH 46/58] bugfix - preserve Rust callable aliases and stdlib enum imports (#708, #710) (#711) --- Cargo.lock | 18 +- Cargo.toml | 2 +- crates/incan_core/src/interop/metadata.rs | 10 +- crates/rust_inspect/src/cache_tests.rs | 7 +- crates/rust_inspect/src/extractor.rs | 49 ++++ src/backend/ir/codegen.rs | 4 + src/backend/ir/emit/expressions/mod.rs | 9 +- src/backend/ir/emit/mod.rs | 3 +- src/backend/ir/emit/program.rs | 3 + src/backend/ir/emit/types.rs | 1 + src/backend/ir/lower/decl/enums.rs | 1 + src/backend/ir/lower/expr/mod.rs | 33 ++- src/backend/ir/trait_bound_inference.rs | 2 + src/backend/ir/types.rs | 9 + src/frontend/symbols.rs | 2 + src/frontend/typechecker/check_decl.rs | 8 +- src/frontend/typechecker/check_expr/access.rs | 209 ++++++++++++++- src/frontend/typechecker/check_expr/basics.rs | 12 +- src/frontend/typechecker/check_expr/calls.rs | 6 +- .../check_expr/calls/rust_boundary.rs | 82 ++++-- src/frontend/typechecker/check_expr/match_.rs | 80 +----- src/frontend/typechecker/collect.rs | 21 +- .../typechecker/collect/stdlib_imports.rs | 34 ++- src/frontend/typechecker/mod.rs | 119 ++++++++- src/frontend/typechecker/stdlib_loader.rs | 15 +- src/frontend/typechecker/tests.rs | 69 ++++- src/frontend/typechecker/type_info.rs | 14 ++ tests/cli_integration.rs | 237 +++++++++++++++++- .../semantic_string_audit.json | 4 +- ...gen_snapshot_tests__std_uuid_compiled.snap | 33 +-- .../docs-site/docs/release_notes/0_3.md | 3 + 31 files changed, 924 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7190e7c3..76cffb061 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc21" +version = "0.3.0-rc24" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 505da8ea6..02a21e4d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc21" +version = "0.3.0-rc24" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/interop/metadata.rs b/crates/incan_core/src/interop/metadata.rs index 92400b7d6..78682e5b5 100644 --- a/crates/incan_core/src/interop/metadata.rs +++ b/crates/incan_core/src/interop/metadata.rs @@ -426,8 +426,15 @@ pub struct RustVariantInfo { } /// Method, field, and variant surface for a Rust ADT or builtin type. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RustTypeInfo { + /// Pretty-printed target type when this item is a Rust `type` alias. + /// + /// Ordinary structs, enums, traits, and builtins leave this empty. Alias targets are metadata, not a substitute + /// type identity: callers should use them only when the alias itself is the expected surface and the target shape + /// is needed for contextual typing or boundary planning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias_target: Option, /// Public inherent methods and associated functions. pub methods: Vec, /// Trait implementations rust-inspect can prove for this Rust type. @@ -485,6 +492,7 @@ mod tests { definition_path: Some(path.to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), implemented_traits: Vec::new(), fields: Vec::new(), diff --git a/crates/rust_inspect/src/cache_tests.rs b/crates/rust_inspect/src/cache_tests.rs index 48bf6da32..0b3ae8c1b 100644 --- a/crates/rust_inspect/src/cache_tests.rs +++ b/crates/rust_inspect/src/cache_tests.rs @@ -9,8 +9,9 @@ fn dummy_type_metadata(path: &str) -> RustItemMetadata { definition_path: None, visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), - implemented_traits: Vec::new(), + implemented_traits: Vec::new(), fields: Vec::new(), variants: Vec::new(), }), @@ -24,6 +25,7 @@ fn dummy_reexported_type_metadata(path: &str, definition_path: &str) -> RustItem definition_path: Some(definition_path.to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), implemented_traits: Vec::new(), fields: Vec::new(), @@ -167,8 +169,9 @@ fn raw_identifier_alias_hits_existing_cached_item() -> Result<(), Box Option }) } +/// Return the written RHS of a Rust `type` alias when available. +/// +/// HIR type displays may erase callable trait-object arguments inside aliases to `_`. The source RHS is the +/// authoritative contract for contextual typing at Rust boundaries, so preserve it when rust-analyzer can recover the +/// defining syntax. +fn source_type_alias_target_display(alias: ra_ap_hir::TypeAlias, db: &RootDatabase) -> Option { + let source = alias.source(db)?; + source.value.ty().map(|ty| ty.to_string().trim().to_string()) +} + fn join_use_path(prefix: Option<&str>, path: &str) -> String { match prefix { Some(prefix) if !prefix.is_empty() => format!("{prefix}::{path}"), @@ -928,6 +938,7 @@ fn extract_rust_item_inner( ModuleDef::Adt(adt) => { let ty = adt.ty(db); RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: collect_inherent_methods(ty.clone(), db, dt), implemented_traits: collect_implemented_traits(ty.clone(), db), fields: collect_public_fields(ty.clone(), db, dt, crate_name), @@ -940,6 +951,7 @@ fn extract_rust_item_inner( ModuleDef::BuiltinType(b) => { let ty = b.ty(db); RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: collect_inherent_methods(ty.clone(), db, dt), implemented_traits: collect_implemented_traits(ty.clone(), db), fields: collect_public_fields(ty, db, dt, crate_name), @@ -956,6 +968,7 @@ fn extract_rust_item_inner( ModuleDef::TypeAlias(a) => { let ty = a.ty(db); RustItemKind::Type(RustTypeInfo { + alias_target: source_type_alias_target_display(a, db).or_else(|| Some(format_ty(&ty, db, dt))), methods: collect_inherent_methods(ty.clone(), db, dt), implemented_traits: collect_implemented_traits(ty.clone(), db), fields: collect_public_fields(ty, db, dt, crate_name), @@ -1060,6 +1073,42 @@ edition = "2021" Ok(()) } + #[test] + fn type_alias_metadata_preserves_source_target_shape() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + fs::create_dir_all(tmp.path().join("src"))?; + fs::write( + tmp.path().join("Cargo.toml"), + r#"[package] +name = "demo_alias_probe" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + tmp.path().join("src/lib.rs"), + r#"use std::sync::Arc; + +pub struct ColumnarValue; +pub struct CallbackError; + +pub type SliceCallback = + Arc Result + Send + Sync>; +"#, + )?; + + let workspace = RustWorkspace::load(tmp.path(), &|_| ())?; + let metadata = extract_rust_item(&workspace, "demo_alias_probe::SliceCallback")?; + let RustItemKind::Type(info) = metadata.kind else { + return Err(std::io::Error::other("expected type metadata").into()); + }; + assert_eq!( + info.alias_target.as_deref(), + Some("Arc Result + Send + Sync>") + ); + Ok(()) + } + #[test] fn type_metadata_preserves_borrowed_slice_params_and_borrowed_option_returns() -> Result<(), Box> { diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 16efcea4b..8e7ce7c42 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -3011,6 +3011,7 @@ pub def make_pair() -> Pair: definition_path: Some("demo::Pair".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), implemented_traits: Vec::new(), fields: vec![ @@ -3125,6 +3126,7 @@ pub def forward(payload: Payload) -> int: definition_path: Some("demo::Builder".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![ RustMethodSig { name: "new".to_string(), @@ -3387,6 +3389,7 @@ pub async def register_csv() -> None: definition_path: Some("demo::SessionContext".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![ RustMethodSig { name: "new".to_string(), @@ -3439,6 +3442,7 @@ pub async def register_csv() -> None: definition_path: Some("demo::CsvReadOptions".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![RustMethodSig { name: "new".to_string(), signature: RustFunctionSig { diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 4e1c30976..22a07a11f 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -916,9 +916,14 @@ impl<'a> IrEmitter<'a> { } => { let param_tokens: Vec = params .iter() - .map(|(pname, _pty)| { + .map(|(pname, pty)| { let n = Self::rust_ident(pname); - quote! { #n } + if matches!(pty, IrType::RustDisplay(_)) { + let ty = self.emit_type(pty); + quote! { #n: #ty } + } else { + quote! { #n } + } }) .collect(); let b = self.emit_expr(body)?; diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 9d47cde1b..37f632389 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -541,7 +541,8 @@ impl<'a> IrEmitter<'a> { | IrType::Numeric(_) | IrType::Struct(_) | IrType::Enum(_) - | IrType::Trait(_) => true, + | IrType::Trait(_) + | IrType::RustDisplay(_) => true, } } diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index 9ba889be0..ff33de43b 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -1079,6 +1079,7 @@ impl<'program> GeneratedUseAnalyzer<'program> { | IrType::FrozenBytes | IrType::StrRef | IrType::Generic(_) + | IrType::RustDisplay(_) | IrType::SelfType | IrType::Unknown => {} } @@ -1733,6 +1734,7 @@ impl<'a> IrEmitter<'a> { | IrType::Enum(_) | IrType::Trait(_) | IrType::Generic(_) + | IrType::RustDisplay(_) | IrType::SelfType | IrType::Unknown => {} } @@ -2172,6 +2174,7 @@ impl<'a> IrEmitter<'a> { | IrType::StrRef | IrType::ImplTrait(_) | IrType::Generic(_) + | IrType::RustDisplay(_) | IrType::SelfType | IrType::Unknown => self.emit_type(ty), } diff --git a/src/backend/ir/emit/types.rs b/src/backend/ir/emit/types.rs index 6604a6401..66f9edb63 100644 --- a/src/backend/ir/emit/types.rs +++ b/src/backend/ir/emit/types.rs @@ -129,6 +129,7 @@ impl<'a> IrEmitter<'a> { } Self::emit_path_ident(name) } + IrType::RustDisplay(display) => display.parse().unwrap_or_else(|_| quote! { _ }), IrType::NamedGeneric(name, _) if name == super::super::types::IR_UNION_TYPE_NAME => { self.emit_union_type_path(ty) } diff --git a/src/backend/ir/lower/decl/enums.rs b/src/backend/ir/lower/decl/enums.rs index fd5a1aa44..44f353f73 100644 --- a/src/backend/ir/lower/decl/enums.rs +++ b/src/backend/ir/lower/decl/enums.rs @@ -115,6 +115,7 @@ fn type_defaults_partial_eq(ty: &IrType) -> bool { | IrType::ImplTrait(_) | IrType::Function { .. } | IrType::Generic(_) + | IrType::RustDisplay(_) | IrType::SelfType | IrType::Unknown => false, } diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index 733bf9c19..a4b37b977 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -1200,9 +1200,40 @@ impl AstLowering { // ---- Closures ---- ast::Expr::Closure(params, body) => { + let recorded_param_types = self + .type_info + .as_ref() + .and_then(|info| match info.expr_type(expr_span) { + Some(crate::frontend::symbols::ResolvedType::Function(callable_params, _)) => Some( + callable_params + .iter() + .map(|param| self.lower_resolved_type(¶m.ty)) + .collect::>(), + ), + _ => None, + }); + let exact_rust_param_types = self + .type_info + .as_ref() + .and_then(|info| info.closure_param_type_displays(expr_span)) + .filter(|displays| displays.len() == params.len()) + .map(|displays| { + displays + .iter() + .map(|display| IrType::RustDisplay(display.clone())) + .collect::>() + }); let param_pairs: Vec<(String, IrType)> = params .iter() - .map(|p| (p.node.name.clone(), self.lower_type(&p.node.ty.node))) + .enumerate() + .map(|(idx, p)| { + let ty = exact_rust_param_types + .as_ref() + .and_then(|types| types.get(idx).cloned()) + .or_else(|| recorded_param_types.as_ref().and_then(|types| types.get(idx).cloned())) + .unwrap_or_else(|| self.lower_type(&p.node.ty.node)); + (p.node.name.clone(), ty) + }) .collect(); self.non_linear_context_depth += 1; let body_ir_result = self.lower_expr_spanned(body); diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index a9238ef80..6c5b0d585 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -1658,6 +1658,7 @@ fn collect_generic_type_param_names(ty: &IrType, type_param_names: &HashSet<&str | IrType::FrozenStr | IrType::FrozenBytes | IrType::StrRef + | IrType::RustDisplay(_) | IrType::SelfType | IrType::Unknown => {} } @@ -2374,6 +2375,7 @@ fn add_bounds_from_type( | IrType::FrozenStr | IrType::FrozenBytes | IrType::StrRef + | IrType::RustDisplay(_) | IrType::SelfType | IrType::Unknown => {} } diff --git a/src/backend/ir/types.rs b/src/backend/ir/types.rs index e64a901f4..ca813dde5 100644 --- a/src/backend/ir/types.rs +++ b/src/backend/ir/types.rs @@ -83,6 +83,12 @@ pub enum IrType { /// - Codegen emits this as `Name`. NamedGeneric(String, Vec), + /// Exact Rust type display carried from interop metadata. + /// + /// This is reserved for Rust boundary shapes that the Incan type model cannot faithfully spell yet, such as + /// borrowed slices (`&[T]`) in closure parameters. + RustDisplay(String), + /// Opaque trait return type emitted as Rust `impl Trait`, RFC 042. ImplTrait(IrTraitBound), @@ -126,6 +132,7 @@ impl IrType { params.iter().any(IrType::contains_generic_parameter) || ret.contains_generic_parameter() } IrType::Generic(_) => true, + IrType::RustDisplay(_) => false, _ => false, } } @@ -204,6 +211,7 @@ impl IrType { IrType::Struct(name) => name.clone(), IrType::Enum(name) => name.clone(), IrType::Trait(name) => name.clone(), + IrType::RustDisplay(display) => display.clone(), IrType::NamedGeneric(name, args) => { let inner: Vec<_> = args.iter().map(|a| a.incan_name()).collect(); format!("{}[{}]", name, inner.join(", ")) @@ -254,6 +262,7 @@ impl IrType { IrType::Result(ok, err) => format!("Result<{}, {}>", ok.rust_name(), err.rust_name()), IrType::Struct(name) | IrType::Enum(name) => name.clone(), IrType::Trait(name) => format!("dyn {}", name), + IrType::RustDisplay(display) => display.clone(), IrType::NamedGeneric(name, _) if name == IR_UNION_TYPE_NAME => { self.union_type_name().unwrap_or_else(|| IR_UNION_TYPE_NAME.to_string()) } diff --git a/src/frontend/symbols.rs b/src/frontend/symbols.rs index bb5040732..346124370 100644 --- a/src/frontend/symbols.rs +++ b/src/frontend/symbols.rs @@ -579,6 +579,8 @@ pub struct EnumInfo { /// Explicit traits adopted by this enum, preserving generic trait arguments when present. pub trait_adoptions: Vec, pub variants: Vec, + /// Positional payload fields for each canonical variant name. + pub variant_fields: HashMap>, /// Variant alias name to canonical variant name. pub variant_aliases: HashMap, pub value_enum: Option, diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index cfb384965..c41c3dd77 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -483,13 +483,17 @@ impl TypeChecker { .params .iter() .skip(skip) - .map(|p| self.resolved_param_type_from_rust_display(p.type_display.as_str())) + .map(|p| { + self.resolved_param_type_from_rust_display_for_owner_path(p.type_display.as_str(), path) + }) .collect(); + let return_display = + self.rust_display_for_owner_path(method.signature.return_type.as_str(), path); candidates.push(InteropAdapterSig { name: format!("rust::{}.{name}", path), receiver, params, - return_type: self.resolved_type_from_rust_display(method.signature.return_type.as_str()), + return_type: self.resolved_type_from_rust_display(return_display.as_str()), }); } } diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 35f8ec9b0..b0058c87a 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -27,6 +27,8 @@ use incan_core::lang::types::collections::CollectionTypeId; use incan_core::lang::types::numerics::NumericFamily; use incan_core::lang::{conventions, stdlib}; use incan_core::lang::{enum_helpers, surface::option_methods}; +use quote::ToTokens; +use syn::{GenericArgument, PathArguments, ReturnType, Type as SynType, TypeParamBound}; use super::TypeChecker; @@ -47,6 +49,18 @@ struct ValueEnumGeneratedCall<'a> { span: Span, } +#[derive(Debug, Clone)] +struct RustCallableAliasParam { + rust_display: String, + resolved_ty: ResolvedType, +} + +#[derive(Debug, Clone)] +struct RustCallableAliasSignature { + params: Vec, + return_ty: ResolvedType, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NumericResizeMethodPolicy { Lossless, @@ -61,6 +75,170 @@ fn rust_receiver_display(path: &str) -> String { } impl TypeChecker { + /// Return the target display for a Rust type alias when the expected destination type names one. + fn rust_callable_alias_target_display(&self, expected_ty: &ResolvedType) -> Option { + let ResolvedType::RustPath(path) = expected_ty else { + return None; + }; + if let Some(metadata) = self.rust_item_metadata_for_path(path) + && let RustItemKind::Type(type_info) = &metadata.kind + && let Some(target) = type_info.alias_target.as_ref() + { + return Some(self.rust_display_for_owner_path(target, path)); + } + Some(self.rust_display_for_owner_path(path, path)) + .filter(|display| display.contains("dyn") && display.contains("Fn")) + } + + /// Parse a Rust callable alias target such as `Arc Result + Send + Sync>`. + fn rust_callable_alias_signature(&self, expected_ty: &ResolvedType) -> Option { + let target_display = self.rust_callable_alias_target_display(expected_ty)?; + let ty = syn::parse_str::(&target_display).ok()?; + let trait_object = Self::rust_callable_trait_object(&ty)?; + let fn_bound = trait_object.bounds.iter().find_map(|bound| { + let TypeParamBound::Trait(trait_bound) = bound else { + return None; + }; + let segment = trait_bound.path.segments.last()?; + if !matches!(segment.ident.to_string().as_str(), "Fn" | "FnMut" | "FnOnce") { + return None; + } + let PathArguments::Parenthesized(args) = &segment.arguments else { + return None; + }; + Some(args) + })?; + + let params = fn_bound + .inputs + .iter() + .map(|input| { + let rust_display = Self::compact_rust_display(&input.to_token_stream().to_string()); + RustCallableAliasParam { + resolved_ty: self.resolved_param_type_from_rust_display(&rust_display), + rust_display, + } + }) + .collect::>(); + let return_ty = match &fn_bound.output { + ReturnType::Default => ResolvedType::Unit, + ReturnType::Type(_, ty) => { + let rust_display = Self::compact_rust_display(&ty.to_token_stream().to_string()); + self.resolved_type_from_rust_display(&rust_display) + } + }; + Some(RustCallableAliasSignature { params, return_ty }) + } + + fn rust_callable_trait_object(ty: &SynType) -> Option<&syn::TypeTraitObject> { + match ty { + SynType::TraitObject(trait_object) => Some(trait_object), + SynType::Group(group) => Self::rust_callable_trait_object(&group.elem), + SynType::Paren(paren) => Self::rust_callable_trait_object(&paren.elem), + SynType::Path(path) => { + let segment = path.path.segments.last()?; + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(inner) => Self::rust_callable_trait_object(inner), + _ => None, + }) + } + _ => None, + } + } + + fn check_closure_with_rust_callable_alias( + &mut self, + expr: &Spanned, + signature: &RustCallableAliasSignature, + ) -> ResolvedType { + let Expr::Closure(params, body) = &expr.node else { + return self.check_expr(expr); + }; + if params.len() != signature.params.len() { + self.errors.push(errors::builtin_arity( + "closure", + signature.params.len(), + params.len(), + expr.span, + )); + return ResolvedType::Unknown; + } + + self.symbols.enter_scope(ScopeKind::Function); + + let prev_in_async_body = self.in_async_body; + self.in_async_body = false; + let prev_return_error_type = self.current_return_error_type.take(); + + let param_types = params + .iter() + .zip(signature.params.iter()) + .map(|(param, expected)| { + let ty = expected.resolved_ty.clone(); + self.symbols.define(Symbol { + name: param.node.name.clone(), + kind: SymbolKind::Variable(VariableInfo { + ty: ty.clone(), + is_mutable: false, + is_used: false, + }), + span: param.span, + scope: 0, + }); + CallableParam::named(param.node.name.clone(), ty, param.node.kind) + }) + .collect::>(); + + let return_ty = self.check_expr_with_expected(body, Some(&signature.return_ty)); + if !matches!(return_ty, ResolvedType::Unknown) && !self.types_compatible(&return_ty, &signature.return_ty) { + self.errors.push(errors::type_mismatch( + &signature.return_ty.to_string(), + &return_ty.to_string(), + body.span, + )); + } + + self.current_return_error_type = prev_return_error_type; + self.in_async_body = prev_in_async_body; + self.symbols.exit_scope(); + + self.type_info.rust.closure_param_type_displays.insert( + (expr.span.start, expr.span.end), + signature + .params + .iter() + .map(|param| param.rust_display.clone()) + .collect(), + ); + + let closure_ty = ResolvedType::Function(param_types, Box::new(signature.return_ty.clone())); + self.record_expr_type(expr.span, closure_ty.clone()); + closure_ty + } + + fn check_method_arg_with_rust_callable_alias( + &mut self, + arg: &CallArg, + signature: Option<&RustCallableAliasSignature>, + ) -> ResolvedType { + match arg { + CallArg::Positional(expr) + | CallArg::Named(_, expr) + | CallArg::PositionalUnpack(expr) + | CallArg::KeywordUnpack(expr) => { + if let Some(signature) = signature + && matches!(expr.node, Expr::Closure(_, _)) + { + return self.check_closure_with_rust_callable_alias(expr, signature); + } + self.check_expr(expr) + } + } + } + /// Return whether `method` names an RFC 070 `Result[T, E]` combinator. fn result_combinator_name(method: &str) -> bool { matches!( @@ -2735,6 +2913,22 @@ impl TypeChecker { self.check_call_args(args); return ResolvedType::Unknown; } + if method == "to_vec" + && args.is_empty() + && matches!( + base_ty, + ResolvedType::Ref(ref inner) | ResolvedType::RefMut(ref inner) + if matches!( + inner.as_ref(), + ResolvedType::Generic(name, _) + if collection_type_id(name.as_str()) == Some(CollectionTypeId::List) + ) + ) + && let ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) = base_ty + { + return *inner; + } + if method == "to_vec" && args.is_empty() && matches!( @@ -2767,15 +2961,18 @@ impl TypeChecker { return ret; } + let contextual_rust_callable = expected_return_ty.and_then(|expected| { + if args.len() == 1 { + self.rust_callable_alias_signature(expected) + } else { + None + } + }); + // Collect arg types for method-specific validation. let arg_types: Vec = args .iter() - .map(|arg| match arg { - CallArg::Positional(e) - | CallArg::Named(_, e) - | CallArg::PositionalUnpack(e) - | CallArg::KeywordUnpack(e) => self.check_expr(e), - }) + .map(|arg| self.check_method_arg_with_rust_callable_alias(arg, contextual_rust_callable.as_ref())) .collect(); if self.receiver_has_computed_property(&base_ty, method, span) { diff --git a/src/frontend/typechecker/check_expr/basics.rs b/src/frontend/typechecker/check_expr/basics.rs index 867539a60..75e6fbe9b 100644 --- a/src/frontend/typechecker/check_expr/basics.rs +++ b/src/frontend/typechecker/check_expr/basics.rs @@ -100,17 +100,7 @@ impl TypeChecker { let resolved = match &info.metadata { Some(meta) => match &meta.kind { incan_core::interop::RustItemKind::Function(sig) => { - let params = sig - .params - .iter() - .map(|p| { - CallableParam::positional( - self.resolved_param_type_from_rust_display(p.type_display.as_str()), - ) - }) - .collect(); - let ret = self.resolved_type_from_rust_display(sig.return_type.as_str()); - ResolvedType::Function(params, Box::new(ret)) + self.resolved_function_type_from_rust_sig_for_owner_path(sig, false, info.path.as_str()) } incan_core::interop::RustItemKind::Constant { type_display } => { self.resolved_type_from_rust_display(type_display.as_str()) diff --git a/src/frontend/typechecker/check_expr/calls.rs b/src/frontend/typechecker/check_expr/calls.rs index da8c3d917..9d1399146 100644 --- a/src/frontend/typechecker/check_expr/calls.rs +++ b/src/frontend/typechecker/check_expr/calls.rs @@ -254,7 +254,11 @@ impl TypeChecker { if self.errors.len() == error_count_before { self.record_expr_type( callee.span, - self.resolved_function_type_from_rust_sig(sig, false), + self.resolved_function_type_from_rust_sig_for_owner_path( + sig, + false, + info.path.as_str(), + ), ); self.type_info .expressions diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index 1739e1975..ee2256de0 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -1,7 +1,7 @@ //! Rust boundary matching, Rust call validation, and coercion metadata recording. use super::TypeChecker; -use crate::frontend::ast::{CallArg, Span}; +use crate::frontend::ast::{CallArg, ParamKind, Span}; use crate::frontend::diagnostics::errors; use crate::frontend::symbols::{CallableParam, ResolvedType, TypeInfo}; use crate::frontend::typechecker::helpers::collection_type_id; @@ -72,8 +72,9 @@ impl TypeChecker { } /// Resolve an inspected Rust return display and cache any returned Rust receiver metadata. - fn resolved_rust_return_type_from_sig(&self, sig: &RustFunctionSig) -> ResolvedType { - let return_ty = self.resolved_type_from_rust_display(sig.return_type.as_str()); + fn resolved_rust_return_type_from_sig(&self, sig: &RustFunctionSig, owner_path: &str) -> ResolvedType { + let return_display = self.rust_display_for_owner_path(sig.return_type.as_str(), owner_path); + let return_ty = self.resolved_type_from_rust_display(return_display.as_str()); self.prewarm_rust_return_type_metadata(&return_ty); return_ty } @@ -83,8 +84,8 @@ impl TypeChecker { /// In an `await` operand we keep the existing source-async behavior: the inner call checks to its output type and /// `check_await` returns that type. Outside `await`, expose the pending future as `Awaitable[T]` so consumers /// cannot accidentally match or unwrap `T` before awaiting the Rust future. - fn resolved_rust_call_type_from_sig(&self, sig: &RustFunctionSig, span: Span) -> ResolvedType { - let return_ty = self.resolved_rust_return_type_from_sig(sig); + fn resolved_rust_call_type_from_sig(&self, sig: &RustFunctionSig, owner_path: &str, span: Span) -> ResolvedType { + let return_ty = self.resolved_rust_return_type_from_sig(sig, owner_path); if sig.is_async && !self.is_in_await_operand(span) { ResolvedType::Generic("Awaitable".to_string(), vec![return_ty]) } else { @@ -350,13 +351,41 @@ impl TypeChecker { } /// Record inspected Rust parameter types so codegen can emit the same borrow shape the typechecker accepted. - fn record_rust_call_site_params(&mut self, span: Span, params: &[incan_core::interop::RustParam]) { + fn rust_params_as_callable_params( + &self, + params: &[incan_core::interop::RustParam], + owner_path: &str, + ) -> Vec { let params: Vec = params .iter() .map(|param| { - CallableParam::positional( - self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()), - ) + let ty = + self.resolved_param_type_from_rust_display_for_owner_path(param.type_display.as_str(), owner_path); + CallableParam { + name: param.name.clone(), + ty, + kind: ParamKind::Normal, + has_default: false, + } + }) + .collect(); + params + } + + /// Record inspected Rust parameter types so codegen can emit the same borrow shape the typechecker accepted. + fn record_rust_call_site_params( + &mut self, + span: Span, + params: &[incan_core::interop::RustParam], + owner_path: &str, + ) { + let params: Vec = params + .iter() + .map(|param| { + CallableParam::positional(self.resolved_rust_boundary_target_from_param_display_for_owner_path( + param.type_display.as_str(), + owner_path, + )) }) .collect(); // Plain Rust type variables carry by-value shape, but they are not ordinary borrow-boundary snapshots. @@ -419,7 +448,7 @@ impl TypeChecker { } else { &sig.params }; - self.record_rust_call_site_params(span, params); + self.record_rust_call_site_params(span, params, callable_display); if arg_types.len() != params.len() { self.errors.push(errors::builtin_arity( @@ -428,19 +457,23 @@ impl TypeChecker { arg_types.len(), span, )); - return self.resolved_rust_call_type_from_sig(sig, span); + return self.resolved_rust_call_type_from_sig(sig, callable_display, span); } for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(params.iter()) { let arg_expr = Self::call_arg_expr(arg); - let normalized = param.type_display.replace(' ', ""); - let target_ty = self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()); + let param_display = self.rust_display_for_owner_path(param.type_display.as_str(), callable_display); + let normalized = param_display.replace(' ', ""); + let target_ty = self.resolved_rust_boundary_target_from_param_display_for_owner_path( + param.type_display.as_str(), + callable_display, + ); self.prewarm_rust_type_identity_metadata(arg_ty); self.prewarm_rust_type_identity_metadata(&target_ty); if preserves_lookup_arg_shape && self.rust_lookup_probe_boundary_match(arg_ty, &target_ty) { continue; } - match self.rust_arg_boundary_match(arg_ty, param.type_display.as_str()) { + match self.rust_arg_boundary_match(arg_ty, param_display.as_str()) { RustArgBoundaryMatch::Exact => {} RustArgBoundaryMatch::Coercion(kind) => { self.type_info.rust.arg_coercions.insert( @@ -462,7 +495,7 @@ impl TypeChecker { } } - let ret = self.resolved_rust_call_type_from_sig(sig, span); + let ret = self.resolved_rust_call_type_from_sig(sig, callable_display, span); self.record_rust_return_coercion_from_display(sig.return_type.as_str(), &ret, span); ret } @@ -478,21 +511,24 @@ impl TypeChecker { if sig.is_async && !self.is_in_await_operand(span) { self.errors.push(errors::async_call_without_await(path, span)); } - let arg_types = self.check_call_arg_types(args); - self.record_rust_call_site_params(span, &sig.params); + let expected_params = self.rust_params_as_callable_params(&sig.params, path); + let arg_types = self.check_call_arg_types_for_params(args, &expected_params); + self.record_rust_call_site_params(span, &sig.params, path); if arg_types.len() != sig.params.len() { self.errors .push(errors::builtin_arity(path, sig.params.len(), arg_types.len(), span)); - return self.resolved_rust_call_type_from_sig(sig, span); + return self.resolved_rust_call_type_from_sig(sig, path, span); } for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(sig.params.iter()) { let arg_expr = Self::call_arg_expr(arg); - let normalized = param.type_display.replace(' ', ""); - let target_ty = self.resolved_rust_boundary_target_from_param_display(param.type_display.as_str()); + let param_display = self.rust_display_for_owner_path(param.type_display.as_str(), path); + let normalized = param_display.replace(' ', ""); + let target_ty = + self.resolved_rust_boundary_target_from_param_display_for_owner_path(param.type_display.as_str(), path); self.prewarm_rust_type_identity_metadata(arg_ty); self.prewarm_rust_type_identity_metadata(&target_ty); - match self.rust_arg_boundary_match(arg_ty, param.type_display.as_str()) { + match self.rust_arg_boundary_match(arg_ty, param_display.as_str()) { RustArgBoundaryMatch::Exact => {} RustArgBoundaryMatch::Coercion(kind) => { self.type_info.rust.arg_coercions.insert( @@ -514,7 +550,7 @@ impl TypeChecker { } } - let ret = self.resolved_rust_call_type_from_sig(sig, span); + let ret = self.resolved_rust_call_type_from_sig(sig, path, span); self.record_rust_return_coercion_from_display(sig.return_type.as_str(), &ret, span); ret } @@ -538,6 +574,7 @@ mod validate_rust_function_call_tests { definition_path: Some(definition_path.to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), implemented_traits: Vec::new(), fields: Vec::new(), @@ -1079,6 +1116,7 @@ mod validate_rust_function_call_tests { definition_path: Some("substrait::proto::Plan".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![], diff --git a/src/frontend/typechecker/check_expr/match_.rs b/src/frontend/typechecker/check_expr/match_.rs index f6ac39c02..dca7eec96 100644 --- a/src/frontend/typechecker/check_expr/match_.rs +++ b/src/frontend/typechecker/check_expr/match_.rs @@ -367,30 +367,8 @@ impl TypeChecker { ); let rust_resolution = self.rust_enum_constructor_payload_types(expected_ty, name.as_str(), positional_count); - let field_types: Option> = incan_resolution - .clone() - .or_else(|| { - self.symbols.all_symbols().iter().rev().find_map(|sym| { - if sym.name != variant_name { - return None; - } - if let SymbolKind::Variant(info) = &sym.kind { - if self.match_variant_symbol_applies_to_scrutinee( - expected_ty, - info, - positional_count, - enum_qualifier_opt, - ) { - Some(info.fields.clone()) - } else { - None - } - } else { - None - } - }) - }) - .or_else(|| match rust_resolution.as_ref() { + let field_types: Option> = + incan_resolution.clone().or_else(|| match rust_resolution.as_ref() { Some(RustEnumPatternResolution::PayloadTypes(fields)) => Some(fields.clone()), Some(RustEnumPatternResolution::QualifierMismatch) | None => None, }); @@ -587,40 +565,11 @@ impl TypeChecker { } } - /// Whether a [`SymbolKind::Variant`] from the symbol table actually describes this match scrutinee. - /// - /// rust-inspect metadata and library manifests register variant names (e.g. `Root`) at module scope. A `rusttype` - /// alias such as `PlanRel` uses a **different** Incan name than the backing Rust enum (`Sender`), so we must not - /// let an unrelated `Root` stub with empty payload metadata shadow [`Self::rust_enum_constructor_payload_types`], - /// or payload bindings in the pattern are never registered and the arm body sees `Unknown symbol`. Source enums can - /// also reuse variant names across distinct enums, so qualified patterns must check the enum name in addition to - /// the short variant symbol. - fn match_variant_symbol_applies_to_scrutinee( - &self, - expected_ty: &ResolvedType, - info: &VariantInfo, - positional_count: usize, - enum_qualifier_opt: Option<&str>, - ) -> bool { - if positional_count > info.fields.len() { - return false; - } - if enum_qualifier_opt.is_some_and(|qualifier| qualifier != info.enum_name) { - return false; - } - match expected_ty { - ResolvedType::Named(type_name) | ResolvedType::Generic(type_name, _) => info.enum_name == *type_name, - // Scrutinee is a bare Rust path: module-level variant symbols are Incan-/manifest-scoped names. - ResolvedType::RustPath(_) => false, - _ => false, - } - } - /// Payload types for a source-defined enum variant, using the enum type's own metadata. /// /// Qualified patterns such as `Color.Red` should not depend on a module-level `Red` symbol being importable or /// winning same-scope shadowing. The scrutinee already tells us which enum is being matched, so resolve the - /// variant from that enum's table first and reserve bare variant symbols as a compatibility fallback. + /// variant from that enum's table. fn incan_enum_constructor_payload_types( &self, expected_ty: &ResolvedType, @@ -635,7 +584,7 @@ impl TypeChecker { if enum_qualifier_opt.is_some_and(|qualifier| qualifier != enum_name) { return None; } - let Some(TypeInfo::Enum(enum_info)) = self.lookup_type_info(enum_name) else { + let Some(TypeInfo::Enum(enum_info)) = self.lookup_semantic_type_info(enum_name) else { return None; }; let canonical_variant = enum_info @@ -646,22 +595,15 @@ impl TypeChecker { if !enum_info.variants.iter().any(|variant| variant == canonical_variant) { return None; } - let Some(symbol) = self - .symbols - .all_symbols() - .iter() - .rev() - .find(|sym| sym.name == canonical_variant || sym.name == variant_name) - else { - return Some(Vec::new()); - }; - let SymbolKind::Variant(info) = &symbol.kind else { - return Some(Vec::new()); - }; - if info.enum_name != *enum_name || positional_count > info.fields.len() { + let fields = enum_info + .variant_fields + .get(canonical_variant) + .cloned() + .unwrap_or_default(); + if positional_count > fields.len() { return None; } - Some(info.fields.clone()) + Some(fields) } /// Tuple-variant payload types for `match` patterns on Rust-backed enum surfaces. diff --git a/src/frontend/typechecker/collect.rs b/src/frontend/typechecker/collect.rs index d2f3eb541..4803e00a0 100644 --- a/src/frontend/typechecker/collect.rs +++ b/src/frontend/typechecker/collect.rs @@ -1107,6 +1107,19 @@ impl TypeChecker { /// Register an enum declaration and define symbols for each variant. fn collect_enum(&mut self, en: &EnumDecl, span: Span) { let variants: Vec<_> = en.variants.iter().map(|v| v.node.name.clone()).collect(); + let variant_fields: HashMap<_, _> = en + .variants + .iter() + .map(|variant| { + let fields = variant + .node + .fields + .iter() + .map(|field| self.resolve_type_checked(field)) + .collect(); + (variant.node.name.clone(), fields) + }) + .collect(); let variant_aliases: HashMap<_, _> = en .variant_aliases .iter() @@ -1137,6 +1150,7 @@ impl TypeChecker { traits: en.traits.iter().map(|t| t.node.name.clone()).collect(), trait_adoptions, variants: variants.clone(), + variant_fields: variant_fields.clone(), variant_aliases: variant_aliases.clone(), value_enum, derives, @@ -1159,12 +1173,7 @@ impl TypeChecker { // Also define each variant as a symbol for variant in &en.variants { - let fields: Vec<_> = variant - .node - .fields - .iter() - .map(|f| self.resolve_type_checked(f)) - .collect(); + let fields = variant_fields.get(&variant.node.name).cloned().unwrap_or_default(); self.symbols.define_preserving_existing_binding(Symbol { name: variant.node.name.clone(), kind: SymbolKind::Variant(VariantInfo { diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index 5cb4cd7d1..f5927defc 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -216,7 +216,7 @@ impl TypeChecker { } let testing_semantics = self.load_testing_semantics_for_import(&context, span); - self.materialize_stdlib_stub_types(&context, span); + self.cache_stdlib_stub_semantics(&context); for item in items { if self.materialize_stdlib_from_import(&context, item, testing_semantics.as_ref(), span) { @@ -237,21 +237,19 @@ impl TypeChecker { } } - /// Materialize all known top-level types for a stub-backed stdlib module before collecting individual items. - fn materialize_stdlib_stub_types(&mut self, context: &FromImportContext<'_>, span: Span) { + /// Cache all known top-level types and traits for a stub-backed stdlib module without making them source-visible. + fn cache_stdlib_stub_semantics(&mut self, context: &FromImportContext<'_>) { if !context.stdlib.as_ref().is_some_and(|stdlib| stdlib.has_stub) { return; } for (type_name, type_info) in self.stdlib_cache.list_types(&context.module.segments) { - if self.symbols.lookup(&type_name).is_none() { - self.symbols.define(Symbol { - name: type_name, - kind: SymbolKind::Type(type_info), - span, - scope: 0, - }); - } + self.transitive_stdlib_stub_types.entry(type_name).or_insert(type_info); + } + for (trait_name, trait_info) in self.stdlib_cache.list_traits(&context.module.segments) { + self.transitive_stdlib_stub_traits + .entry(trait_name) + .or_insert(trait_info); } } @@ -1342,6 +1340,20 @@ impl TypeChecker { traits: export.traits.clone(), trait_adoptions: Self::trait_adoptions_from_manifest(&export.traits, &export.trait_adoptions), variants: export.variants.iter().map(|variant| variant.name.clone()).collect(), + variant_fields: export + .variants + .iter() + .map(|variant| { + ( + variant.name.clone(), + variant + .fields + .iter() + .map(resolved_type_from_manifest_type_ref) + .collect(), + ) + }) + .collect(), variant_aliases: export .variant_aliases .iter() diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index bd8732443..9c3f293ae 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -214,6 +214,17 @@ pub struct TypeChecker { /// This keeps trait/supertrait compatibility available for imported function signatures without making those trait /// names ambient in user source. pub(crate) transitive_pub_traits: HashMap>, + /// Internal semantic type cache for stdlib stub helper types referenced by imported stdlib signatures. + /// + /// Stub-backed stdlib modules often define carrier classes that imported functions return or accept. These types + /// must be available for internal method lookup, but importing one stdlib item must not make every sibling stub + /// type source-visible in user modules. + pub(crate) transitive_stdlib_stub_types: HashMap, + /// Internal semantic trait cache for stdlib stub helper traits referenced by imported stdlib signatures. + /// + /// Like [`Self::transitive_stdlib_stub_types`], these traits are used for trait-bound compatibility and + /// trait-method dispatch without widening source-visible name lookup. + pub(crate) transitive_stdlib_stub_traits: HashMap, /// Tracks which `pub::` libraries have already seeded the internal transitive semantic caches for this checker /// run. pub(crate) cached_pub_libraries: HashSet, @@ -310,6 +321,8 @@ impl TypeChecker { library_manifests: Arc::new(LibraryManifestIndex::default()), transitive_pub_types: HashMap::new(), transitive_pub_traits: HashMap::new(), + transitive_stdlib_stub_types: HashMap::new(), + transitive_stdlib_stub_traits: HashMap::new(), cached_pub_libraries: HashSet::new(), current_module_path: None, declared_crate_names: None, @@ -543,6 +556,35 @@ impl TypeChecker { (Self::normalize_rust_namespace_path(trimmed).to_string(), Vec::new()) } + /// Rewrite Rust's crate-relative type displays against the inspected callable or type path that produced them. + /// + /// rust-analyzer reports signatures from inside a Rust crate using displays such as + /// `crate::ScalarFunctionImplementation`. Incan source, imports, and generated Rust refer to the dependency by its + /// crate name (`datafusion_expr::ScalarFunctionImplementation`), so boundary typing must canonicalize those + /// displays before compatibility checks or contextual argument checking run. + pub(crate) fn rust_display_for_owner_path(&self, rust_ty: &str, owner_path: &str) -> String { + let owner = Self::normalize_rust_namespace_path(owner_path); + let Some(crate_name) = owner.split("::").next().filter(|segment| !segment.is_empty()) else { + return rust_ty.to_string(); + }; + let replacement = format!("{crate_name}::"); + let mut rendered = String::with_capacity(rust_ty.len() + replacement.len()); + let mut remaining = rust_ty; + while let Some(idx) = remaining.find("crate::") { + let (before, after) = remaining.split_at(idx); + rendered.push_str(before); + let prev = rendered.chars().next_back(); + if prev.is_some_and(|ch| ch == '_' || ch.is_ascii_alphanumeric()) { + rendered.push_str("crate::"); + } else { + rendered.push_str(replacement.as_str()); + } + remaining = &after["crate::".len()..]; + } + rendered.push_str(remaining); + rendered + } + fn rust_definition_for_path(&self, canonical_path: &str) -> Option { let canonical_path = Self::normalize_rust_namespace_path(canonical_path); if let Some(definition) = self.symbols.all_symbols().iter().find_map(|sym| { @@ -684,23 +726,26 @@ impl TypeChecker { sig.params.first().is_some_and(Self::rust_param_is_receiver) } - /// Build a conservative function type from rust-inspect metadata. - /// - /// When `drop_receiver` is true and the Rust signature starts with `self`, that first parameter is omitted because - /// method-call syntax already supplies the receiver expression. - pub(crate) fn resolved_function_type_from_rust_sig( + /// Build a Rust function type from a signature whose displays may use `crate::` relative to `rust_path`. + pub(crate) fn resolved_function_type_from_rust_sig_for_owner_path( &self, sig: &RustFunctionSig, drop_receiver: bool, + rust_path: &str, ) -> ResolvedType { let skip = usize::from(drop_receiver && Self::rust_signature_has_receiver(sig)); let params = sig .params .iter() .skip(skip) - .map(|p| CallableParam::positional(self.resolved_param_type_from_rust_display(p.type_display.as_str()))) + .map(|p| { + CallableParam::positional( + self.resolved_param_type_from_rust_display_for_owner_path(p.type_display.as_str(), rust_path), + ) + }) .collect(); - let ret = self.resolved_type_from_rust_display(sig.return_type.as_str()); + let ret_display = self.rust_display_for_owner_path(sig.return_type.as_str(), rust_path); + let ret = self.resolved_type_from_rust_display(ret_display.as_str()); ResolvedType::Function(params, Box::new(ret)) } @@ -755,7 +800,10 @@ impl TypeChecker { drop_receiver: bool, rust_path: &str, ) -> ResolvedType { - Self::substitute_rust_self_type(self.resolved_function_type_from_rust_sig(sig, drop_receiver), rust_path) + Self::substitute_rust_self_type( + self.resolved_function_type_from_rust_sig_for_owner_path(sig, drop_receiver, rust_path), + rust_path, + ) } /// Render `path` with generic arguments as `path` for embedding in [`ResolvedType::RustPath`]. @@ -1062,6 +1110,13 @@ impl TypeChecker { let inner_ty = match inner { "str" => ResolvedType::Str, "[u8]" => ResolvedType::Bytes, + _ if inner_normalized.starts_with('[') && inner_normalized.ends_with(']') => { + let elem = &inner_normalized[1..inner_normalized.len() - 1]; + ResolvedType::Generic( + collection_name(CollectionTypeId::List).to_string(), + vec![self.resolved_type_from_rust_display(elem)], + ) + } _ => self.resolved_type_from_rust_display(inner_normalized.as_str()), }; return if is_mut { @@ -1078,6 +1133,16 @@ impl TypeChecker { self.resolved_type_from_rust_display(normalized.as_str()) } + /// Convert a Rust parameter display into a resolved type after expanding crate-relative paths for its owner. + pub(crate) fn resolved_param_type_from_rust_display_for_owner_path( + &self, + rust_ty: &str, + owner_path: &str, + ) -> ResolvedType { + let display = self.rust_display_for_owner_path(rust_ty, owner_path); + self.resolved_param_type_from_rust_display(display.as_str()) + } + /// Convert a Rust parameter display type into the typed target carried by Rust-boundary coercion metadata. /// /// This preserves the semantic difference between slice borrow targets such as `&str`/`&[u8]` and borrowed owned @@ -1111,6 +1176,16 @@ impl TypeChecker { self.resolved_param_type_from_rust_display(normalized.as_str()) } + /// Convert a Rust boundary target after expanding crate-relative paths for its owner. + pub(crate) fn resolved_rust_boundary_target_from_param_display_for_owner_path( + &self, + rust_ty: &str, + owner_path: &str, + ) -> ResolvedType { + let display = self.rust_display_for_owner_path(rust_ty, owner_path); + self.resolved_rust_boundary_target_from_param_display(display.as_str()) + } + /// Set the declared Rust crate names from `incan.toml [rust-dependencies]`. /// /// When set, `rust.module()` path validation will check that the first segment of the path is either `incan_stdlib` @@ -1454,6 +1529,9 @@ impl TypeChecker { if let Some(info) = self.lookup_type_info(name) { return Some(info); } + if let Some(info) = self.transitive_stdlib_stub_types.get(name) { + return Some(info); + } let infos = self.transitive_pub_types.get(name)?; (infos.len() == 1).then(|| &infos[0]) } @@ -1467,6 +1545,9 @@ impl TypeChecker { if let Some(info) = self.lookup_trait_info(name) { return Some(info); } + if let Some(info) = self.transitive_stdlib_stub_traits.get(name) { + return Some(info); + } let infos = self.transitive_pub_traits.get(name)?; (infos.len() == 1).then(|| &infos[0]) } @@ -3558,7 +3639,10 @@ impl TypeChecker { /// interfaces such as `session -> dataset -> session`. A predeclaration pass breaks that order sensitivity by /// making type- and trait-like names resolvable before method and function signatures are collected. fn predeclare_dependency_interfaces(&mut self, dependencies: &[(&str, &Program)], public_only: bool) { - for (_, dep_ast) in dependencies { + for (module_name, dep_ast) in dependencies { + if Self::is_generated_stdlib_dependency_module(module_name) { + continue; + } for decl in &dep_ast.declarations { if public_only && !is_public_decl(decl) { continue; @@ -3568,6 +3652,13 @@ impl TypeChecker { } } + fn is_generated_stdlib_dependency_module(module_name: &str) -> bool { + module_name == incan_core::lang::stdlib::INCAN_STD_NAMESPACE + || module_name + .strip_prefix(incan_core::lang::stdlib::INCAN_STD_NAMESPACE) + .is_some_and(|tail| tail.starts_with('_')) + } + /// Seed the symbol table with a minimal placeholder for one dependency declaration. /// /// The subsequent `import_module*` pass overwrites these placeholders with full collected metadata. These shells @@ -3664,6 +3755,7 @@ impl TypeChecker { traits: en.traits.iter().map(|t| t.node.name.clone()).collect(), trait_adoptions: Vec::new(), variants: en.variants.iter().map(|v| v.node.name.clone()).collect(), + variant_fields: HashMap::new(), variant_aliases: en .variant_aliases .iter() @@ -3707,12 +3799,18 @@ impl TypeChecker { self.dependency_module_traits.clear(); self.dependency_trait_rust_derive_paths.clear(); for (name, dep_ast) in dependencies { + if Self::is_generated_stdlib_dependency_module(name) { + continue; + } self.dependency_exports .insert(name.to_string(), exported_symbols(dep_ast)); } self.predeclare_dependency_interfaces(dependencies, true); // First: import all dependencies for (name, dep_ast) in dependencies { + if Self::is_generated_stdlib_dependency_module(name) { + continue; + } self.import_module(dep_ast, name); } @@ -3736,6 +3834,9 @@ impl TypeChecker { self.dependency_trait_rust_derive_paths.clear(); self.predeclare_dependency_interfaces(dependencies, false); for (name, dep_ast) in dependencies { + if Self::is_generated_stdlib_dependency_module(name) { + continue; + } self.import_module_all(dep_ast, name); } self.check_program(program) diff --git a/src/frontend/typechecker/stdlib_loader.rs b/src/frontend/typechecker/stdlib_loader.rs index d273df73e..28f84b66a 100644 --- a/src/frontend/typechecker/stdlib_loader.rs +++ b/src/frontend/typechecker/stdlib_loader.rs @@ -167,7 +167,6 @@ impl StdlibAstCache { } /// List public trait signatures in a stdlib module. - #[cfg(feature = "lsp")] pub fn list_traits(&mut self, module_path: &[String]) -> Vec<(String, TraitInfo)> { self.ensure_loaded(module_path); let key = module_path.join("."); @@ -1070,6 +1069,19 @@ fn extract_type_signatures(program: &ast::Program) -> Vec<(String, TypeInfo)> { let method_overloads = extract_method_overloads_with_rust_imports(&en.methods, &tp_names, &rust_imports, &stdlib_imports); let methods = methods_from_overloads(&method_overloads); + let variant_fields = en + .variants + .iter() + .map(|variant| { + let fields = variant + .node + .fields + .iter() + .map(|field| ast_type_to_resolved_with_rust_imports(&field.node, &tp_names, &rust_imports)) + .collect(); + (variant.node.name.clone(), fields) + }) + .collect(); types.push(( en.name.clone(), TypeInfo::Enum(EnumInfo { @@ -1077,6 +1089,7 @@ fn extract_type_signatures(program: &ast::Program) -> Vec<(String, TypeInfo)> { traits: en.traits.iter().map(|t| t.node.name.clone()).collect(), trait_adoptions: trait_adoption_infos_from_bounds(&en.traits, &tp_names, &stdlib_imports), variants: en.variants.iter().map(|variant| variant.node.name.clone()).collect(), + variant_fields, variant_aliases: en .variant_aliases .iter() diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index 4535c495f..1c571d1ac 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -2629,12 +2629,38 @@ fn test_resolved_param_type_from_builtin_borrowed_displays_preserves_ref_payload checker.resolved_param_type_from_rust_display("&'h [u8]"), ResolvedType::Ref(Box::new(ResolvedType::Bytes)), ); + assert_eq!( + checker.resolved_param_type_from_rust_display("&[demo::ColumnarValue]"), + ResolvedType::Ref(Box::new(ResolvedType::Generic( + "List".to_string(), + vec![ResolvedType::RustPath("demo::ColumnarValue".to_string())] + ))), + ); assert_eq!( checker.resolved_param_type_from_rust_display("&'h mut demo::Thing"), ResolvedType::RefMut(Box::new(ResolvedType::RustPath("demo::Thing".to_string()))), ); } +#[test] +fn test_rust_owner_path_expands_crate_relative_signature_displays() { + let checker = TypeChecker::new(); + assert_eq!( + checker.rust_display_for_owner_path( + "Arc crate::Result + Send + Sync>", + "demo_runtime::create_udf", + ), + "Arc demo_runtime::Result + Send + Sync>", + ); + assert_eq!( + checker.resolved_param_type_from_rust_display_for_owner_path( + "crate::ScalarFunctionImplementation", + "demo_runtime::create_udf", + ), + ResolvedType::RustPath("demo_runtime::ScalarFunctionImplementation".to_string()), + ); +} + #[test] fn test_resolved_param_type_from_structural_borrowed_display_preserves_nested_ref_payload() { let checker = TypeChecker::new(); @@ -3178,7 +3204,7 @@ fn test_rust_inspect_function_signature_preserves_borrowed_rust_path_param() -> return Err(std::io::Error::other("expected rust-inspect function entry").into()); }; assert_eq!( - checker.resolved_function_type_from_rust_sig(&sig, false), + checker.resolved_function_type_from_rust_sig_for_owner_path(&sig, false, "demo::takes_ref"), ResolvedType::Function( vec![CallableParam::positional(ResolvedType::Ref(Box::new( ResolvedType::RustPath("demo::Thing".to_string()) @@ -3232,6 +3258,7 @@ fn test_rust_item_metadata_lookup_reuses_cached_nominal_item_for_instantiated_ru definition_path: Some("demo::SendError".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, fields: Vec::new(), methods: Vec::new(), implemented_traits: Vec::new(), @@ -3324,6 +3351,7 @@ fn test_types_compatible_accepts_rust_alias_definition_without_metadata_lookup() definition_path: Some("incan_stdlib::r#async::channel::Sender".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, fields: Vec::new(), methods: Vec::new(), implemented_traits: Vec::new(), @@ -3361,6 +3389,7 @@ fn test_types_compatible_accepts_rust_path_alias_with_attached_definition_metada definition_path: Some("incan_stdlib::r#async::sync::Semaphore".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, fields: Vec::new(), methods: Vec::new(), implemented_traits: Vec::new(), @@ -3713,6 +3742,7 @@ def render[T](value: Label[T]) -> str: definition_path: Some("std::string::String".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![RustMethodSig { name: "as_str".to_string(), signature: RustFunctionSig { @@ -3768,6 +3798,7 @@ fn seed_async_rust_method_probe_with_options_param( definition_path: Some("demo::SessionContext".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![ RustMethodSig { name: "new".to_string(), @@ -3818,6 +3849,7 @@ fn seed_async_rust_method_probe_with_options_param( definition_path: Some("demo::CsvReadOptions".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![RustMethodSig { name: "new".to_string(), signature: RustFunctionSig { @@ -3987,6 +4019,7 @@ def render(value: Label) -> str: definition_path: Some("std::string::String".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![RustMethodSig { name: "as_str".to_string(), signature: RustFunctionSig { @@ -4071,6 +4104,7 @@ def f(x: Envelope) -> None: definition_path: Some("demo::Envelope".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![RustFieldInfo { @@ -4095,6 +4129,7 @@ def f(x: Envelope) -> None: definition_path: Some("demo::Kind".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![], @@ -4143,6 +4178,7 @@ def f(x: Envelope) -> None: definition_path: Some("demo::Envelope".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![RustFieldInfo { @@ -4167,6 +4203,7 @@ def f(x: Envelope) -> None: definition_path: Some("demo::Kind".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![], @@ -4218,6 +4255,7 @@ def inspect(rel: Rel) -> None: definition_path: Some("demo::Rel".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![RustFieldInfo { @@ -4242,6 +4280,7 @@ def inspect(rel: Rel) -> None: definition_path: Some("demo::rel::RelType".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![], @@ -4265,6 +4304,7 @@ def inspect(rel: Rel) -> None: definition_path: Some("demo::ReadRel".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![RustFieldInfo { @@ -4289,6 +4329,7 @@ def inspect(rel: Rel) -> None: definition_path: Some("demo::read_rel::ReadType".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: Vec::new(), fields: vec![], @@ -8401,6 +8442,7 @@ def f(w: Widget) -> None: definition_path: Some("demo::Widget".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), implemented_traits: vec![RustImplementedTrait { path: "demo::AlphaRender".to_string(), @@ -8478,6 +8520,7 @@ def f(encoded: bytes) -> None: definition_path: Some(path.to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: Vec::new(), implemented_traits: vec![RustImplementedTrait { path: "demo::Message".to_string(), @@ -8622,6 +8665,7 @@ type Thing = rusttype RustThing with Labelled definition_path: Some("demo::RustThing".to_string()), visibility: RustVisibility::Public, kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, methods: vec![], implemented_traits: vec![RustImplementedTrait { path: "demo::Labelled".to_string(), @@ -9765,6 +9809,29 @@ def relation_kind_name_from_conformance(rel: ConformanceRel) -> str: assert!(check_str(source).is_ok()); } +#[test] +fn test_match_qualified_incan_enum_variant_uses_enum_owned_payload_metadata() { + let source = r#" +enum Packet: + Bool(bool) + String(str) + +enum OtherKind(str): + Bool = "bool" + String = "string" + +def packet_name(packet: Packet) -> str: + match packet: + Packet.Bool(flag) => + if flag: + return "true" + return "false" + Packet.String(value) => + return value +"#; + assert!(check_str(source).is_ok()); +} + #[test] fn test_enum_variant_does_not_shadow_existing_same_scope_type_binding() { let source = r#" diff --git a/src/frontend/typechecker/type_info.rs b/src/frontend/typechecker/type_info.rs index 7dd05d86f..f7383571d 100644 --- a/src/frontend/typechecker/type_info.rs +++ b/src/frontend/typechecker/type_info.rs @@ -172,6 +172,12 @@ pub struct RustInteropArtifacts { /// resolved field names so `Range(1, 3)` can emit `Range { start: 1, end: 3 }` instead of an invalid tuple-style /// Rust constructor. pub named_field_constructor_fields: HashMap<(usize, usize), Vec>, + /// Rust closure parameter displays keyed by closure-expression span. + /// + /// This is populated when contextual Rust metadata proves a closure is being used as a Rust callable boundary + /// whose parameter shape cannot be faithfully represented by ordinary Incan surface types, such as `&[T]`. + /// Lowering/emission consumes the displays directly so generated closures keep Rust inference stable. + pub closure_param_type_displays: HashMap<(usize, usize), Vec>, } /// Declaration-level binding rewrites and visibility facts consumed by lowering. @@ -483,6 +489,14 @@ impl TypeCheckInfo { self.expressions.expr_types.get(&(span.start, span.end)) } + /// Return exact Rust parameter displays recorded for a closure expression, if any. + pub fn closure_param_type_displays(&self, span: Span) -> Option<&[String]> { + self.rust + .closure_param_type_displays + .get(&(span.start, span.end)) + .map(Vec::as_slice) + } + /// Return computed-property metadata for a field-access expression, if that access resolved to a property. pub fn computed_property_access(&self, span: Span) -> Option<&ComputedPropertyAccessInfo> { self.expressions.computed_property_accesses.get(&(span.start, span.end)) diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index d5f843879..29e419d8e 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -627,6 +627,7 @@ fn run_accepts_generic_rust_param_scenarios_share_one_generated_project() -> Res r#" [rust-dependencies] +arc_callback = { path = "rust/arc_callback" } borrow_helper = { path = "rust/borrow_helper" } decode_helper = { path = "rust/decode_helper" } decode_trait_helper = { path = "rust/decode_trait_helper" } @@ -637,18 +638,40 @@ reexport_identity = { path = "rust/reexport_identity" } )?; fs::write( &main_path, - r#"from borrowed_generic import borrowed_generic_case + r#"from arc_callback import arc_callback_case +from borrowed_generic import borrowed_generic_case from by_value_decode import by_value_decode_case from cross_crate_decode import cross_crate_decode_case from reexport_identity import reexport_identity_case from trait_by_value_decode import trait_by_value_decode_case def main() -> None: + println(arc_callback_case()) println(borrowed_generic_case()) println(by_value_decode_case()) println(trait_by_value_decode_case()) println(cross_crate_decode_case()) println(reexport_identity_case()) +"#, + )?; + fs::write( + tmp.path().join("src").join("arc_callback.incn"), + r#"from rust::arc_callback import CallbackError, ColumnarValue, SliceCallback, create_udf +from rust::std::sync import Arc + +def callback(args: list[ColumnarValue]) -> Result[ColumnarValue, CallbackError]: + return Ok(args[0].clone()) + +def inline_arc_callback_value() -> int: + match create_udf("inline", Arc.from((args) => callback(args.to_vec()))): + Ok(value) => return value.value() + Err(_) => return -1 + +pub def arc_callback_case() -> str: + implementation: SliceCallback = Arc.from((args) => callback(args.to_vec())) + match create_udf("assigned", implementation): + Ok(value) => return f"arc_callback:{value.value()}:{inline_arc_callback_value()}" + Err(_) => return "arc_callback:err" "#, )?; fs::write( @@ -712,6 +735,53 @@ pub def reexport_identity_case() -> str: "#, )?; + let helper_src = tmp.path().join("rust").join("arc_callback").join("src"); + fs::create_dir_all(&helper_src)?; + fs::write( + helper_src + .parent() + .ok_or("arc_callback src has no parent")? + .join("Cargo.toml"), + r#"[package] +name = "arc_callback" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + helper_src.join("lib.rs"), + r#"use std::sync::Arc; + +#[derive(Clone)] +pub struct ColumnarValue { + value: i64, +} + +impl ColumnarValue { + pub fn new(value: i64) -> Self { + Self { value } + } + + pub fn value(&self) -> i64 { + self.value + } +} + +pub struct CallbackError; + +pub type SliceCallback = Arc Result + Send + Sync>; + +pub fn invoke(callback: SliceCallback) -> Result { + let args = vec![ColumnarValue::new(7)]; + callback(&args) +} + +pub fn create_udf(_name: &str, callback: crate::SliceCallback) -> Result { + let args = vec![ColumnarValue::new(11)]; + callback(&args) +} +"#, + )?; let helper_src = tmp.path().join("rust").join("borrow_helper").join("src"); fs::create_dir_all(&helper_src)?; fs::write( @@ -917,12 +987,175 @@ impl Expr { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), - "borrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok\nreexport_identity:ok", + "arc_callback:11:11\nborrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok\nreexport_identity:ok", "expected batched generic Rust param output, got:\n{stdout}" ); Ok(()) } +#[test] +fn test_runner_prefers_project_sibling_import_over_unimported_stdlib_stub_type() +-> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path(); + fs::write( + project_root.join("incan.toml"), + r#"[project] +name = "stdhash_sibling_collision" +version = "0.1.0" +"#, + )?; + + let src_dir = project_root.join("src"); + let functions_dir = src_dir.join("functions"); + let hashing_dir = functions_dir.join("hashing"); + let session_dir = src_dir.join("session"); + let tests_dir = project_root.join("tests"); + fs::create_dir_all(&hashing_dir)?; + fs::create_dir_all(&session_dir)?; + fs::create_dir_all(&tests_dir)?; + + fs::write( + hashing_dir.join("expr.incn"), + r#"pub model Expr: + pub value: int +"#, + )?; + fs::write( + hashing_dir.join("sha224.incn"), + r#"from functions.hashing.expr import Expr + +pub def sha224(expr: Expr) -> Expr: + return expr +"#, + )?; + fs::write( + hashing_dir.join("sha2.incn"), + r#"from functions.hashing.expr import Expr +from functions.hashing.sha224 import sha224 + +pub def sha2(expr: Expr) -> Expr: + return sha224(expr) +"#, + )?; + fs::write( + functions_dir.join("mod.incn"), + r#"pub from functions.hashing.expr import Expr +pub from functions.hashing.sha224 import sha224 +pub from functions.hashing.sha2 import sha2 +"#, + )?; + fs::write( + session_dir.join("bridge.incn"), + r#"from std.hash import sha1 as std_sha1 + +pub def digest(data: bytes) -> bytes: + return std_sha1.digest(data) +"#, + )?; + fs::write( + session_dir.join("mod.incn"), + r#"pub from session.bridge import digest +"#, + )?; + fs::write( + src_dir.join("lib.incn"), + r#"pub from functions import Expr, sha224, sha2 +pub from session import digest +"#, + )?; + fs::write( + tests_dir.join("test_collision.incn"), + r#"from functions import Expr, sha2 +from session import digest + +def test_collision__sibling_import_wins() -> None: + payload = Expr(value=1) + assert len(digest(b"abc")) > 0 + assert sha2(payload).value == 1 +"#, + )?; + + let output = run_incan(project_root, &["test", "tests"])?; + assert_success( + &output, + "incan test should keep project sibling imports ahead of unimported stdlib stub helper types", + ); + Ok(()) +} + +#[test] +fn test_runner_resolves_imported_stdlib_enum_patterns_from_enum_metadata() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let project_root = tmp.path(); + fs::write( + project_root.join("incan.toml"), + r#"[project] +name = "stdlib_enum_pattern_metadata" +version = "0.1.0" +"#, + )?; + + let src_dir = project_root.join("src"); + let substrait_dir = src_dir.join("substrait"); + let session_dir = src_dir.join("session"); + let tests_dir = project_root.join("tests"); + fs::create_dir_all(&substrait_dir)?; + fs::create_dir_all(&session_dir)?; + fs::create_dir_all(&tests_dir)?; + + fs::write( + substrait_dir.join("schema.incn"), + r#"pub enum PrimitiveKind(str): + Bool = "bool" + String = "string" +"#, + )?; + fs::write( + session_dir.join("json_schema.incn"), + r#"from std.json import JsonKind, JsonValue +from substrait.schema import PrimitiveKind + +pub def primitive_kind() -> PrimitiveKind: + return PrimitiveKind.Bool + +pub def schema_name(value: JsonValue) -> str: + match value.kind(): + JsonKind.Bool => return "BOOLEAN" + JsonKind.String => return "STRING" + _ => return "OTHER" +"#, + )?; + fs::write( + session_dir.join("mod.incn"), + r#"pub from session.json_schema import primitive_kind, schema_name +"#, + )?; + fs::write( + src_dir.join("lib.incn"), + r#"pub from session import primitive_kind, schema_name +"#, + )?; + fs::write( + tests_dir.join("test_json_schema.incn"), + r#"from session import primitive_kind, schema_name +from std.json import JsonValue + +def test_stdlib_enum_patterns_survive_colliding_project_variants() -> None: + assert primitive_kind().value() == "bool" + assert schema_name(JsonValue.bool(True)) == "BOOLEAN" + assert schema_name(JsonValue.string("x")) == "STRING" +"#, + )?; + + let output = run_incan(project_root, &["test", "tests"])?; + assert_success( + &output, + "incan test should resolve imported stdlib enum patterns from enum-owned metadata", + ); + Ok(()) +} + #[test] fn build_locked_rejects_stale_lockfile() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/fixtures/vocab_guardrails/semantic_string_audit.json b/tests/fixtures/vocab_guardrails/semantic_string_audit.json index 401b471e5..f4bec755b 100644 --- a/tests/fixtures/vocab_guardrails/semantic_string_audit.json +++ b/tests/fixtures/vocab_guardrails/semantic_string_audit.json @@ -249,8 +249,8 @@ { "path": "src/frontend/typechecker/check_expr/access.rs", "category": "method/type access surface classification", - "expected_count": 43, - "expected_fingerprint": "0x8ea35141db935e1c" + "expected_count": 45, + "expected_fingerprint": "0x04cd6395cd9125c3" }, { "path": "src/frontend/typechecker/check_expr/basics.rs", diff --git a/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap index 0b4484dc3..a575257b7 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2783 expression: rust_code --- // Generated by the Incan compiler v @@ -556,30 +555,30 @@ impl _UuidBytesWriter { } pub fn u8(&self, value: u8) -> Result<(), UuidError> { "\n Append one byte to the UUID buffer.\n\n Args:\n value: Byte to write.\n\n Returns:\n `Ok(None)` when the write succeeds.\n "; - return self - .out - .write(value, Endian::Big) + return crate::__incan_std::io::BinaryWrite::< + u8, + >::write(&self.out, value, Endian::Big.clone()) .map_err(|err| _io_error(self.operation.clone(), err.clone())); } pub fn u16(&self, value: u16) -> Result<(), UuidError> { "\n Append a 16-bit integer in network byte order.\n\n Args:\n value: Integer to write.\n\n Returns:\n `Ok(None)` when the write succeeds.\n "; - return self - .out - .write(value, Endian::Big) + return crate::__incan_std::io::BinaryWrite::< + u16, + >::write(&self.out, value, Endian::Big.clone()) .map_err(|err| _io_error(self.operation.clone(), err.clone())); } pub fn u32(&self, value: u32) -> Result<(), UuidError> { "\n Append a 32-bit integer in network byte order.\n\n Args:\n value: Integer to write.\n\n Returns:\n `Ok(None)` when the write succeeds.\n "; - return self - .out - .write(value, Endian::Big) + return crate::__incan_std::io::BinaryWrite::< + u32, + >::write(&self.out, value, Endian::Big.clone()) .map_err(|err| _io_error(self.operation.clone(), err.clone())); } pub fn u128(&self, value: u128) -> Result<(), UuidError> { "\n Append a 128-bit integer in network byte order.\n\n Args:\n value: Integer to write.\n\n Returns:\n `Ok(None)` when the write succeeds.\n "; - return self - .out - .write(value, Endian::Big) + return crate::__incan_std::io::BinaryWrite::< + u128, + >::write(&self.out, value, Endian::Big.clone()) .map_err(|err| _io_error(self.operation.clone(), err.clone())); } pub fn clock_seq(&self, clock_seq: i64) -> Result<(), UuidError> { @@ -932,14 +931,18 @@ fn _hex_byte(value: u8) -> String { fn _read_u128_from_bytes(raw: Vec) -> Result { "\n Read sixteen network-order bytes into a `u128`.\n\n Args:\n raw: Bytes to read.\n\n Returns:\n The decoded integer, or `Err(IoError)` when the input is too short.\n "; let reader = crate::__incan_std::io::BytesIO(raw); - let value: u128 = reader.read(Endian::Big)?; + let value: u128 = crate::__incan_std::io::BinaryRead::< + u128, + >::read(&reader, Endian::Big.clone())?; return Ok::(value); } fn _byte_at(raw: Vec, index: i64) -> Result { "\n Read one byte from a byte buffer.\n\n Args:\n raw: Source bytes.\n index: Byte offset to read.\n\n Returns:\n The selected byte, or `Err(IoError)` when the offset is outside the buffer.\n "; let reader = crate::__incan_std::io::BytesIO(raw); reader.seek(index)?; - let value: u8 = reader.read(Endian::Big)?; + let value: u8 = crate::__incan_std::io::BinaryRead::< + u8, + >::read(&reader, Endian::Big.clone())?; return Ok::(value); } fn _byte_at_uuid(raw: Vec, index: i64, operation: String) -> Result { diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 4094d0ec7..0e7c74b81 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -89,12 +89,14 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Release smoke paths are less fragile**: InQL and release smoke testing fixed loop-item fields, union call arguments, storage-rooted match scrutinees, static list index assignment, typed `assert false` lowering, and const model metadata constructors (#620, #621, #622, #627, #630, #644, #671, #674). - **Generic and trait flow keeps more type information**: Instantiated receivers, generic fields, generic methods, `list[Self]`, trait/supertrait upcasts, imported prost oneofs, explicit generic cycles, and static factory locals now survive typechecking and lowering more consistently (#237, #231, #253, #230, #184, #218, #279, #252, #255). - **Runtime-boundary errors are clearer**: `std.regex` text borrowing, collection f-string formatting, and collection/string conversion diagnostics fail closer to the Incan source instead of surfacing as obscure Rust/runtime errors (#624, #625, #71, #81). +- **Enum patterns use enum-owned metadata**: Qualified enum patterns now read variant payloads from the enum's semantic metadata instead of ambient variant symbols, keeping imported stdlib enums stable when project enums reuse variant names (#710). - **Stringly compiler behavior is guarded**: Remaining semantic string comparisons in high-risk compiler paths are fingerprinted so new string-based behavior has to be centralized or explicitly classified. ### Rust Interop And Generated Rust - **Borrowing decisions are metadata-backed**: Borrowed `str`/bytes calls, method fallback borrowing, and retained generated imports now follow the same decisions across typechecking, lowering, and emission. - **Rust bridge identity is preserved**: Inspected methods with unknown generic or lifetime placeholders and re-exported Rust argument displays keep stable bridge identity, including nested generic wrappers such as `Arc` (#645, #630, #705). +- **Rust callable aliases can accept Incan closures**: Rust `type` aliases such as `Arc Result + Send + Sync>` now preserve their alias target metadata, contextually type Incan closure parameters, and emit the required Rust closure parameter annotations without pulling heavyweight downstream crates into compiler regression tests (#708). - **Collection adaptation is less manual**: Owned Incan values can flow to shared borrowed generic Rust parameters, and `list[T]` can adapt to `Vec` where metadata proves the boundary (#506, #128). - **Protobuf-style APIs need fewer workarounds**: Prost-style inherent and trait-provided `decode(buf: T)` calls lower correctly (#609, #612). - **Generated Rust pruning is safer**: Enum-pattern imports and metadata-derived extension-trait imports survive pruning, while unused generated Rust is pruned without broad `allow` attributes (#459, #447, #214). @@ -110,6 +112,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Decorator metadata crosses package boundaries**: Source signatures, imported/decorator `const str` arguments, generic decorator factories, method-call decorator factories, and reexport-only facade projections are represented in checked metadata more reliably (#636, #638, #640, #669, #694, #695). - **Decorator helpers can inspect generic callables**: `func.__name__` works in generic `(F) -> F` decorator helpers, including imported alias and union callable signatures, so registry decorators can infer the decorated helper name instead of repeating it as a string (#694, #701). - **Decorated wrappers preserve defaults**: Decorated functions and methods keep source default-argument call behavior when the final decorated callable surface still matches the original declaration, including direct imports and public facade re-exports (#703). +- **Stdlib implementation modules stay internal**: Generated stdlib source dependencies no longer leak unimported helper classes into project modules, so explicit sibling imports keep precedence over unrelated `std.*` imports (#710). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). ### Formatter And Test Runner From f56c8b16880197a4fd08aeab1d2f7ac3903fe017 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Fri, 29 May 2026 11:26:29 +0200 Subject: [PATCH 47/58] bugfix - support generic reflection calls (#712) (#713) --- Cargo.lock | 18 +- Cargo.toml | 2 +- crates/incan_core/src/lang/derives.rs | 6 + crates/incan_core/src/lang/trait_bounds.rs | 4 + crates/incan_stdlib/README.md | 7 +- crates/incan_stdlib/src/lib.rs | 2 +- crates/incan_stdlib/src/prelude.rs | 2 +- crates/incan_stdlib/src/reflection.rs | 17 +- src/backend/ir/emit/decls/impls.rs | 14 +- src/backend/ir/emit/decls/structures.rs | 58 +- src/backend/ir/lower/decl/classes.rs | 12 +- src/backend/ir/lower/decl/models.rs | 12 +- src/backend/ir/lower/mod.rs | 33 +- src/backend/ir/trait_bound_inference.rs | 21 +- src/frontend/typechecker/check_expr/access.rs | 40 ++ src/frontend/typechecker/tests.rs | 39 ++ tests/cli_integration.rs | 73 +++ .../semantic_string_audit.json | 4 +- .../codegen_snapshot_tests__classes.snap | 221 +++++++- ...hot_tests__constructor_field_defaults.snap | 41 ++ ...ot_tests__explicit_call_site_generics.snap | 13 + ...gen_snapshot_tests__fixed_call_unpack.snap | 13 + ...degen_snapshot_tests__generic_methods.snap | 72 +++ ...hot_tests__generic_model_field_access.snap | 23 + ...ssue241_field_backed_method_arg_clone.snap | 36 ++ ...ests__issue246_class_field_visibility.snap | 32 ++ ...s__issue364_filtered_list_comp_borrow.snap | 32 ++ ...sts__issue366_clone_self_string_field.snap | 32 ++ ...apshot_tests__issue380_len_comparison.snap | 41 ++ ...__issue389_for_tuple_unpack_enumerate.snap | 41 ++ ...e483_list_comp_tuple_unpack_enumerate.snap | 41 ++ ...egen_snapshot_tests__list_clone_model.snap | 23 + ...shot_tests__list_pop_clone_only_model.snap | 23 + .../codegen_snapshot_tests__model_struct.snap | 32 ++ .../codegen_snapshot_tests__models.snap | 319 +++++++++++ ...shot_tests__newtype_implicit_coercion.snap | 24 +- ...shot_tests__rfc024_module_derive_json.snap | 23 + ...ot_tests__rfc024_partial_alias_derive.snap | 24 +- ...tests__rfc043_rust_derive_passthrough.snap | 24 +- ...hot_tests__rfc046_computed_properties.snap | 23 + .../codegen_snapshot_tests__rust_allow.snap | 46 ++ ...hot_tests__std_async_channel_compiled.snap | 37 +- ...apshot_tests__std_async_sync_compiled.snap | 14 +- ...apshot_tests__std_async_time_compiled.snap | 46 +- ...ests__std_derives_collection_compiled.snap | 522 +++++++++++++++++- ...en_snapshot_tests__std_graph_compiled.snap | 211 ++++++- ...snapshot_tests__std_serde_json_import.snap | 41 ++ ...tests__std_serde_with_serialize_trait.snap | 24 +- ...pshot_tests__std_traits_convert_usage.snap | 47 +- ...gen_snapshot_tests__std_uuid_compiled.snap | 87 +++ ...tests__trait_supertrait_assignability.snap | 69 +++ ...gen_snapshot_tests__trait_supertraits.snap | 23 + .../codegen_snapshot_tests__traits.snap | 174 ++++++ ...hot_tests__uppercase_var_field_access.snap | 23 + ...tests__user_defined_method_decorators.snap | 24 +- ...ser_defined_mutable_method_decorators.snap | 24 +- ...napshot_tests__user_defined_operators.snap | 92 +++ ...odegen_snapshot_tests__variadic_calls.snap | 13 + ...ot_tests__std_compression_core_source.snap | 50 ++ ...ed_rust_snapshot_tests__std_io_source.snap | 84 +++ ...shot_tests__std_telemetry_core_source.snap | 245 ++++++++ ...napshot_tests__std_web_prelude_import.snap | 23 + .../docs/language/reference/reflection.md | 9 + .../docs-site/docs/release_notes/0_3.md | 2 + 64 files changed, 3378 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76cffb061..1eee6c373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc24" +version = "0.3.0-rc25" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 02a21e4d4..5ea3cd155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc24" +version = "0.3.0-rc25" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/lang/derives.rs b/crates/incan_core/src/lang/derives.rs index 2f0b0b00c..6f66a652d 100644 --- a/crates/incan_core/src/lang/derives.rs +++ b/crates/incan_core/src/lang/derives.rs @@ -33,6 +33,12 @@ pub enum DeriveId { Validate, } +/// Compiler-generated derive name that emits model/class field metadata. +pub const FIELD_INFO_DERIVE_NAME: &str = "FieldInfo"; + +/// Compiler-generated derive name that emits model/class class-name metadata. +pub const INCAN_CLASS_DERIVE_NAME: &str = "IncanClass"; + /// Metadata for a builtin derive. pub type DeriveInfo = LangItemInfo; diff --git a/crates/incan_core/src/lang/trait_bounds.rs b/crates/incan_core/src/lang/trait_bounds.rs index 763bb80fc..ca6737f70 100644 --- a/crates/incan_core/src/lang/trait_bounds.rs +++ b/crates/incan_core/src/lang/trait_bounds.rs @@ -138,6 +138,10 @@ pub mod rust { // Async pub const FUTURE: &str = "std::future::Future"; + + // Compiler-provided Incan reflection capabilities + pub const INCAN_CLASS_NAME: &str = "incan_stdlib::reflection::HasClassName"; + pub const INCAN_FIELD_METADATA: &str = "incan_stdlib::reflection::HasFieldMetadata"; } /// Look up the Rust trait path for an Incan trait bound name. diff --git a/crates/incan_stdlib/README.md b/crates/incan_stdlib/README.md index f9bda648c..4d8744608 100644 --- a/crates/incan_stdlib/README.md +++ b/crates/incan_stdlib/README.md @@ -16,7 +16,7 @@ The user-facing contract is the Incan `std.*` surface declared by the stdlib stu #### `HasFieldInfo` - Reflection Support -Provides compile-time reflection for Incan models and classes: +Provides compile-time field-name and field-type reflection for Incan models and classes: ```rust pub trait HasFieldInfo { @@ -27,6 +27,10 @@ pub trait HasFieldInfo { **Used by**: All Incan models and classes automatically implement this via `#[derive(FieldInfo)]` +#### `HasFieldMetadata` / `HasClassName` - Generic Reflection Support + +Generated Incan models and classes also implement value-level reflection traits used by compiler-inferred generic bounds. These traits back generic Incan calls such as `value.__fields__()` and `value.__class_name__()` without changing the concrete reflection helpers emitted on each model or class. + #### `ToJson` / `FromJson` - Serialization Helpers Convenient wrappers around `serde_json` for types with `Serialize`/`Deserialize`: @@ -55,6 +59,7 @@ use incan_stdlib::prelude::*; This brings in: - `HasFieldInfo` trait +- `HasFieldMetadata` and `HasClassName` traits - `FieldInfo` record type - `ToJson` / `FromJson` traits (when `json` feature enabled) diff --git a/crates/incan_stdlib/src/lib.rs b/crates/incan_stdlib/src/lib.rs index 5b294ffef..7b0fa65c6 100644 --- a/crates/incan_stdlib/src/lib.rs +++ b/crates/incan_stdlib/src/lib.rs @@ -51,7 +51,7 @@ pub mod __private { pub mod web; // Re-export commonly used items -pub use reflection::{FieldInfo, HasFieldInfo}; +pub use reflection::{FieldInfo, HasClassName, HasFieldInfo, HasFieldMetadata}; #[cfg(feature = "json")] pub use json::{FromJson, ToJson}; diff --git a/crates/incan_stdlib/src/prelude.rs b/crates/incan_stdlib/src/prelude.rs index c402714fa..679303369 100644 --- a/crates/incan_stdlib/src/prelude.rs +++ b/crates/incan_stdlib/src/prelude.rs @@ -7,7 +7,7 @@ //! ``` // Re-export runtime traits and helpers -pub use crate::reflection::{FieldInfo, HasFieldInfo}; +pub use crate::reflection::{FieldInfo, HasClassName, HasFieldInfo, HasFieldMetadata}; // frozen runtime types for consts (RFC 008) pub use crate::frozen::{FrozenBytes, FrozenDict, FrozenList, FrozenSet, FrozenStr}; // Python-like numeric operations (generic entrypoints + compatibility helpers) diff --git a/crates/incan_stdlib/src/reflection.rs b/crates/incan_stdlib/src/reflection.rs index de5984630..dd1064430 100644 --- a/crates/incan_stdlib/src/reflection.rs +++ b/crates/incan_stdlib/src/reflection.rs @@ -3,7 +3,7 @@ //! The `HasFieldInfo` trait provides introspection capabilities for structured types, //! allowing generated code to query field names and types at runtime. -use crate::frozen::{FrozenDict, FrozenStr}; +use crate::frozen::{FrozenDict, FrozenList, FrozenStr}; /// Provides reflection information about a type's fields. /// @@ -31,6 +31,21 @@ pub trait HasFieldInfo { fn field_types() -> Vec<&'static str>; } +/// Provides the rich field metadata returned by Incan's value-level `__fields__()` helper. +/// +/// The compiler implements this trait for generated models and classes so generic Incan code can use +/// `value.__fields__()` through an inferred Rust capability bound without changing the concrete reflection result. +pub trait HasFieldMetadata { + /// Returns field metadata for this value's type. + fn __fields__(&self) -> FrozenList; +} + +/// Provides the value-level `__class_name__()` reflection helper for generated models and classes. +pub trait HasClassName { + /// Returns this value's Incan class/model name. + fn __class_name__(&self) -> &'static str; +} + /// Runtime value type for field reflection (RFC 021). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FieldInfo { diff --git a/src/backend/ir/emit/decls/impls.rs b/src/backend/ir/emit/decls/impls.rs index 08a262abe..be8ad9970 100644 --- a/src/backend/ir/emit/decls/impls.rs +++ b/src/backend/ir/emit/decls/impls.rs @@ -312,8 +312,10 @@ impl<'a> IrEmitter<'a> { })) } - /// Emit the generated `__fields__` reflection method for a struct when field metadata is available. - fn emit_fields_method(&self, struct_name: &str) -> Result, EmitError> { + pub(in crate::backend::ir::emit) fn reflection_field_info_entries( + &self, + struct_name: &str, + ) -> Result)>, EmitError> { let Some(field_names) = self.struct_field_names.get(struct_name) else { return Ok(None); }; @@ -357,6 +359,14 @@ impl<'a> IrEmitter<'a> { } let field_count = Literal::usize_unsuffixed(field_infos.len()); + Ok(Some((field_count, field_infos))) + } + + /// Emit the generated `__fields__` reflection method for a struct when field metadata is available. + fn emit_fields_method(&self, struct_name: &str) -> Result, EmitError> { + let Some((field_count, field_infos)) = self.reflection_field_info_entries(struct_name)? else { + return Ok(None); + }; Ok(Some(quote! { /// Returns field metadata for this type. pub fn __fields__(&self) -> incan_stdlib::frozen::FrozenList { diff --git a/src/backend/ir/emit/decls/structures.rs b/src/backend/ir/emit/decls/structures.rs index bb4871992..a0774e5b2 100644 --- a/src/backend/ir/emit/decls/structures.rs +++ b/src/backend/ir/emit/decls/structures.rs @@ -38,8 +38,8 @@ impl<'a> IrEmitter<'a> { // `Validate` is an Incan semantic derive (not a Rust derive macro). .filter(|d| derives::from_str(d.as_str()) != Some(DeriveId::Validate)) .map(|d| match derives::from_str(d.as_str()) { - _ if d == "FieldInfo" => quote! { incan_derive::FieldInfo }, - _ if d == "IncanClass" => quote! { incan_derive::IncanClass }, + _ if d == derives::FIELD_INFO_DERIVE_NAME => quote! { incan_derive::FieldInfo }, + _ if d == derives::INCAN_CLASS_DERIVE_NAME => quote! { incan_derive::IncanClass }, _ if d.contains("::") => { let segs: Vec = d.split("::").map(Self::rust_ident).map(|id| quote! { #id }).collect(); super::join_path_tokens(&segs) @@ -80,6 +80,7 @@ impl<'a> IrEmitter<'a> { // RFC 023: emit generic type parameters with trait bounds (declaration) and bare names (type positions). let generics = self.emit_type_params(&s.type_params); let generics_bare = self.emit_type_params_bare(&s.type_params); + let reflection_impls = self.emit_struct_reflection_trait_impls(s)?; if is_tuple_struct { let tuple_fields: Vec = s @@ -107,6 +108,7 @@ impl<'a> IrEmitter<'a> { Ok(quote! { #struct_def #constructor_impl + #reflection_impls }) } else { let fields: Vec = s @@ -168,10 +170,58 @@ impl<'a> IrEmitter<'a> { } #constructor + #reflection_impls }) } } + /// Emit the Rust traits that make compiler-provided reflection available through generic bounds. + fn emit_struct_reflection_trait_impls(&self, s: &IrStruct) -> Result { + let name = Self::rust_ident(&s.name); + let generics = self.emit_type_params(&s.type_params); + let generics_bare = self.emit_type_params_bare(&s.type_params); + let has_class_name = s + .derives + .iter() + .any(|derive| derive == derives::INCAN_CLASS_DERIVE_NAME); + let has_field_metadata = s.derives.iter().any(|derive| derive == derives::FIELD_INFO_DERIVE_NAME); + + let class_name_impl = if has_class_name { + let class_name = s.name.as_str(); + quote! { + impl #generics incan_stdlib::reflection::HasClassName for #name #generics_bare { + fn __class_name__(&self) -> &'static str { + #class_name + } + } + } + } else { + quote! {} + }; + + let field_metadata_impl = if has_field_metadata { + if let Some((field_count, field_infos)) = self.reflection_field_info_entries(&s.name)? { + quote! { + impl #generics incan_stdlib::reflection::HasFieldMetadata for #name #generics_bare { + fn __fields__(&self) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; #field_count] = [#(#field_infos),*]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } + } + } + } else { + quote! {} + } + } else { + quote! {} + }; + + Ok(quote! { + #class_name_impl + #field_metadata_impl + }) + } + /// Emit a Rust enum definition plus shared and value-enum-specific helper implementations. pub(in crate::backend::ir::emit) fn emit_enum(&self, e: &IrEnum) -> Result { let name = format_ident!("{}", &e.name); @@ -216,8 +266,8 @@ impl<'a> IrEmitter<'a> { && derives::from_str(d.as_str()) != Some(DeriveId::Display) }) .map(|d| match derives::from_str(d.as_str()) { - _ if d == "FieldInfo" => quote! { incan_derive::FieldInfo }, - _ if d == "IncanClass" => quote! { incan_derive::IncanClass }, + _ if d == derives::FIELD_INFO_DERIVE_NAME => quote! { incan_derive::FieldInfo }, + _ if d == derives::INCAN_CLASS_DERIVE_NAME => quote! { incan_derive::IncanClass }, _ if d.contains("::") => { let segs: Vec = d.split("::").map(Self::rust_ident).map(|id| quote! { #id }).collect(); super::join_path_tokens(&segs) diff --git a/src/backend/ir/lower/decl/classes.rs b/src/backend/ir/lower/decl/classes.rs index b217ca5db..2edb6547b 100644 --- a/src/backend/ir/lower/decl/classes.rs +++ b/src/backend/ir/lower/decl/classes.rs @@ -52,13 +52,13 @@ impl AstLowering { if !derives.iter().any(|d| d == clone) { derives.push(clone.to_string()); } - // Classes always get FieldInfo for reflection - if !derives.contains(&"FieldInfo".to_string()) { - derives.push("FieldInfo".to_string()); + // Classes always get FieldInfo for reflection. + if !derives.iter().any(|d| d == derives::FIELD_INFO_DERIVE_NAME) { + derives.push(derives::FIELD_INFO_DERIVE_NAME.to_string()); } - // Classes always get IncanClass for __class__() and __fields__() methods - if !derives.contains(&"IncanClass".to_string()) { - derives.push("IncanClass".to_string()); + // Classes always get IncanClass for __class_name__() and __fields__() methods. + if !derives.iter().any(|d| d == derives::INCAN_CLASS_DERIVE_NAME) { + derives.push(derives::INCAN_CLASS_DERIVE_NAME.to_string()); } Ok(IrStruct { diff --git a/src/backend/ir/lower/decl/models.rs b/src/backend/ir/lower/decl/models.rs index 3389811ba..39f7b5bc6 100644 --- a/src/backend/ir/lower/decl/models.rs +++ b/src/backend/ir/lower/decl/models.rs @@ -43,13 +43,13 @@ impl AstLowering { if !derives.iter().any(|d| d == clone) { derives.push(clone.to_string()); } - // Models always get FieldInfo for reflection - if !derives.contains(&"FieldInfo".to_string()) { - derives.push("FieldInfo".to_string()); + // Models always get FieldInfo for reflection. + if !derives.iter().any(|d| d == derives::FIELD_INFO_DERIVE_NAME) { + derives.push(derives::FIELD_INFO_DERIVE_NAME.to_string()); } - // Models always get IncanClass for __class__() and __fields__() methods - if !derives.contains(&"IncanClass".to_string()) { - derives.push("IncanClass".to_string()); + // Models always get IncanClass for __class_name__() and __fields__() methods. + if !derives.iter().any(|d| d == derives::INCAN_CLASS_DERIVE_NAME) { + derives.push(derives::INCAN_CLASS_DERIVE_NAME.to_string()); } Ok(IrStruct { diff --git a/src/backend/ir/lower/mod.rs b/src/backend/ir/lower/mod.rs index 698f67a4b..55763f011 100644 --- a/src/backend/ir/lower/mod.rs +++ b/src/backend/ir/lower/mod.rs @@ -1395,24 +1395,23 @@ impl AstLowering { errors.push(e); } - // Generate impl block for all methods/properties (inherited + own) - if !all_methods.is_empty() || !all_properties.is_empty() { - match self.lower_decorated_method_statics(&struct_ir.name, &all_methods) { - Ok(statics) => ir_program.declarations.extend(statics), - Err(e) => errors.push(e), - } - match self.lower_class_methods( - &struct_ir.name, - &c.type_params, - &all_methods, - &all_properties, - &c.traits, - ) { - Ok(impl_ir) => { - ir_program.declarations.push(IrDecl::new(IrDeclKind::Impl(impl_ir))); - } - Err(e) => errors.push(e), + // Generate an impl block even for field-only classes so compiler-provided reflection + // helpers have the same concrete surface as models. + match self.lower_decorated_method_statics(&struct_ir.name, &all_methods) { + Ok(statics) => ir_program.declarations.extend(statics), + Err(e) => errors.push(e), + } + match self.lower_class_methods( + &struct_ir.name, + &c.type_params, + &all_methods, + &all_properties, + &c.traits, + ) { + Ok(impl_ir) => { + ir_program.declarations.push(IrDecl::new(IrDeclKind::Impl(impl_ir))); } + Err(e) => errors.push(e), } // Generate trait impls for each trait this class implements diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index 6c5b0d585..124f8af94 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -27,7 +27,7 @@ use std::collections::{HashMap, HashSet}; -use incan_core::lang::trait_bounds::rust as tb; +use incan_core::lang::{magic_methods, trait_bounds::rust as tb}; use super::IrProgram; use super::decl::{FunctionParam, IrDeclKind, IrFunction, IrTraitBound, IrTypeParam}; @@ -1817,6 +1817,14 @@ fn scan_stmt_for_bounds( } } +fn reflection_magic_trait_bound(method: &str) -> Option<&'static str> { + match magic_methods::from_str(method) { + Some(magic_methods::MagicMethodId::ClassName) => Some(tb::INCAN_CLASS_NAME), + Some(magic_methods::MagicMethodId::Fields) => Some(tb::INCAN_FIELD_METADATA), + _ => None, + } +} + /// Scan an expression for trait-bound-relevant operations on type parameters. fn scan_expr_for_bounds( expr: &IrExpr, @@ -1868,10 +1876,13 @@ fn scan_expr_for_bounds( IrExprKind::MethodCall { receiver, method, args, .. } => { - if method == "clone" - && let Some(tp_name) = expr_type_param_name(receiver, type_params, params) - { - add_bound(bounds_map, &tp_name, IrTraitBound::simple(tb::CLONE)); + if let Some(tp_name) = expr_type_param_name(receiver, type_params, params) { + if method == "clone" { + add_bound(bounds_map, &tp_name, IrTraitBound::simple(tb::CLONE)); + } + if let Some(bound) = reflection_magic_trait_bound(method) { + add_bound(bounds_map, &tp_name, IrTraitBound::simple(bound)); + } } else if method == "clone" && matches!(receiver.ty, IrType::Unknown) && matches!(&receiver.kind, IrExprKind::Var { .. } | IrExprKind::Field { .. }) diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index b0058c87a..227c24ccd 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -1260,6 +1260,41 @@ impl TypeChecker { } } + /// Return the receiver-independent reflection result type available through an inferred generic capability. + fn generic_reflection_magic_method_return_type(&self, method: &str) -> Option { + match magic_methods::from_str(method) { + Some(magic_methods::MagicMethodId::ClassName) => Some(ResolvedType::Str), + Some(magic_methods::MagicMethodId::Fields) => Some(ResolvedType::FrozenList(Box::new( + ResolvedType::Named(surface_types::as_str(SurfaceTypeId::FieldInfo).to_string()), + ))), + _ => None, + } + } + + fn validate_reflection_magic_call( + &mut self, + method: &str, + type_args: &[Spanned], + args: &[CallArg], + span: Span, + ) { + if !type_args.is_empty() { + self.errors + .push(errors::explicit_call_site_type_args_not_supported(span)); + } + let expected_arity = match magic_methods::from_str(method) { + Some(magic_methods::MagicMethodId::ClassName) + | Some(magic_methods::MagicMethodId::Fields) + | Some(magic_methods::MagicMethodId::FieldItems) => 0, + Some(magic_methods::MagicMethodId::FieldValue) => 1, + _ => return, + }; + if args.len() != expected_arity { + self.errors + .push(errors::builtin_arity(method, expected_arity, args.len(), span)); + } + } + /// Report whether a nominal type is allowed to use a given reflection magic method. /// /// Support is intentionally method-specific: `__class_name__()` is limited to models and classes, while @@ -3008,6 +3043,7 @@ impl TypeChecker { if self.nominal_type_supports_reflection_magic(&base_ty, method) && let Some(ret) = self.reflection_magic_method_return_type(&base_ty, method) { + self.validate_reflection_magic_call(method, type_args, args, span); return ret; } @@ -3602,6 +3638,10 @@ impl TypeChecker { { return ret; } + if let Some(ret) = self.generic_reflection_magic_method_return_type(method) { + self.validate_reflection_magic_call(method, type_args, args, span); + return ret; + } return base_ty.clone(); } diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index 1c571d1ac..f26020c1c 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -4923,6 +4923,45 @@ def describe(u: User) -> None: Ok(()) } +#[test] +fn test_generic_reflection_magic_methods_record_surface_types() -> Result<(), Box> { + let source = r#" +def reflected_field_count[T](value: T) -> int: + fields = value.__fields__() + return len(fields) + +def reflected_class_name[T](value: T) -> str: + return value.__class_name__() +"#; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; + let mut checker = TypeChecker::new(); + checker + .check_program(&ast) + .map_err(|errs| std::io::Error::other(format!("check_program failed: {errs:?}")))?; + let info = checker.type_info(); + assert!( + info.expressions + .expr_types + .values() + .any(|ty| matches!(ty, ResolvedType::Str)), + "expected generic __class_name__() to resolve to str, got {:?}", + info.expressions.expr_types + ); + assert!( + info.expressions.expr_types.values().any(|ty| { + matches!( + ty, + ResolvedType::FrozenList(inner) + if matches!(inner.as_ref(), ResolvedType::Named(name) if name == "FieldInfo") + ) + }), + "expected generic __fields__() to resolve to FrozenList[FieldInfo], got {:?}", + info.expressions.expr_types + ); + Ok(()) +} + #[test] fn test_reflection_fieldinfo_members_typecheck_without_explicit_import() -> Result<(), Box> { let source = r#" diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 29e419d8e..78f97994f 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1858,6 +1858,79 @@ fn fmt_tuple_target_list_comprehension_remains_buildable() -> Result<(), Box Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "generic_reflection_issue712", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + fs::write( + src_dir.join("generic_reflection_helpers.incn"), + r#"pub def imported_field_count[T](value: T) -> int: + return len(value.__fields__()) + + +pub def imported_class_name[T](value: T) -> str: + return str(value.__class_name__()) +"#, + )?; + fs::write( + &main_path, + r#"from generic_reflection_helpers import imported_class_name, imported_field_count + + +model Row: + name: str + + +class Bare: + value: int + + +def reflected_field_count[T](value: T) -> int: + return len(value.__fields__()) + + +def reflected_class_name[T](value: T) -> str: + return str(value.__class_name__()) + + +def main() -> None: + row = Row(name="Ada") + println(reflected_class_name(row)) + println(reflected_field_count(row)) + println(imported_class_name(row)) + println(imported_field_count(row)) + bare = Bare(value=1) + println(bare.__class_name__()) + println(len(bare.__fields__())) + println(reflected_class_name(bare)) + println(reflected_field_count(bare)) + println(imported_class_name(bare)) + println(imported_field_count(bare)) +"#, + )?; + + let check_output = run_incan( + tmp.path(), + &["--check", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&check_output, "incan --check for generic reflection issue712"); + + let run_output = run_incan( + tmp.path(), + &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&run_output, "incan run for generic reflection issue712"); + let stdout = String::from_utf8_lossy(&run_output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!( + lines, + vec!["Row", "1", "Row", "1", "Bare", "1", "Bare", "1", "Bare", "1"], + "unexpected generic reflection output:\n{stdout}" + ); + Ok(()) +} + #[test] fn build_public_alias_of_imported_item_reexports_original_path_issue617() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/fixtures/vocab_guardrails/semantic_string_audit.json b/tests/fixtures/vocab_guardrails/semantic_string_audit.json index f4bec755b..b1a1ce389 100644 --- a/tests/fixtures/vocab_guardrails/semantic_string_audit.json +++ b/tests/fixtures/vocab_guardrails/semantic_string_audit.json @@ -218,9 +218,9 @@ }, { "path": "src/backend/ir/trait_bound_inference.rs", - "category": "clone/as_ref/self trait-bound inference compatibility", + "category": "clone/as_ref/self and reflection trait-bound inference compatibility", "expected_count": 4, - "expected_fingerprint": "0x7246e6844a35b5b3" + "expected_fingerprint": "0xf6bac5fbb4750df0" }, { "path": "src/cli/commands/common.rs", diff --git a/tests/snapshots/codegen_snapshot_tests__classes.snap b/tests/snapshots/codegen_snapshot_tests__classes.snap index cce461ab3..74996106a 100644 --- a/tests/snapshots/codegen_snapshot_tests__classes.snap +++ b/tests/snapshots/codegen_snapshot_tests__classes.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 992 expression: rust_code --- // Generated by the Incan compiler v @@ -13,15 +12,102 @@ struct Point { pub x: i64, pub y: i64, } +impl incan_stdlib::reflection::HasClassName for Point { + fn __class_name__(&self) -> &'static str { + "Point" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Point { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("x"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("x"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("y"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("y"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Rectangle { pub width: i64, pub height: i64, } +impl incan_stdlib::reflection::HasClassName for Rectangle { + fn __class_name__(&self) -> &'static str { + "Rectangle" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Rectangle { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("width"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("width"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("height"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("height"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Circle { radius: f64, } +impl incan_stdlib::reflection::HasClassName for Circle { + fn __class_name__(&self) -> &'static str { + "Circle" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Circle { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("radius"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("radius"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Circle { pub fn area(&self) -> f64 { return 3.14159f64 * self.radius * self.radius; @@ -34,6 +120,29 @@ impl Circle { struct Counter { count: i64, } +impl incan_stdlib::reflection::HasClassName for Counter { + fn __class_name__(&self) -> &'static str { + "Counter" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Counter { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("count"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("count"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Counter { pub fn increment(&mut self) { self.count = self.count + 1; @@ -49,6 +158,29 @@ impl Counter { struct Stack { items: Vec, } +impl incan_stdlib::reflection::HasClassName for Stack { + fn __class_name__(&self) -> &'static str { + "Stack" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Stack { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("items"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("items"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[int]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Stack { pub fn push(&mut self, item: i64) { self.items.push(item); @@ -68,6 +200,29 @@ struct Calculator { #[expect(dead_code, reason = "retained for Incan private field semantics")] name: String, } +impl incan_stdlib::reflection::HasClassName for Calculator { + fn __class_name__(&self) -> &'static str { + "Calculator" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Calculator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Calculator { pub fn add(&self, a: i64, b: i64) -> i64 { return a + b; @@ -79,6 +234,38 @@ struct Person { #[expect(dead_code, reason = "retained for Incan private field semantics")] age: i64, } +impl incan_stdlib::reflection::HasClassName for Person { + fn __class_name__(&self) -> &'static str { + "Person" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Person { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("age"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("age"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Person { pub fn greet(&self) -> String { return { @@ -93,6 +280,38 @@ struct Employee { person: Person, employee_id: i64, } +impl incan_stdlib::reflection::HasClassName for Employee { + fn __class_name__(&self) -> &'static str { + "Employee" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Employee { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("person"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("person"), + type_name: incan_stdlib::frozen::FrozenStr::new("Person"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("employee_id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("employee_id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Employee { pub fn get_info(&self) -> String { let greeting: String = self.person.greet(); diff --git a/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap b/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap index 3a96a43b9..bb437dcb1 100644 --- a/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap +++ b/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap @@ -14,6 +14,47 @@ struct Settings { #[expect(dead_code, reason = "retained for Incan private field semantics")] auto_save: bool, } +impl incan_stdlib::reflection::HasClassName for Settings { + fn __class_name__(&self) -> &'static str { + "Settings" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Settings { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("theme"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("theme"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("font_size"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("font_size"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("auto_save"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("auto_save"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn main() { std::panic::set_hook( std::boxed::Box::new(|panic_info| { diff --git a/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap b/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap index 9d452cb17..acb58a1f1 100644 --- a/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap +++ b/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap @@ -12,6 +12,19 @@ fn id(x: T) -> T { } #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Boxed {} +impl incan_stdlib::reflection::HasClassName for Boxed { + fn __class_name__(&self) -> &'static str { + "Boxed" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Boxed { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Boxed { pub fn pick(&self, value: T) -> T { return value; diff --git a/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap b/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap index 283364303..6f0483319 100644 --- a/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap +++ b/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap @@ -27,6 +27,19 @@ fn route(path: String, method: String) -> String { } #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Counter {} +impl incan_stdlib::reflection::HasClassName for Counter { + fn __class_name__(&self) -> &'static str { + "Counter" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Counter { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Counter { pub fn add(&self, left: i64, right: i64) -> i64 { return left + right; diff --git a/tests/snapshots/codegen_snapshot_tests__generic_methods.snap b/tests/snapshots/codegen_snapshot_tests__generic_methods.snap index f5a3204da..80193f3fb 100644 --- a/tests/snapshots/codegen_snapshot_tests__generic_methods.snap +++ b/tests/snapshots/codegen_snapshot_tests__generic_methods.snap @@ -9,6 +9,19 @@ expression: rust_code incan_stdlib::__incan_stdlib_version_check!(""); #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Box {} +impl incan_stdlib::reflection::HasClassName for Box { + fn __class_name__(&self) -> &'static str { + "Box" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Box { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Box { pub fn get(&self, value: T) -> T { return value; @@ -19,6 +32,29 @@ struct Shelf { #[expect(dead_code, reason = "retained for Incan private field semantics")] item: U, } +impl incan_stdlib::reflection::HasClassName for Shelf { + fn __class_name__(&self) -> &'static str { + "Shelf" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Shelf { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("item"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("item"), + type_name: incan_stdlib::frozen::FrozenStr::new("U"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Shelf { pub fn swap(&self, value: T) -> T { return value; @@ -29,6 +65,19 @@ pub trait Echo { } #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Pair {} +impl incan_stdlib::reflection::HasClassName for Pair { + fn __class_name__(&self) -> &'static str { + "Pair" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Pair { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Pair { pub fn swap(&self, _: T, right: U) -> U { return right; @@ -39,6 +88,29 @@ struct EchoBox { #[expect(dead_code, reason = "retained for Incan private field semantics")] marker: i64, } +impl incan_stdlib::reflection::HasClassName for EchoBox { + fn __class_name__(&self) -> &'static str { + "EchoBox" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for EchoBox { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Echo for EchoBox { fn echo(&self, value: T) -> T { return value; diff --git a/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap b/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap index 211b342ec..3529a5e9c 100644 --- a/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap +++ b/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap @@ -11,6 +11,29 @@ incan_stdlib::__incan_stdlib_version_check!(""); pub struct Boxed { pub value: T, } +impl incan_stdlib::reflection::HasClassName for Boxed { + fn __class_name__(&self) -> &'static str { + "Boxed" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Boxed { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Boxed { /// Returns field metadata for this type. pub fn __fields__( diff --git a/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap b/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap index 75623f9e7..b3d1bf7fa 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap @@ -9,6 +9,19 @@ expression: rust_code incan_stdlib::__incan_stdlib_version_check!(""); #[derive(Clone, Debug, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Cursor {} +impl incan_stdlib::reflection::HasClassName for Cursor { + fn __class_name__(&self) -> &'static str { + "Cursor" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Cursor { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Cursor { pub fn join(&self, _: Self, _: bool) -> Self { return Cursor {}; @@ -18,6 +31,29 @@ impl Cursor { struct Wrapper { _cursor: Cursor, } +impl incan_stdlib::reflection::HasClassName for Wrapper { + fn __class_name__(&self) -> &'static str { + "Wrapper" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Wrapper { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("_cursor"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("_cursor"), + type_name: incan_stdlib::frozen::FrozenStr::new("Cursor"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Wrapper { pub fn merge(&self, other: Self) -> Self { return Wrapper { diff --git a/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap b/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap index 7c24e9530..f25d499ec 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap @@ -12,6 +12,38 @@ pub struct LazyFrame { _cursor: i64, pub schema: String, } +impl incan_stdlib::reflection::HasClassName for LazyFrame { + fn __class_name__(&self) -> &'static str { + "LazyFrame" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for LazyFrame { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("_cursor"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("_cursor"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("schema"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("schema"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl LazyFrame { pub fn cursor(&self) -> i64 { return self._cursor; diff --git a/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap b/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap index 4192cce50..13554a683 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap @@ -12,6 +12,38 @@ struct StoredNode { store_id_raw: i64, node: String, } +impl incan_stdlib::reflection::HasClassName for StoredNode { + fn __class_name__(&self) -> &'static str { + "StoredNode" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for StoredNode { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("store_id_raw"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("store_id_raw"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("node"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("node"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn store_nodes(store_id: i64, stored_nodes: Vec) -> Vec { return (stored_nodes) .iter() diff --git a/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap b/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap index 4ecf325e2..a3ce07b55 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap @@ -12,6 +12,38 @@ pub struct ActiveRegistration { pub logical_name: String, pub rank: i64, } +impl incan_stdlib::reflection::HasClassName for ActiveRegistration { + fn __class_name__(&self) -> &'static str { + "ActiveRegistration" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for ActiveRegistration { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("logical_name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("logical_name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("rank"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("rank"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl ActiveRegistration { pub fn clone(&self) -> Self { return ActiveRegistration { diff --git a/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap b/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap index 725e4e2da..541b25881 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap @@ -29,6 +29,47 @@ pub struct Expr { pub column_name: String, pub arguments: Vec, } +impl incan_stdlib::reflection::HasClassName for Expr { + fn __class_name__(&self) -> &'static str { + "Expr" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Expr { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("kind"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("kind"), + type_name: incan_stdlib::frozen::FrozenStr::new("ExprKind"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("column_name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("column_name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("arguments"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("arguments"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[Expr]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Expr { /// Returns field metadata for this type. pub fn __fields__( diff --git a/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap b/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap index 0b8dd453a..77e862e81 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap @@ -16,6 +16,47 @@ struct Binding { #[expect(dead_code, reason = "retained for Incan private field semantics")] expr_index: i64, } +impl incan_stdlib::reflection::HasClassName for Binding { + fn __class_name__(&self) -> &'static str { + "Binding" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Binding { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("output_index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("output_index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("expr_index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("expr_index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn field_ref(index: i64) -> i64 { return index; } diff --git a/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap b/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap index 5f6bf5925..6972dcec7 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap @@ -16,6 +16,47 @@ struct Binding { #[expect(dead_code, reason = "retained for Incan private field semantics")] expr_index: i64, } +impl incan_stdlib::reflection::HasClassName for Binding { + fn __class_name__(&self) -> &'static str { + "Binding" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Binding { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("output_index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("output_index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("expr_index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("expr_index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn field_ref(index: i64) -> i64 { return index; } diff --git a/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap b/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap index 5d3f11491..6e521f1a1 100644 --- a/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap +++ b/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap @@ -12,6 +12,29 @@ struct Node { #[expect(dead_code, reason = "retained for Incan private field semantics")] id: i64, } +impl incan_stdlib::reflection::HasClassName for Node { + fn __class_name__(&self) -> &'static str { + "Node" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Node { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn clone_nodes(nodes: Vec) -> Vec { let copy = nodes.clone(); return copy; diff --git a/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap b/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap index 6e06c4ec5..8ac0865eb 100644 --- a/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap +++ b/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap @@ -11,6 +11,29 @@ incan_stdlib::__incan_stdlib_version_check!(""); struct PopRegressItem { id: i64, } +impl incan_stdlib::reflection::HasClassName for PopRegressItem { + fn __class_name__(&self) -> &'static str { + "PopRegressItem" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for PopRegressItem { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn drain_items() { let mut xs: Vec = vec![PopRegressItem { id : 1 }]; while ::std::convert::identity(xs.len() as i64) > 0 { diff --git a/tests/snapshots/codegen_snapshot_tests__model_struct.snap b/tests/snapshots/codegen_snapshot_tests__model_struct.snap index c834f97a5..0b2b9501c 100644 --- a/tests/snapshots/codegen_snapshot_tests__model_struct.snap +++ b/tests/snapshots/codegen_snapshot_tests__model_struct.snap @@ -13,6 +13,38 @@ struct User { #[expect(dead_code, reason = "retained for Incan private field semantics")] age: i64, } +impl incan_stdlib::reflection::HasClassName for User { + fn __class_name__(&self) -> &'static str { + "User" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for User { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("age"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("age"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn main() { std::panic::set_hook( std::boxed::Box::new(|panic_info| { diff --git a/tests/snapshots/codegen_snapshot_tests__models.snap b/tests/snapshots/codegen_snapshot_tests__models.snap index 3389bac1a..25cd81b71 100644 --- a/tests/snapshots/codegen_snapshot_tests__models.snap +++ b/tests/snapshots/codegen_snapshot_tests__models.snap @@ -15,6 +15,47 @@ struct User { #[expect(dead_code, reason = "retained for Incan private field semantics")] email: String, } +impl incan_stdlib::reflection::HasClassName for User { + fn __class_name__(&self) -> &'static str { + "User" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for User { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("age"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("age"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("email"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("email"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Product { id: i64, @@ -23,6 +64,47 @@ struct Product { #[expect(dead_code, reason = "retained for Incan private field semantics")] price: f64, } +impl incan_stdlib::reflection::HasClassName for Product { + fn __class_name__(&self) -> &'static str { + "Product" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Product { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("price"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("price"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Clone, Debug, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Config { #[expect(dead_code, reason = "retained for Incan private field semantics")] @@ -31,6 +113,47 @@ struct Config { #[expect(dead_code, reason = "retained for Incan private field semantics")] timeout: i64, } +impl incan_stdlib::reflection::HasClassName for Config { + fn __class_name__(&self) -> &'static str { + "Config" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Config { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("host"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("host"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("port"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("port"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("timeout"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("timeout"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct OptionalData { #[expect(dead_code, reason = "retained for Incan private field semantics")] @@ -40,6 +163,47 @@ struct OptionalData { #[expect(dead_code, reason = "retained for Incan private field semantics")] maybe_number: Option, } +impl incan_stdlib::reflection::HasClassName for OptionalData { + fn __class_name__(&self) -> &'static str { + "OptionalData" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for OptionalData { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("required"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("required"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("optional"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("optional"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[str]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("maybe_number"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("maybe_number"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[int]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct StudentData { name: String, @@ -48,6 +212,47 @@ struct StudentData { #[expect(dead_code, reason = "retained for Incan private field semantics")] metadata: std::collections::HashMap, } +impl incan_stdlib::reflection::HasClassName for StudentData { + fn __class_name__(&self) -> &'static str { + "StudentData" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for StudentData { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("grades"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("grades"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[int]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("metadata"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("metadata"), + type_name: incan_stdlib::frozen::FrozenStr::new("dict[str, str]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Address { #[expect(dead_code, reason = "retained for Incan private field semantics")] @@ -56,6 +261,47 @@ struct Address { #[expect(dead_code, reason = "retained for Incan private field semantics")] zipcode: String, } +impl incan_stdlib::reflection::HasClassName for Address { + fn __class_name__(&self) -> &'static str { + "Address" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Address { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("street"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("street"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("city"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("city"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("zipcode"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("zipcode"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Contact { name: String, @@ -63,11 +309,84 @@ struct Contact { #[expect(dead_code, reason = "retained for Incan private field semantics")] phone: String, } +impl incan_stdlib::reflection::HasClassName for Contact { + fn __class_name__(&self) -> &'static str { + "Contact" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Contact { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("address"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("address"), + type_name: incan_stdlib::frozen::FrozenStr::new("Address"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("phone"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("phone"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Coordinate { latitude: f64, longitude: f64, } +impl incan_stdlib::reflection::HasClassName for Coordinate { + fn __class_name__(&self) -> &'static str { + "Coordinate" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Coordinate { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("latitude"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("latitude"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("longitude"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("longitude"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn create_coordinate(lat: f64, lon: f64) -> Coordinate { return Coordinate { latitude: lat, diff --git a/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap b/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap index 49f0312c7..58d7d2736 100644 --- a/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap +++ b/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2171 expression: rust_code --- // Generated by the Incan compiler v @@ -33,6 +32,29 @@ struct RetryAttempts(pub Attempts); struct Job { attempts: Attempts, } +impl incan_stdlib::reflection::HasClassName for Job { + fn __class_name__(&self) -> &'static str { + "Job" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Job { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("attempts"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("attempts"), + type_name: incan_stdlib::frozen::FrozenStr::new("Attempts"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn take_attempts(a: Attempts) { println!( "{}", { let __parts : [& str; 2usize] = ["", ""]; let __args : Vec < String > = diff --git a/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap b/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap index 7815bef02..70e9cd157 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap @@ -21,6 +21,29 @@ struct Payload { #[expect(dead_code, reason = "retained for Incan private field semantics")] value: i64, } +impl incan_stdlib::reflection::HasClassName for Payload { + fn __class_name__(&self) -> &'static str { + "Payload" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Payload { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl json::Serialize for Payload { fn to_json(&self) -> String { incan_stdlib::json::__private::stringify_or_raise(self, stringify!(Payload)) diff --git a/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap b/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap index e32ba3b55..78fc8a1a0 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2863 expression: rust_code --- // Generated by the Incan compiler v @@ -20,6 +19,29 @@ struct Payload { #[expect(dead_code, reason = "retained for Incan private field semantics")] value: i64, } +impl incan_stdlib::reflection::HasClassName for Payload { + fn __class_name__(&self) -> &'static str { + "Payload" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Payload { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl JsonSerialize for Payload { fn to_json(&self) -> String { return incan_stdlib::json::__private::stringify_or_raise( diff --git a/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap b/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap index fb670bbf8..31f30e473 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2009 expression: rust_code --- // Generated by the Incan compiler v @@ -22,6 +21,29 @@ incan_stdlib::__incan_stdlib_version_check!(""); pub struct PayloadKey { pub value: i64, } +impl incan_stdlib::reflection::HasClassName for PayloadKey { + fn __class_name__(&self) -> &'static str { + "PayloadKey" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for PayloadKey { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl PayloadKey { /// Returns field metadata for this type. pub fn __fields__( diff --git a/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap b/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap index 4ac2cd226..ea4cc4693 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap @@ -14,6 +14,29 @@ pub trait Named { pub struct Money { pub cents: i64, } +impl incan_stdlib::reflection::HasClassName for Money { + fn __class_name__(&self) -> &'static str { + "Money" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Money { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("cents"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("cents"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Money { pub fn dollars(&self) -> i64 { return self.cents + 1; diff --git a/tests/snapshots/codegen_snapshot_tests__rust_allow.snap b/tests/snapshots/codegen_snapshot_tests__rust_allow.snap index 317081758..3a2059e13 100644 --- a/tests/snapshots/codegen_snapshot_tests__rust_allow.snap +++ b/tests/snapshots/codegen_snapshot_tests__rust_allow.snap @@ -12,6 +12,29 @@ incan_stdlib::__incan_stdlib_version_check!(""); struct AllowModel { value: i64, } +impl incan_stdlib::reflection::HasClassName for AllowModel { + fn __class_name__(&self) -> &'static str { + "AllowModel" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for AllowModel { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl AllowModel { #[allow(unused_variables)] pub fn model_method(&self, _: i64) -> i64 { @@ -23,6 +46,29 @@ impl AllowModel { struct allow_class { value: i64, } +impl incan_stdlib::reflection::HasClassName for allow_class { + fn __class_name__(&self) -> &'static str { + "allow_class" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for allow_class { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl allow_class { #[allow(non_snake_case)] pub fn MixedMethod(&self) -> i64 { diff --git a/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap index 38936aa70..e62c3225c 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2546 expression: rust_code --- // Generated by the Incan compiler v @@ -32,6 +31,29 @@ pub use ::incan_stdlib::r#async::channel::oneshot_receiver_recv as rust_oneshot_ pub struct SendError { pub value: T, } +impl incan_stdlib::reflection::HasClassName for SendError { + fn __class_name__(&self) -> &'static str { + "SendError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for SendError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl SendError { pub fn message(&self) -> String { return "channel send failed".to_string(); @@ -67,6 +89,19 @@ impl Error for SendError { } #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] pub struct RecvError {} +impl incan_stdlib::reflection::HasClassName for RecvError { + fn __class_name__(&self) -> &'static str { + "RecvError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for RecvError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl RecvError { pub fn message(&self) -> String { return "channel closed: no more messages".to_string(); diff --git a/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap index 10d83becc..e0e95d4f0 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2557 expression: rust_code --- // Generated by the Incan compiler v @@ -39,6 +38,19 @@ pub use ::incan_stdlib::r#async::sync::barrier_new as rust_barrier_new; pub use ::incan_stdlib::r#async::sync::barrier_wait as rust_barrier_wait; #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] pub struct SemaphoreAcquireError {} +impl incan_stdlib::reflection::HasClassName for SemaphoreAcquireError { + fn __class_name__(&self) -> &'static str { + "SemaphoreAcquireError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for SemaphoreAcquireError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl SemaphoreAcquireError { pub fn message(&self) -> String { return "failed to acquire semaphore permit: semaphore closed".to_string(); diff --git a/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap index cb9042967..c35691257 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2535 expression: rust_code --- // Generated by the Incan compiler v @@ -21,6 +20,19 @@ pub use ::incan_stdlib::__private::tokio::time::sleep as tokio_sleep; pub use ::incan_stdlib::__private::tokio::time::timeout as tokio_timeout; #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] pub struct TimeoutError {} +impl incan_stdlib::reflection::HasClassName for TimeoutError { + fn __class_name__(&self) -> &'static str { + "TimeoutError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for TimeoutError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl TimeoutError { pub fn message(&self) -> String { return "operation timed out".to_string(); @@ -49,6 +61,38 @@ pub struct Duration { pub secs: i64, pub nanos: i64, } +impl incan_stdlib::reflection::HasClassName for Duration { + fn __class_name__(&self) -> &'static str { + "Duration" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Duration { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("secs"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("secs"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("nanos"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("nanos"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Duration { pub fn from_secs(secs: i64) -> Duration { "Create a duration from whole seconds."; diff --git a/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap index 2d7cfc2aa..860c0fe39 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2660 expression: rust_code --- // Generated by the Incan compiler v @@ -35,6 +34,38 @@ pub struct ListIterator { pub items: Vec, pub index: i64, } +impl incan_stdlib::reflection::HasClassName for ListIterator { + fn __class_name__(&self) -> &'static str { + "ListIterator" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for ListIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("items"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("items"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[T]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl ListIterator { pub fn __next__(&mut self) -> Option { "\n Return the next list item, or `None` after the final index.\n "; @@ -90,6 +121,40 @@ pub struct MapIterator, U> { pub source: Source, pub f: fn(T) -> U, } +impl, U> incan_stdlib::reflection::HasClassName +for MapIterator { + fn __class_name__(&self) -> &'static str { + "MapIterator" + } +} +impl, U> incan_stdlib::reflection::HasFieldMetadata +for MapIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("f"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("f"), + type_name: incan_stdlib::frozen::FrozenStr::new("(T) -> U"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl, U> MapIterator { pub fn __next__(&mut self) -> Option { "\n Pull one source item and return its mapped value.\n "; @@ -149,6 +214,40 @@ pub struct FilterIterator> { pub source: Source, pub f: fn(T) -> bool, } +impl> incan_stdlib::reflection::HasClassName +for FilterIterator { + fn __class_name__(&self) -> &'static str { + "FilterIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for FilterIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("f"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("f"), + type_name: incan_stdlib::frozen::FrozenStr::new("(T) -> bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl + Clone> FilterIterator { pub fn __next__(&mut self) -> Option { "\n Return the next source item accepted by the predicate.\n "; @@ -218,6 +317,58 @@ pub struct FlatMapIterator, U> { pub current: Vec, pub index: i64, } +impl, U> incan_stdlib::reflection::HasClassName +for FlatMapIterator { + fn __class_name__(&self) -> &'static str { + "FlatMapIterator" + } +} +impl, U> incan_stdlib::reflection::HasFieldMetadata +for FlatMapIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("f"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("f"), + type_name: incan_stdlib::frozen::FrozenStr::new("(T) -> list[U]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("current"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("current"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[U]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl + Clone, U: Clone> FlatMapIterator { pub fn __next__(&mut self) -> Option { "\n Return the next item from the current nested list, or open a new one.\n "; @@ -321,6 +472,49 @@ pub struct TakeIterator> { pub remaining: i64, pub marker: Option, } +impl> incan_stdlib::reflection::HasClassName +for TakeIterator { + fn __class_name__(&self) -> &'static str { + "TakeIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for TakeIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("remaining"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("remaining"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[T]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl> TakeIterator { pub fn __next__(&mut self) -> Option { "\n Yield one item while the remaining count is positive.\n "; @@ -396,6 +590,49 @@ pub struct SkipIterator> { pub remaining: i64, pub marker: Option, } +impl> incan_stdlib::reflection::HasClassName +for SkipIterator { + fn __class_name__(&self) -> &'static str { + "SkipIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for SkipIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("remaining"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("remaining"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[T]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl> SkipIterator { pub fn __next__(&mut self) -> Option { "\n Discard source items until the remaining skip count reaches zero.\n "; @@ -470,6 +707,61 @@ pub struct ChainIterator, Second: Iterator> { pub in_second: bool, pub marker: Option, } +impl, Second: Iterator> incan_stdlib::reflection::HasClassName +for ChainIterator { + fn __class_name__(&self) -> &'static str { + "ChainIterator" + } +} +impl< + T, + First: Iterator, + Second: Iterator, +> incan_stdlib::reflection::HasFieldMetadata for ChainIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("first"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("first"), + type_name: incan_stdlib::frozen::FrozenStr::new("First"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("second"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("second"), + type_name: incan_stdlib::frozen::FrozenStr::new("Second"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("in_second"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("in_second"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[T]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl, Second: Iterator> ChainIterator { pub fn __next__(&mut self) -> Option { "\n Yield from the first iterator until exhausted, then from the second.\n "; @@ -553,6 +845,49 @@ pub struct EnumerateIterator> { pub index: i64, pub marker: Option, } +impl> incan_stdlib::reflection::HasClassName +for EnumerateIterator { + fn __class_name__(&self) -> &'static str { + "EnumerateIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for EnumerateIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("index"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("index"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[T]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl> EnumerateIterator { pub fn __next__(&mut self) -> Option<(i64, T)> { "\n Return the current index with the next source item.\n "; @@ -625,6 +960,62 @@ pub struct ZipIterator, U, Right: Iterator> { pub left_marker: Option, pub right_marker: Option, } +impl, U, Right: Iterator> incan_stdlib::reflection::HasClassName +for ZipIterator { + fn __class_name__(&self) -> &'static str { + "ZipIterator" + } +} +impl< + T, + Left: Iterator, + U, + Right: Iterator, +> incan_stdlib::reflection::HasFieldMetadata for ZipIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("left"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("left"), + type_name: incan_stdlib::frozen::FrozenStr::new("Left"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("right"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("right"), + type_name: incan_stdlib::frozen::FrozenStr::new("Right"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("left_marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("left_marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[T]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("right_marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("right_marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[U]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl, U, Right: Iterator> ZipIterator { pub fn __next__(&mut self) -> Option<(T, U)> { "\n Pull one item from each side and return the pair.\n "; @@ -716,6 +1107,49 @@ pub struct TakeWhileIterator> { pub f: fn(T) -> bool, pub done: bool, } +impl> incan_stdlib::reflection::HasClassName +for TakeWhileIterator { + fn __class_name__(&self) -> &'static str { + "TakeWhileIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for TakeWhileIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("f"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("f"), + type_name: incan_stdlib::frozen::FrozenStr::new("(T) -> bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("done"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("done"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl> TakeWhileIterator { pub fn __next__(&mut self) -> Option { "\n Yield source items until the predicate rejects one.\n "; @@ -801,6 +1235,49 @@ pub struct SkipWhileIterator> { pub f: fn(T) -> bool, pub skipping: bool, } +impl> incan_stdlib::reflection::HasClassName +for SkipWhileIterator { + fn __class_name__(&self) -> &'static str { + "SkipWhileIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for SkipWhileIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("f"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("f"), + type_name: incan_stdlib::frozen::FrozenStr::new("(T) -> bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("skipping"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("skipping"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl + Clone> SkipWhileIterator { pub fn __next__(&mut self) -> Option { "\n Discard source items until the predicate rejects one, then yield normally.\n "; @@ -883,6 +1360,49 @@ pub struct BatchIterator> { pub size: i64, pub marker: Option, } +impl> incan_stdlib::reflection::HasClassName +for BatchIterator { + fn __class_name__(&self) -> &'static str { + "BatchIterator" + } +} +impl> incan_stdlib::reflection::HasFieldMetadata +for BatchIterator { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("source"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("source"), + type_name: incan_stdlib::frozen::FrozenStr::new("Source"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("size"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("size"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("marker"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("marker"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[T]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl + Clone> BatchIterator { pub fn __next__(&mut self) -> Option> { "\n Build and return the next non-empty batch.\n "; diff --git a/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap index 4b7bfe880..79281783d 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2206 expression: rust_code --- // Generated by the Incan compiler v @@ -63,6 +62,29 @@ impl EdgeId { pub struct GraphError { pub detail: String, } +impl incan_stdlib::reflection::HasClassName for GraphError { + fn __class_name__(&self) -> &'static str { + "GraphError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for GraphError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("detail"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("detail"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl GraphError { pub fn message(&self) -> String { return self.detail.clone(); @@ -102,6 +124,47 @@ pub struct GraphNode { pub payload: T, pub active: bool, } +impl incan_stdlib::reflection::HasClassName for GraphNode { + fn __class_name__(&self) -> &'static str { + "GraphNode" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for GraphNode { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("NodeId"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("payload"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("payload"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("active"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("active"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl GraphNode { /// Returns field metadata for this type. pub fn __fields__( @@ -146,6 +209,56 @@ pub struct GraphEdge { pub to: NodeId, pub active: bool, } +impl incan_stdlib::reflection::HasClassName for GraphEdge { + fn __class_name__(&self) -> &'static str { + "GraphEdge" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for GraphEdge { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("EdgeId"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("from_"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("from_"), + type_name: incan_stdlib::frozen::FrozenStr::new("NodeId"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("to"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("to"), + type_name: incan_stdlib::frozen::FrozenStr::new("NodeId"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("active"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("active"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl GraphEdge { /// Returns field metadata for this type. pub fn __fields__( @@ -199,6 +312,56 @@ pub struct DiGraph { pub nodes: Vec>, pub edges: Vec, } +impl incan_stdlib::reflection::HasClassName for DiGraph { + fn __class_name__(&self) -> &'static str { + "DiGraph" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for DiGraph { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("next_node_id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("next_node_id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("next_edge_id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("next_edge_id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("nodes"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("nodes"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[GraphNode[T]]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("edges"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("edges"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[GraphEdge]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl DiGraph { pub fn __incan_new() -> Self { "Compiler hook for `DiGraph[T]()` constructor syntax."; @@ -619,6 +782,29 @@ impl DiGraph { pub struct Dag { pub graph: DiGraph, } +impl incan_stdlib::reflection::HasClassName for Dag { + fn __class_name__(&self) -> &'static str { + "Dag" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Dag { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("graph"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("graph"), + type_name: incan_stdlib::frozen::FrozenStr::new("DiGraph[T]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Dag { pub fn __incan_new() -> Self { "Compiler hook for `Dag[T]()` constructor syntax."; @@ -744,6 +930,29 @@ impl Dag { pub struct MultiDiGraph { pub graph: DiGraph, } +impl incan_stdlib::reflection::HasClassName for MultiDiGraph { + fn __class_name__(&self) -> &'static str { + "MultiDiGraph" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for MultiDiGraph { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("graph"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("graph"), + type_name: incan_stdlib::frozen::FrozenStr::new("DiGraph[T]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl MultiDiGraph { pub fn __incan_new() -> Self { "Compiler hook for `MultiDiGraph[T]()` constructor syntax."; diff --git a/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap b/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap index 850861a32..e6751f72a 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap @@ -25,6 +25,47 @@ struct Config { #[expect(dead_code, reason = "retained for Incan private field semantics")] debug: bool, } +impl incan_stdlib::reflection::HasClassName for Config { + fn __class_name__(&self) -> &'static str { + "Config" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Config { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("host"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("host"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("port"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("port"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("debug"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("debug"), + type_name: incan_stdlib::frozen::FrozenStr::new("bool"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl json::Serialize for Config { fn to_json(&self) -> String { incan_stdlib::json::__private::stringify_or_raise(self, stringify!(Config)) diff --git a/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap b/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap index 2e489711e..30b039e41 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2782 expression: rust_code --- // Generated by the Incan compiler v @@ -20,6 +19,29 @@ struct Payload { #[expect(dead_code, reason = "retained for Incan private field semantics")] value: i64, } +impl incan_stdlib::reflection::HasClassName for Payload { + fn __class_name__(&self) -> &'static str { + "Payload" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Payload { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Serialize for Payload { fn to_json(&self) -> String { return incan_stdlib::json::__private::stringify_or_raise( diff --git a/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap b/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap index 3a43a47c8..bb09ad9e6 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 3080 expression: rust_code --- // Generated by the Incan compiler v @@ -14,6 +13,29 @@ pub use crate::__incan_std::traits::convert::TryFrom; struct UserId { value: i64, } +impl incan_stdlib::reflection::HasClassName for UserId { + fn __class_name__(&self) -> &'static str { + "UserId" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for UserId { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl UserId { pub fn from(value: i64) -> Self { return UserId { value: value }; @@ -31,6 +53,29 @@ impl From for UserId { struct PositiveInt { value: i64, } +impl incan_stdlib::reflection::HasClassName for PositiveInt { + fn __class_name__(&self) -> &'static str { + "PositiveInt" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for PositiveInt { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl PositiveInt { pub fn try_from(value: i64) -> Result { if value <= 0 { diff --git a/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap index a575257b7..81c3bb74c 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap @@ -121,6 +121,38 @@ pub struct UuidError { pub kind: String, pub detail: String, } +impl incan_stdlib::reflection::HasClassName for UuidError { + fn __class_name__(&self) -> &'static str { + "UuidError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for UuidError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("kind"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("kind"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("detail"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("detail"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl UuidError { pub fn message(&self) -> String { "\n Return the human-readable error detail.\n\n Returns:\n The detail text attached to the UUID failure.\n "; @@ -417,6 +449,29 @@ impl OrdinalKey for UUID { struct _UuidText { text: String, } +impl incan_stdlib::reflection::HasClassName for _UuidText { + fn __class_name__(&self) -> &'static str { + "_UuidText" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for _UuidText { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("text"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("text"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl _UuidText { pub fn to_bytes(&self) -> Result, UuidError> { "\n Decode the accepted UUID text spellings into network-order bytes.\n\n Returns:\n Sixteen UUID bytes, or `Err(UuidError)` when normalization or hex decoding fails.\n "; @@ -536,6 +591,38 @@ struct _UuidBytesWriter { out: _BytesIO, operation: String, } +impl incan_stdlib::reflection::HasClassName for _UuidBytesWriter { + fn __class_name__(&self) -> &'static str { + "_UuidBytesWriter" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for _UuidBytesWriter { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("out"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("out"), + type_name: incan_stdlib::frozen::FrozenStr::new("_BytesIO"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("operation"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("operation"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl _UuidBytesWriter { pub fn raw(&self) -> Vec { "\n Return the bytes written so far.\n\n Returns:\n The current byte buffer.\n "; diff --git a/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap b/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap index b2ff587ee..7eb3ef9ff 100644 --- a/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap +++ b/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap @@ -26,6 +26,29 @@ pub trait BoundedDataSet: DataSet { struct Thing { x: i64, } +impl incan_stdlib::reflection::HasClassName for Thing { + fn __class_name__(&self) -> &'static str { + "Thing" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Thing { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("x"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("x"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Mid for Thing { fn mid_id(&self) -> i64 { return self.x + 1; @@ -41,10 +64,56 @@ struct Row { #[expect(dead_code, reason = "retained for Incan private field semantics")] id: i64, } +impl incan_stdlib::reflection::HasClassName for Row { + fn __class_name__(&self) -> &'static str { + "Row" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Row { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Wrapper { value: T, } +impl incan_stdlib::reflection::HasClassName for Wrapper { + fn __class_name__(&self) -> &'static str { + "Wrapper" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Wrapper { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl BoundedDataSet for Wrapper { fn bound(&self) -> T { return self.value.clone(); diff --git a/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap b/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap index 8662a1870..1fd93b002 100644 --- a/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap +++ b/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap @@ -17,6 +17,29 @@ pub trait OrderedCollection: Collection { struct BoxedValue { value: T, } +impl incan_stdlib::reflection::HasClassName for BoxedValue { + fn __class_name__(&self) -> &'static str { + "BoxedValue" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for BoxedValue { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl BoxedValue { pub fn first(&self) -> T { return self.value.clone(); diff --git a/tests/snapshots/codegen_snapshot_tests__traits.snap b/tests/snapshots/codegen_snapshot_tests__traits.snap index 33db51ec4..7b08c0c41 100644 --- a/tests/snapshots/codegen_snapshot_tests__traits.snap +++ b/tests/snapshots/codegen_snapshot_tests__traits.snap @@ -19,12 +19,67 @@ pub trait Shape { struct Dog { pub name: String, } +impl incan_stdlib::reflection::HasClassName for Dog { + fn __class_name__(&self) -> &'static str { + "Dog" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Dog { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Named for Dog {} #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Rectangle { width: f64, height: f64, } +impl incan_stdlib::reflection::HasClassName for Rectangle { + fn __class_name__(&self) -> &'static str { + "Rectangle" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Rectangle { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("width"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("width"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("height"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("height"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Rectangle { pub fn area(&self) -> f64 { return self.width * self.height; @@ -45,6 +100,29 @@ impl Shape for Rectangle { struct Circle { radius: f64, } +impl incan_stdlib::reflection::HasClassName for Circle { + fn __class_name__(&self) -> &'static str { + "Circle" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Circle { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("radius"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("radius"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Circle { pub fn draw(&self) -> String { return { @@ -67,6 +145,29 @@ impl Drawable for Circle { struct Square { side: f64, } +impl incan_stdlib::reflection::HasClassName for Square { + fn __class_name__(&self) -> &'static str { + "Square" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Square { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("side"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("side"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Square { pub fn area(&self) -> f64 { return self.side * self.side; @@ -105,6 +206,47 @@ struct Carton { height: f64, depth: f64, } +impl incan_stdlib::reflection::HasClassName for Carton { + fn __class_name__(&self) -> &'static str { + "Carton" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Carton { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("width"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("width"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("height"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("height"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("depth"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("depth"), + type_name: incan_stdlib::frozen::FrozenStr::new("float"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Carton { pub fn resize(&self, factor: f64) { self.width = self.width * factor; @@ -133,6 +275,38 @@ struct Document { title: String, content: String, } +impl incan_stdlib::reflection::HasClassName for Document { + fn __class_name__(&self) -> &'static str { + "Document" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Document { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("title"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("title"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("content"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("content"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Document { pub fn to_string(&self) -> String { return { diff --git a/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap b/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap index 44b0c7d08..4c22c67fb 100644 --- a/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap +++ b/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap @@ -11,6 +11,29 @@ incan_stdlib::__incan_stdlib_version_check!(""); struct Person { name: String, } +impl incan_stdlib::reflection::HasClassName for Person { + fn __class_name__(&self) -> &'static str { + "Person" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Person { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} fn main() { std::panic::set_hook( std::boxed::Box::new(|panic_info| { diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap index cc44b82f4..1e03012e6 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 661 expression: rust_code --- // Generated by the Incan compiler v @@ -36,6 +35,29 @@ struct Box { #[expect(dead_code, reason = "retained for Incan private field semantics")] value: i64, } +impl incan_stdlib::reflection::HasClassName for Box { + fn __class_name__(&self) -> &'static str { + "Box" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Box { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} static __INCAN_DECORATED_BOX_LABEL: std::sync::LazyLock< incan_stdlib::storage::StaticCell i64>, > = std::sync::LazyLock::new(|| incan_stdlib::storage::StaticCell::new( diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap index 3340d916f..af8216f72 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 668 expression: rust_code --- // Generated by the Incan compiler v @@ -36,6 +35,29 @@ struct Counter { #[expect(dead_code, reason = "retained for Incan private field semantics")] value: i64, } +impl incan_stdlib::reflection::HasClassName for Counter { + fn __class_name__(&self) -> &'static str { + "Counter" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Counter { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} static __INCAN_DECORATED_COUNTER_BUMP: std::sync::LazyLock< incan_stdlib::storage::StaticCell i64>, > = std::sync::LazyLock::new(|| incan_stdlib::storage::StaticCell::new( diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap index 2c1ed0221..af4e95f49 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap @@ -11,6 +11,29 @@ incan_stdlib::__incan_stdlib_version_check!(""); struct Money { cents: i64, } +impl incan_stdlib::reflection::HasClassName for Money { + fn __class_name__(&self) -> &'static str { + "Money" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Money { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("cents"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("cents"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Money { pub fn __add__(&self, other: Money) -> Money { return Money { @@ -25,6 +48,29 @@ impl Money { struct User { id: i64, } +impl incan_stdlib::reflection::HasClassName for User { + fn __class_name__(&self) -> &'static str { + "User" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for User { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("id"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("id"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl PartialEq for User { fn eq(&self, other: &Self) -> bool { return self.id == other.id; @@ -34,6 +80,29 @@ impl PartialEq for User { struct Row { value: i64, } +impl incan_stdlib::reflection::HasClassName for Row { + fn __class_name__(&self) -> &'static str { + "Row" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Row { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Row { pub fn __getitem__(&self, index: i64) -> i64 { return self.value + index; @@ -46,6 +115,29 @@ impl Row { struct OpBox { value: i64, } +impl incan_stdlib::reflection::HasClassName for OpBox { + fn __class_name__(&self) -> &'static str { + "OpBox" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for OpBox { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl OpBox { pub fn __matmul__(&self, other: OpBox) -> OpBox { return OpBox { diff --git a/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap b/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap index 96c07b657..2760d994f 100644 --- a/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap +++ b/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap @@ -41,6 +41,19 @@ fn collect_via_callable( } #[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] struct Collector {} +impl incan_stdlib::reflection::HasClassName for Collector { + fn __class_name__(&self) -> &'static str { + "Collector" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Collector { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Collector { pub fn collect( &self, diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap index e08dfd2a4..3d2ed59a7 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap @@ -101,6 +101,56 @@ pub struct CompressionError { pub operation: String, pub detail: String, } +impl incan_stdlib::reflection::HasClassName for CompressionError { + fn __class_name__(&self) -> &'static str { + "CompressionError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for CompressionError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("kind"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("kind"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("codec"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("codec"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[Codec]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("operation"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("operation"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("detail"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("detail"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl CompressionError { pub fn message(&self) -> String { "\n Return the human-readable error detail.\n "; diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap index b8b13eb6e..0c9a8a37e 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap @@ -27,6 +27,65 @@ pub struct IoError { pub position: i64, pub path: Option, } +impl incan_stdlib::reflection::HasClassName for IoError { + fn __class_name__(&self) -> &'static str { + "IoError" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for IoError { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 5] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("kind"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("kind"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("detail"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("detail"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("operation"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("operation"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("position"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("position"), + type_name: incan_stdlib::frozen::FrozenStr::new("int"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("path"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("path"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[str]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl IoError { pub fn message(&self) -> String { "\n Return the human-readable error detail.\n\n Returns:\n The detail text attached to the failed operation.\n "; @@ -122,6 +181,31 @@ impl Endian { pub struct _BytesIO { pub handle: Rc>>>, } +impl incan_stdlib::reflection::HasClassName for _BytesIO { + fn __class_name__(&self) -> &'static str { + "_BytesIO" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for _BytesIO { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("handle"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("handle"), + type_name: incan_stdlib::frozen::FrozenStr::new( + "Rc[RefCell[Cursor[bytes]]]", + ), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl _BytesIO { pub fn read(&self, size: i64) -> Result, IoError> { "\n Read up to `size` bytes from the current cursor.\n\n Args:\n size: Maximum bytes to read. Use a negative value to read through EOF.\n\n Returns:\n The bytes read, or `Err(IoError)`.\n\n Example:\n `BytesIO(b\"abc\").read(2)?` returns `b\"ab\"`.\n "; diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap index 6ab67fc2d..271a4aa2b 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap @@ -137,6 +137,126 @@ pub struct TelemetryValue { #[serde(rename = "MapValue")] pub map_value: std::collections::HashMap, } +impl incan_stdlib::reflection::HasClassName for TelemetryValue { + fn __class_name__(&self) -> &'static str { + "TelemetryValue" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for TelemetryValue { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 8] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("kind"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("Type")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Telemetry value kind: none, string, bool, int, float, bytes, array, or map.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("Type"), + type_name: incan_stdlib::frozen::FrozenStr::new("TelemetryValueKind"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("string_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("StringValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "String value when kind is string.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("StringValue"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[str]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("bool_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("BoolValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Boolean value when kind is bool.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("BoolValue"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[bool]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("int_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("IntValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Integer value when kind is int.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("IntValue"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[int]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("float_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("FloatValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Floating-point value when kind is float.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("FloatValue"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[float]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("bytes_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("BytesValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Encoded byte value when kind is bytes.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("BytesValue"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[str]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("array_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("ArrayValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Nested array values when kind is array.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("ArrayValue"), + type_name: incan_stdlib::frozen::FrozenStr::new("list[TelemetryValue]"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("map_value"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("MapValue")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Nested map values when kind is map.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("MapValue"), + type_name: incan_stdlib::frozen::FrozenStr::new( + "dict[str, TelemetryValue]", + ), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl TelemetryValue { pub fn none() -> Self { "Return a telemetry null value."; @@ -467,6 +587,33 @@ pub struct Resource { #[serde(rename = "Attributes")] pub attributes: Attributes, } +impl incan_stdlib::reflection::HasClassName for Resource { + fn __class_name__(&self) -> &'static str { + "Resource" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Resource { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("attributes"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("Attributes")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Resource attributes such as service.name or service.version.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("Attributes"), + type_name: incan_stdlib::frozen::FrozenStr::new("Attributes"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl Resource { /// Returns field metadata for this type. pub fn __fields__( @@ -513,6 +660,57 @@ pub struct InstrumentationScope { #[serde(rename = "SchemaUrl")] pub schema_url: Option, } +impl incan_stdlib::reflection::HasClassName for InstrumentationScope { + fn __class_name__(&self) -> &'static str { + "InstrumentationScope" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for InstrumentationScope { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("name"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("Name")), + description: Some( + incan_stdlib::frozen::FrozenStr::new("Instrumentation scope name."), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("Name"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("version"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("Version")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Instrumentation scope version, when known.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("Version"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[str]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("schema_url"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("SchemaUrl")), + description: Some( + incan_stdlib::frozen::FrozenStr::new( + "Schema URL for scope metadata, when known.", + ), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("SchemaUrl"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[str]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl InstrumentationScope { /// Returns field metadata for this type. pub fn __fields__( @@ -583,6 +781,53 @@ pub struct SpanContext { #[serde(rename = "TraceFlags")] pub trace_flags: Option, } +impl incan_stdlib::reflection::HasClassName for SpanContext { + fn __class_name__(&self) -> &'static str { + "SpanContext" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for SpanContext { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("trace_id"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("TraceId")), + description: Some( + incan_stdlib::frozen::FrozenStr::new("Trace identifier."), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("TraceId"), + type_name: incan_stdlib::frozen::FrozenStr::new("TraceId"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("span_id"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("SpanId")), + description: Some( + incan_stdlib::frozen::FrozenStr::new("Span identifier."), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("SpanId"), + type_name: incan_stdlib::frozen::FrozenStr::new("SpanId"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("trace_flags"), + alias: Some(incan_stdlib::frozen::FrozenStr::new("TraceFlags")), + description: Some( + incan_stdlib::frozen::FrozenStr::new("W3C trace flags."), + ), + wire_name: incan_stdlib::frozen::FrozenStr::new("TraceFlags"), + type_name: incan_stdlib::frozen::FrozenStr::new("Option[TraceFlags]"), + has_default: true, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} impl SpanContext { /// Returns field metadata for this type. pub fn __fields__( diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap index c6b40314e..2fb5fb3af 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap @@ -21,6 +21,29 @@ struct Search { #[expect(dead_code, reason = "retained for Incan private field semantics")] q: String, } +impl incan_stdlib::reflection::HasClassName for Search { + fn __class_name__(&self) -> &'static str { + "Search" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Search { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("q"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("q"), + type_name: incan_stdlib::frozen::FrozenStr::new("str"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} #[incan_web_macros::route("/snapshot/{id}", method = "GET")] async fn snapshot(_: Path, query: Query) -> Json { return crate::__incan_std::web::Json(query.value.clone()); diff --git a/workspaces/docs-site/docs/language/reference/reflection.md b/workspaces/docs-site/docs/language/reference/reflection.md index b0e523209..a1db8f6f1 100644 --- a/workspaces/docs-site/docs/language/reference/reflection.md +++ b/workspaces/docs-site/docs/language/reference/reflection.md @@ -32,6 +32,15 @@ def main() -> None: println(f"{info.name}: {info.type_name}") ``` +## Generic Reflection + +Generic helpers may call `value.__class_name__()` and `value.__fields__()` on a type parameter. The compiler treats those calls as reflection capabilities and emits the required runtime bounds for the generated Rust function, so the generic helper has the same field metadata result as a direct concrete call when it is instantiated with a reflectable model or class. + +```incan +def reflected_field_count[T](value: T) -> int: + return len(value.__fields__()) +``` + ### `FieldInfo` structure Each `FieldInfo` record contains: diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 0e7c74b81..42f51476d 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -39,6 +39,7 @@ Use this section as the map. The release note names each larger feature, says wh - **Value enums**: Keep enum type safety while exposing canonical `str` or `int` representations for external values. Read [Enums](../language/explanation/enums.md) and [Modeling with enums](../language/how-to/modeling_with_enums.md) ([RFC 032], #317). - **Enum methods and trait adoption**: Put enum-owned behavior on the enum and let enums adopt the same trait protocols as other source types. Read [Enums](../language/explanation/enums.md) and [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) ([RFC 050], #334). - **Computed properties and protocol hooks**: Define property-like readers and dunder-backed operator/protocol behavior without pushing users into Rust-shaped wrappers. Read [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) and [Derives and traits](../language/reference/derives_and_traits.md) ([RFC 046], [RFC 068], [RFC 028], #86, #162, #203). +- **Model and class reflection**: Inspect source field metadata and class names from concrete values or generic helpers with `__fields__()` and `__class_name__()`. Read [Reflection](../language/reference/reflection.md) and [`std.reflection`](../language/reference/stdlib/reflection.md) (#712). - **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape, concrete decorated callable values expose `__name__` for registry-style decorators, and decorated wrappers preserve source default-argument calls when the callable surface is unchanged. Read [Decorators](../language/reference/language.md#decorators), [Functions](../language/reference/functions.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640, #694, #703). - **Symbol aliases**: Export an existing callable or type-like symbol under another name without pretending it is a hand-written wrapper. Read [Symbol aliases](../language/reference/symbol_aliases.md) ([RFC 083], #437). - **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). @@ -89,6 +90,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Release smoke paths are less fragile**: InQL and release smoke testing fixed loop-item fields, union call arguments, storage-rooted match scrutinees, static list index assignment, typed `assert false` lowering, and const model metadata constructors (#620, #621, #622, #627, #630, #644, #671, #674). - **Generic and trait flow keeps more type information**: Instantiated receivers, generic fields, generic methods, `list[Self]`, trait/supertrait upcasts, imported prost oneofs, explicit generic cycles, and static factory locals now survive typechecking and lowering more consistently (#237, #231, #253, #230, #184, #218, #279, #252, #255). - **Runtime-boundary errors are clearer**: `std.regex` text borrowing, collection f-string formatting, and collection/string conversion diagnostics fail closer to the Incan source instead of surfacing as obscure Rust/runtime errors (#624, #625, #71, #81). +- **Reflection is capability-backed**: Generic `T.__class_name__()` and `T.__fields__()` calls infer runtime reflection bounds, and field-only classes emit the same reflection support as models (#712). - **Enum patterns use enum-owned metadata**: Qualified enum patterns now read variant payloads from the enum's semantic metadata instead of ambient variant symbols, keeping imported stdlib enums stable when project enums reuse variant names (#710). - **Stringly compiler behavior is guarded**: Remaining semantic string comparisons in high-risk compiler paths are fingerprinted so new string-based behavior has to be centralized or explicitly classified. From f179c0a257ea413ca905c1bee29ab17e90553c45 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 30 May 2026 00:19:25 +0200 Subject: [PATCH 48/58] bugfix - support type reflection and string borrows (#714, #715, #716) (#717) --- Cargo.lock | 18 +- Cargo.toml | 2 +- crates/incan_core/src/lang/trait_bounds.rs | 2 + crates/incan_stdlib/src/lib.rs | 2 +- crates/incan_stdlib/src/prelude.rs | 4 +- crates/incan_stdlib/src/reflection.rs | 18 ++ .../src/diagnostics/catalog/errors/types.rs | 7 + src/backend/ir/codegen.rs | 119 ++++++- src/backend/ir/conversions.rs | 19 ++ src/backend/ir/emit/decls/functions.rs | 15 +- src/backend/ir/emit/decls/structures.rs | 12 + .../ir/emit/expressions/comprehensions.rs | 3 + src/backend/ir/emit/expressions/methods.rs | 45 ++- src/backend/ir/emit/expressions/mod.rs | 86 +++++ src/backend/ir/emit/mod.rs | 50 ++- src/backend/ir/emit/program.rs | 96 +++++- src/backend/ir/emit/statements.rs | 3 + src/backend/ir/expr.rs | 31 ++ src/backend/ir/lower/decl/functions.rs | 66 ++-- src/backend/ir/lower/expr/mod.rs | 7 +- src/backend/ir/lower/mod.rs | 166 +++++++++- src/backend/ir/trait_bound_inference.rs | 192 ++++++++++- src/frontend/symbols.rs | 2 +- src/frontend/typechecker/check_decl.rs | 22 +- src/frontend/typechecker/check_expr/access.rs | 41 ++- src/frontend/typechecker/check_expr/basics.rs | 29 +- src/frontend/typechecker/check_expr/calls.rs | 26 +- src/frontend/typechecker/check_expr/mod.rs | 15 + src/frontend/typechecker/mod.rs | 23 ++ src/frontend/typechecker/tests.rs | 100 ++++++ .../typechecker/trait_bound_relations.rs | 2 +- src/frontend/typechecker/type_info.rs | 10 +- tests/cli_integration.rs | 305 ++++++++++++++++++ .../semantic_string_audit.json | 2 +- .../codegen_snapshot_tests__classes.snap | 96 ++++++ ...hot_tests__constructor_field_defaults.snap | 12 + ...ot_tests__explicit_call_site_generics.snap | 12 + ...gen_snapshot_tests__fixed_call_unpack.snap | 12 + ...degen_snapshot_tests__generic_methods.snap | 48 +++ ...hot_tests__generic_model_field_access.snap | 12 + ...ssue241_field_backed_method_arg_clone.snap | 24 ++ ...ests__issue246_class_field_visibility.snap | 12 + ...s__issue364_filtered_list_comp_borrow.snap | 12 + ...sts__issue366_clone_self_string_field.snap | 12 + ...apshot_tests__issue380_len_comparison.snap | 12 + ...__issue389_for_tuple_unpack_enumerate.snap | 12 + ...e483_list_comp_tuple_unpack_enumerate.snap | 12 + ...egen_snapshot_tests__list_clone_model.snap | 12 + ...shot_tests__list_pop_clone_only_model.snap | 12 + .../codegen_snapshot_tests__model_struct.snap | 12 + .../codegen_snapshot_tests__models.snap | 96 ++++++ ...shot_tests__newtype_implicit_coercion.snap | 12 + ...shot_tests__rfc024_module_derive_json.snap | 12 + ...ot_tests__rfc024_partial_alias_derive.snap | 12 + ...tests__rfc043_rust_derive_passthrough.snap | 12 + ...hot_tests__rfc046_computed_properties.snap | 12 + .../codegen_snapshot_tests__rust_allow.snap | 24 ++ ...hot_tests__std_async_channel_compiled.snap | 24 ++ ...apshot_tests__std_async_sync_compiled.snap | 12 + ...apshot_tests__std_async_time_compiled.snap | 24 ++ ...ests__std_derives_collection_compiled.snap | 180 +++++++++++ ...en_snapshot_tests__std_graph_compiled.snap | 72 +++++ ...snapshot_tests__std_serde_json_import.snap | 12 + ...tests__std_serde_with_serialize_trait.snap | 12 + ...pshot_tests__std_traits_convert_usage.snap | 24 ++ ...gen_snapshot_tests__std_uuid_compiled.snap | 36 +++ ...tests__trait_supertrait_assignability.snap | 36 +++ ...gen_snapshot_tests__trait_supertraits.snap | 12 + .../codegen_snapshot_tests__traits.snap | 72 +++++ ...hot_tests__uppercase_var_field_access.snap | 12 + ...tests__user_defined_method_decorators.snap | 12 + ...ser_defined_mutable_method_decorators.snap | 12 + ...napshot_tests__user_defined_operators.snap | 48 +++ ...odegen_snapshot_tests__variadic_calls.snap | 12 + ...ot_tests__std_compression_core_source.snap | 12 + ...ed_rust_snapshot_tests__std_io_source.snap | 24 ++ ...shot_tests__std_telemetry_core_source.snap | 48 +++ ...napshot_tests__std_web_prelude_import.snap | 12 + .../docs/language/reference/reflection.md | 27 +- .../language/reference/stdlib/reflection.md | 2 +- .../docs-site/docs/release_notes/0_3.md | 8 +- 81 files changed, 2706 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1eee6c373..d7a1d0ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc25" +version = "0.3.0-rc28" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 5ea3cd155..e880dfc25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc25" +version = "0.3.0-rc28" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/lang/trait_bounds.rs b/crates/incan_core/src/lang/trait_bounds.rs index ca6737f70..0df203347 100644 --- a/crates/incan_core/src/lang/trait_bounds.rs +++ b/crates/incan_core/src/lang/trait_bounds.rs @@ -142,6 +142,8 @@ pub mod rust { // Compiler-provided Incan reflection capabilities pub const INCAN_CLASS_NAME: &str = "incan_stdlib::reflection::HasClassName"; pub const INCAN_FIELD_METADATA: &str = "incan_stdlib::reflection::HasFieldMetadata"; + pub const INCAN_TYPE_CLASS_NAME: &str = "incan_stdlib::reflection::HasTypeClassName"; + pub const INCAN_TYPE_FIELD_METADATA: &str = "incan_stdlib::reflection::HasTypeFieldMetadata"; } /// Look up the Rust trait path for an Incan trait bound name. diff --git a/crates/incan_stdlib/src/lib.rs b/crates/incan_stdlib/src/lib.rs index 7b0fa65c6..47f297197 100644 --- a/crates/incan_stdlib/src/lib.rs +++ b/crates/incan_stdlib/src/lib.rs @@ -51,7 +51,7 @@ pub mod __private { pub mod web; // Re-export commonly used items -pub use reflection::{FieldInfo, HasClassName, HasFieldInfo, HasFieldMetadata}; +pub use reflection::{FieldInfo, HasClassName, HasFieldInfo, HasFieldMetadata, HasTypeClassName, HasTypeFieldMetadata}; #[cfg(feature = "json")] pub use json::{FromJson, ToJson}; diff --git a/crates/incan_stdlib/src/prelude.rs b/crates/incan_stdlib/src/prelude.rs index 679303369..788adef68 100644 --- a/crates/incan_stdlib/src/prelude.rs +++ b/crates/incan_stdlib/src/prelude.rs @@ -7,7 +7,9 @@ //! ``` // Re-export runtime traits and helpers -pub use crate::reflection::{FieldInfo, HasClassName, HasFieldInfo, HasFieldMetadata}; +pub use crate::reflection::{ + FieldInfo, HasClassName, HasFieldInfo, HasFieldMetadata, HasTypeClassName, HasTypeFieldMetadata, +}; // frozen runtime types for consts (RFC 008) pub use crate::frozen::{FrozenBytes, FrozenDict, FrozenList, FrozenSet, FrozenStr}; // Python-like numeric operations (generic entrypoints + compatibility helpers) diff --git a/crates/incan_stdlib/src/reflection.rs b/crates/incan_stdlib/src/reflection.rs index dd1064430..28036fee0 100644 --- a/crates/incan_stdlib/src/reflection.rs +++ b/crates/incan_stdlib/src/reflection.rs @@ -40,12 +40,30 @@ pub trait HasFieldMetadata { fn __fields__(&self) -> FrozenList; } +/// Provides type-level field metadata for generated models and classes. +/// +/// The compiler uses this trait for generic schema helpers that reflect on an explicit type argument, for example +/// `T.__fields__()`, without requiring a dummy runtime value. +pub trait HasTypeFieldMetadata { + /// Returns field metadata for this type. + fn __fields__() -> FrozenList; +} + /// Provides the value-level `__class_name__()` reflection helper for generated models and classes. pub trait HasClassName { /// Returns this value's Incan class/model name. fn __class_name__(&self) -> &'static str; } +/// Provides type-level class/model names for generated models and classes. +/// +/// The compiler uses this trait for generic schema helpers that reflect on an explicit type argument, for example +/// `T.__class_name__()`, without requiring a dummy runtime value. +pub trait HasTypeClassName { + /// Returns this type's Incan class/model name. + fn __class_name__() -> &'static str; +} + /// Runtime value type for field reflection (RFC 021). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FieldInfo { diff --git a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs index 3ba4ec969..db9263b49 100644 --- a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs +++ b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs @@ -418,6 +418,13 @@ pub fn generic_function_reference(name: &str, span: Span) -> CompileError { .with_note("Only monomorphic (non-generic) functions can be passed by name (RFC 035)") } +/// Type error for using a type-like name in value position. +pub fn type_name_used_as_value(name: &str, span: Span) -> CompileError { + CompileError::type_error(format!("Cannot use type '{name}' as a value"), span) + .with_hint("Use the type in a constructor, type argument, or type-owned reflection call") + .with_note("Model and class types are not first-class runtime values") +} + pub fn missing_return_type(span: Span) -> CompileError { CompileError::type_error("Function is missing a return type".to_string(), span) .with_hint("Add a return type annotation: def name(...) -> Type:") diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 8e7ce7c42..1897053a7 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -632,12 +632,18 @@ impl<'a> IrCodegen<'a> { ); dep_lowering.seed_dependency_trait_decls(&self.dependency_modules); dep_lowering.seed_struct_field_aliases(global_aliases.clone()); - let dep_ir = dep_lowering.lower_program(dep_ast)?; + let mut dep_ir = dep_lowering.lower_program(dep_ast)?; + super::trait_bound_inference::infer_trait_bounds(&mut dep_ir); let module_path = dep_path_segments .clone() .unwrap_or_else(|| vec![(*dep_name).to_string()]); dependency_ir_programs.push((module_path, dep_ir)); } + let dependency_programs = dependency_ir_programs + .iter() + .map(|(_, dep_ir)| dep_ir) + .collect::>(); + super::trait_bound_inference::propagate_trait_bounds_from_programs(&mut ir_program, &dependency_programs); let canonical_registry = Self::canonical_registry_for_programs( dependency_ir_programs .iter() @@ -714,7 +720,7 @@ impl<'a> IrCodegen<'a> { /// /// Returns `GenerationError::Lowering` if AST lowering fails, or /// `GenerationError::Emission` if IR emission fails. - pub fn try_generate_module(&mut self, _module_name: &str, program: &Program) -> Result { + pub fn try_generate_module(&mut self, module_name: &str, program: &Program) -> Result { // Use the IR pipeline for module generation too let mut lowering = AstLowering::new(); lowering.set_current_source_module_name( @@ -723,10 +729,35 @@ impl<'a> IrCodegen<'a> { .as_deref() .and_then(crate::frontend::module::logical_module_name_from_source_path), ); + lowering.seed_dependency_trait_decls(&self.dependency_modules); let mut ir_program = lowering.lower_program(program)?; // RFC 023: Infer trait bounds for generic functions. super::trait_bound_inference::infer_trait_bounds(&mut ir_program); + let mut dependency_ir_programs = Vec::new(); + for (dep_name, dep_ast, dep_path_segments) in &self.dependency_modules { + if *dep_name == module_name { + continue; + } + let mut dep_lowering = AstLowering::new(); + dep_lowering.set_current_source_module_name( + dep_path_segments + .clone() + .map(|segments| segments.join(".")) + .or_else(|| { + dep_ast + .source_path + .as_deref() + .and_then(crate::frontend::module::logical_module_name_from_source_path) + }), + ); + dep_lowering.seed_dependency_trait_decls(&self.dependency_modules); + let mut dep_ir = dep_lowering.lower_program(dep_ast)?; + super::trait_bound_inference::infer_trait_bounds(&mut dep_ir); + dependency_ir_programs.push(dep_ir); + } + let dependency_programs = dependency_ir_programs.iter().collect::>(); + super::trait_bound_inference::propagate_trait_bounds_from_programs(&mut ir_program, &dependency_programs); // Best-effort: treat registered dependency module names as internal roots. // (This is most relevant for the non-nested multi-file API.) @@ -2388,6 +2419,90 @@ def main() -> None: must_some(modules.get("store"), "missing generated non-nested store module").to_string() } + fn nested_module_code(modules: &[(&str, &str, Vec<&str>)], target_path: &[&str]) -> String { + let main_module = main_module_program(); + let mut codegen = IrCodegen::new(); + let parsed_modules = modules + .iter() + .map(|(flat_name, source, path)| { + ( + (*flat_name).to_string(), + parse_program(source), + path.iter().map(|segment| (*segment).to_string()).collect::>(), + ) + }) + .collect::>(); + for (flat_name, program, _) in &parsed_modules { + codegen.add_module(flat_name, program); + } + let paths = parsed_modules + .iter() + .map(|(_, _, path)| path.clone()) + .collect::>(); + + let (_main_code, rust_modules) = must_ok(codegen.try_generate_multi_file_nested(&main_module, &paths)); + let target = target_path + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); + must_some(rust_modules.get(&target), "missing generated nested target module").to_string() + } + + #[test] + fn nested_decorated_generic_original_inherits_imported_reflection_bounds() { + let code = nested_module_code( + &[ + ( + "substrait_schema", + r#" +def requires_clone[T with Clone]() -> str: + return "clone" + +pub def reflected_schema_marker[T]() -> str: + return f"{T.__class_name__()}:{len(T.__fields__())}:{requires_clone[T]()}" +"#, + vec!["substrait", "schema"], + ), + ( + "functions_csv_from_csv", + r#" +from substrait.schema import reflected_schema_marker + +def registered_application(parts: list[str]) -> str: + return parts[0] + +def register[F]() -> ((F) -> F): + return (func) => remember[F](func) + +def remember[F](func: F) -> F: + if func.__name__ == "": + return func + return func + +@register() +pub def from_csv[T]() -> str: + return registered_application([reflected_schema_marker[T]()]) +"#, + vec!["functions", "csv", "from_csv"], + ), + ], + &["functions", "csv", "from_csv"], + ); + + assert!( + code.contains("fn __incan_original_from_csv<\n T: incan_stdlib::reflection::HasTypeClassName") + || code + .contains("fn __incan_original_from_csv<\n T: incan_stdlib::reflection::HasTypeFieldMetadata"), + "{code}" + ); + assert!( + code.contains("incan_stdlib::reflection::HasTypeClassName") + && code.contains("incan_stdlib::reflection::HasTypeFieldMetadata") + && code.contains("+ Clone"), + "{code}" + ); + } + #[test] fn test_simple_function() { let code = generate( diff --git a/src/backend/ir/conversions.rs b/src/backend/ir/conversions.rs index 25e2abfd3..2497b0ae8 100644 --- a/src/backend/ir/conversions.rs +++ b/src/backend/ir/conversions.rs @@ -807,6 +807,11 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: (IrExprKind::Field { .. }, Some(IrType::String)) if matches!(expr.ty, IrType::String) => { Conversion::Clone } + (_, Some(IrType::StrRef)) + if matches!(expr.ty, IrType::String) && !expr_has_rust_reference_shape(expr) => + { + Conversion::Borrow + } (IrExprKind::Var { .. }, _) if matches!(expr.ty, IrType::String) => { if expr_has_rust_reference_shape(expr) { Conversion::None @@ -821,6 +826,9 @@ pub fn determine_conversion(expr: &IrExpr, target_ty: Option<&IrType>, context: Conversion::Borrow } } + (_, None) if matches!(expr.ty, IrType::String) && !expr_has_rust_reference_shape(expr) => { + Conversion::Borrow + } (_, Some(IrType::Ref(_))) if !expr_has_rust_reference_shape(expr) => Conversion::Borrow, (_, Some(IrType::RefMut(_))) if !expr_has_rust_reference_shape(expr) => Conversion::MutBorrow, // Rust adapter leaves commonly accept borrowed handles (`&Sender`, `&Mutex`, ...). @@ -1399,6 +1407,17 @@ mod tests { assert_eq!(conv, Conversion::Borrow); } + #[test] + fn test_external_function_string_expression_to_str_ref_borrows_issue716() { + let expr = IrExpr::new(IrExprKind::Format { parts: Vec::new() }, IrType::String); + + let conv = determine_conversion(&expr, Some(&IrType::StrRef), ConversionContext::ExternalFunctionArg); + assert_eq!(conv, Conversion::Borrow); + + let conv = determine_conversion(&expr, None, ConversionContext::ExternalFunctionArg); + assert_eq!(conv, Conversion::Borrow); + } + #[test] fn test_external_function_as_slice_arg_does_not_double_borrow() { let expr = IrExpr::new( diff --git a/src/backend/ir/emit/decls/functions.rs b/src/backend/ir/emit/decls/functions.rs index 2b0665b02..2a2989a22 100644 --- a/src/backend/ir/emit/decls/functions.rs +++ b/src/backend/ir/emit/decls/functions.rs @@ -122,6 +122,12 @@ impl<'a> IrEmitter<'a> { Self::rewrite_borrowed_param_types_in_expr(&mut arg.expr, borrowed); } } + IrExprKind::RegisterCallableName { callable, .. } => { + Self::rewrite_borrowed_param_types_in_expr(callable, borrowed); + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + Self::rewrite_borrowed_param_types_in_expr(value, borrowed); + } IrExprKind::BuiltinCall { args, .. } => { for arg in args { Self::rewrite_borrowed_param_types_in_expr(arg, borrowed); @@ -292,6 +298,7 @@ impl<'a> IrEmitter<'a> { | IrExprKind::StaticRead { .. } | IrExprKind::StaticBinding { .. } | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Literal(_) | IrExprKind::FieldsList(_) | IrExprKind::SerdeToJson @@ -1297,7 +1304,13 @@ impl<'a> IrEmitter<'a> { IrExprKind::Var { name, .. } | IrExprKind::StaticRead { name } | IrExprKind::StaticBinding { name } => { Self::note_param_use(name, param_names, shadowed_names, used_names); } - IrExprKind::AssociatedFunction { .. } => {} + IrExprKind::AssociatedFunction { .. } | IrExprKind::FunctionItem { .. } => {} + IrExprKind::RegisterCallableName { callable, .. } => { + Self::collect_expr_used_names(callable, param_names, shadowed_names, used_names); + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + Self::collect_expr_used_names(value, param_names, shadowed_names, used_names); + } IrExprKind::BinOp { left, right, .. } => { Self::collect_expr_used_names(left, param_names, shadowed_names, used_names); Self::collect_expr_used_names(right, param_names, shadowed_names, used_names); diff --git a/src/backend/ir/emit/decls/structures.rs b/src/backend/ir/emit/decls/structures.rs index a0774e5b2..68ef4a7f3 100644 --- a/src/backend/ir/emit/decls/structures.rs +++ b/src/backend/ir/emit/decls/structures.rs @@ -191,6 +191,12 @@ impl<'a> IrEmitter<'a> { quote! { impl #generics incan_stdlib::reflection::HasClassName for #name #generics_bare { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } + } + + impl #generics incan_stdlib::reflection::HasTypeClassName for #name #generics_bare { + fn __class_name__() -> &'static str { #class_name } } @@ -204,6 +210,12 @@ impl<'a> IrEmitter<'a> { quote! { impl #generics incan_stdlib::reflection::HasFieldMetadata for #name #generics_bare { fn __fields__(&self) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } + } + + impl #generics incan_stdlib::reflection::HasTypeFieldMetadata for #name #generics_bare { + fn __fields__() -> incan_stdlib::frozen::FrozenList { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; #field_count] = [#(#field_infos),*]; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } diff --git a/src/backend/ir/emit/expressions/comprehensions.rs b/src/backend/ir/emit/expressions/comprehensions.rs index 34d4bd50c..fe143d333 100644 --- a/src/backend/ir/emit/expressions/comprehensions.rs +++ b/src/backend/ir/emit/expressions/comprehensions.rs @@ -706,6 +706,8 @@ impl<'a> IrEmitter<'a> { FormatPart::Literal(_) => false, FormatPart::Expr { expr, .. } => Self::expr_contains_try(expr), }), + IrExprKind::RegisterCallableName { callable, .. } => Self::expr_contains_try(callable), + IrExprKind::CacheGenericDecoratedFunction { value, .. } => Self::expr_contains_try(value), IrExprKind::Unit | IrExprKind::None | IrExprKind::Bool(_) @@ -719,6 +721,7 @@ impl<'a> IrEmitter<'a> { | IrExprKind::StaticRead { .. } | IrExprKind::StaticBinding { .. } | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Literal(_) | IrExprKind::FieldsList(_) | IrExprKind::SerdeToJson diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index c43d7cec1..0156e9320 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -23,6 +23,7 @@ use incan_core::interop::{ MetadataFreeReceiverClass, RustCollectionFamily, }; use incan_core::lang::surface::result_methods::{self, ResultMethodId}; +use incan_core::lang::{magic_methods, trait_bounds::rust as tb}; mod collection_methods; mod fast_paths; @@ -34,6 +35,14 @@ use fast_paths::emit_registered_method_fast_path; use iterator_methods::emit_iterator_method; use string_methods::emit_string_method; +fn type_reflection_trait_path(method: &str) -> Option<&'static str> { + match magic_methods::from_str(method) { + Some(magic_methods::MagicMethodId::ClassName) => Some(tb::INCAN_TYPE_CLASS_NAME), + Some(magic_methods::MagicMethodId::Fields) => Some(tb::INCAN_TYPE_FIELD_METADATA), + _ => None, + } +} + /// Compute common receiver setup for method emission. /// /// This deduplicates the pattern of: @@ -847,7 +856,7 @@ impl<'a> IrEmitter<'a> { arg_policy: MethodCallArgPolicy, result_use_site: ValueUseSite<'_>, ) -> Result { - self.emit_method_call_expr_with_result_use( + let emitted = self.emit_method_call_expr_with_result_use( receiver, method, dispatch, @@ -856,7 +865,13 @@ impl<'a> IrEmitter<'a> { callable_signature, arg_policy, Some(result_use_site), - ) + )?; + if magic_methods::from_str(method) == Some(magic_methods::MagicMethodId::ClassName) + && matches!(Self::use_site_target_ty(result_use_site), Some(IrType::String)) + { + return Ok(quote! { (#emitted).to_string() }); + } + Ok(emitted) } /// Shared method-call emitter used by plain and target-aware method emission. @@ -963,6 +978,32 @@ impl<'a> IrEmitter<'a> { } } + if let IrExprKind::Var { + name, + ref_kind: VarRefKind::TypeName, + .. + } = &receiver.kind + && args.is_empty() + && type_args.is_empty() + && let Some(trait_path) = type_reflection_trait_path(method) + { + let receiver_ty = match &receiver.ty { + IrType::Unknown => IrType::Struct(name.clone()), + ty => ty.clone(), + }; + let receiver_tokens = self.emit_type(&receiver_ty); + let path_tokens: Vec = trait_path + .split("::") + .map(|segment| { + let ident = Self::rust_ident(segment); + quote! { #ident } + }) + .collect(); + let trait_tokens = super::super::decls::join_path_tokens(&path_tokens); + let m = Self::rust_ident(method); + return Ok(quote! { <#receiver_tokens as #trait_tokens>::#m() }); + } + // Associated function call on a type: `Type.method(...)` → `Type::method(...)` // // This is needed for external Rust types like `Uuid`, `Instant`, `HashMap`, and also for diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 22a07a11f..9c42692b3 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -104,6 +104,72 @@ impl<'a> IrEmitter<'a> { .with_span(expr.span) } + /// Emit explicit callable-name metadata for a concrete function pointer. + fn emit_register_callable_name(&self, callable: &TypedExpr, source_name: &str) -> Result { + let IrType::Function { params, ret } = &callable.ty else { + return Ok(quote! { () }); + }; + let Some(signature_key) = Self::callable_name_signature_key(params, ret) else { + return Ok(quote! { () }); + }; + let register = Self::callable_name_register_ident(&signature_key); + let fn_ty = self.emit_callable_fn_type(params, ret); + let callable = self.emit_expr(callable)?; + let source_name = Literal::string(source_name); + Ok(quote! {{ + let __incan_callable: #fn_ty = #callable; + #register(__incan_callable, #source_name); + }}) + } + + fn emit_cache_generic_decorated_function( + &self, + cache_name: &str, + type_param_names: &[String], + value: &TypedExpr, + ) -> Result { + if !matches!(value.ty, IrType::Function { .. }) { + return Err(EmitError::Unsupported( + "generic decorated function cache requires a function pointer type".to_string(), + )); + } + let cache_ident = Self::rust_static_ident(&format!("__incan_generic_decorated_{cache_name}")); + let fn_ty = self.emit_type(&value.ty); + let value_tokens = self.emit_expr(value)?; + let type_key_parts = type_param_names + .iter() + .map(|name| { + let ident = Self::rust_ident(name); + quote! { std::any::type_name::<#ident>() } + }) + .collect::>(); + let type_key = if type_key_parts.is_empty() { + quote! { String::new() } + } else { + quote! { [#(#type_key_parts),*].join("\u{1f}") } + }; + + Ok(quote! {{ + static #cache_ident: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let __incan_type_key = #type_key; + let mut __incan_entries = #cache_ident + .get_or_init(|| std::sync::Mutex::new(Vec::new())) + .lock() + .unwrap_or_else(|__incan_poisoned| __incan_poisoned.into_inner()); + if let Some((_, __incan_cached)) = __incan_entries + .iter() + .find(|(__incan_key, _)| __incan_key == &__incan_type_key) + { + *__incan_cached + } else { + let __incan_decorated = #value_tokens; + __incan_entries.push((__incan_type_key, __incan_decorated)); + __incan_decorated + } + }}) + } + /// Emit one list-literal element, materializing owned sink semantics at the literal boundary. /// /// Incan `list[str]` literals should store owned Rust `String` elements up front, but ordinary Incan-to-Incan @@ -732,6 +798,26 @@ impl<'a> IrEmitter<'a> { Ok(quote! { #type_ident :: #function_ident }) } + IrExprKind::FunctionItem { name, type_args } => { + let ident = Self::rust_ident(name); + if type_args.is_empty() { + Ok(quote! { #ident }) + } else { + let args: Vec<_> = type_args.iter().map(|ty| self.emit_type(ty)).collect(); + Ok(quote! { #ident :: < #(#args),* > }) + } + } + + IrExprKind::RegisterCallableName { callable, source_name } => { + self.emit_register_callable_name(callable, source_name) + } + + IrExprKind::CacheGenericDecoratedFunction { + cache_name, + type_param_names, + value, + } => self.emit_cache_generic_decorated_function(cache_name, type_param_names, value), + IrExprKind::BinOp { op, left, right } => self.emit_binop_expr(op, left, right), IrExprKind::UnaryOp { op, operand } => { diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 37f632389..034999a45 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -83,6 +83,28 @@ pub(crate) struct CallableNameUseFacts { pub(crate) generic_trait_used: bool, } +/// Generated callable-name symbol roles for one concrete function-pointer signature. +#[derive(Debug, Clone, Copy)] +enum CallableNameSymbolRole { + /// Resolve a function pointer to a source name, using static candidates first and dynamic registrations second. + Resolver, + /// Return the shared dynamic-name storage for generic/decorated callables with this signature. + Registry, + /// Insert or update one dynamic callable-name registration for this signature. + Register, +} + +impl CallableNameSymbolRole { + /// Return the stable generated Rust symbol prefix for this helper role. + const fn prefix(self) -> &'static str { + match self { + Self::Resolver => "__incan_callable_name", + Self::Registry => "__incan_callable_name_registry", + Self::Register => "__incan_register_callable_name", + } + } +} + /// Usage facts collected before Rust emission. /// /// This analysis is intentionally about generated Rust lints, not source-language reachability diagnostics. It records @@ -486,14 +508,36 @@ impl<'a> IrEmitter<'a> { } } - /// Return the deterministic helper identifier for a concrete callable signature key. - pub(super) fn callable_name_helper_ident(key: &str) -> proc_macro2::Ident { + /// Return a deterministic generated symbol for one callable-name helper role and concrete signature key. + fn callable_name_symbol_ident(role: CallableNameSymbolRole, key: &str) -> proc_macro2::Ident { format_ident!( - "__incan_callable_name_{:016x}", + "{}_{:016x}", + role.prefix(), Self::stable_callable_name_hash(key.as_bytes()) ) } + /// Return the generated resolver helper identifier for a concrete callable signature key. + /// + /// The resolver checks same-module static function candidates and then the per-signature dynamic registry. + pub(super) fn callable_name_helper_ident(key: &str) -> proc_macro2::Ident { + Self::callable_name_symbol_ident(CallableNameSymbolRole::Resolver, key) + } + + /// Return the generated dynamic-name registration helper identifier for a concrete callable signature key. + /// + /// The registration helper records runtime metadata for concrete generic/decorated function values. + pub(super) fn callable_name_register_ident(key: &str) -> proc_macro2::Ident { + Self::callable_name_symbol_ident(CallableNameSymbolRole::Register, key) + } + + /// Return the generated dynamic-name registry accessor identifier for a concrete callable signature key. + /// + /// The registry accessor owns the per-signature `OnceLock>` used by the registration helper. + pub(super) fn callable_name_registry_ident(key: &str) -> proc_macro2::Ident { + Self::callable_name_symbol_ident(CallableNameSymbolRole::Registry, key) + } + /// Return a stable signature key for callable-name helpers when the function-pointer type is concrete. pub(super) fn callable_name_signature_key(params: &[IrType], ret: &IrType) -> Option { if !params.iter().all(Self::callable_name_type_supported) || !Self::callable_name_type_supported(ret) { diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index ff33de43b..24271f6f7 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -545,6 +545,23 @@ impl<'program> GeneratedUseAnalyzer<'program> { .insert((type_name.clone(), original_name.to_string())); } } + IrExprKind::FunctionItem { name, type_args } => { + self.mark_reachable_item(name); + for ty in type_args { + self.scan_type(ty); + } + } + IrExprKind::RegisterCallableName { callable, .. } => { + self.scan_expr(callable); + if let IrType::Function { params, ret } = &callable.ty + && let Some(key) = IrEmitter::callable_name_signature_key(params, ret) + { + self.analysis.callable_name_signature_keys.insert(key); + } + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + self.scan_expr(value); + } IrExprKind::BinOp { left, right, .. } => { self.scan_expr(left); self.scan_expr(right); @@ -833,9 +850,23 @@ impl<'program> GeneratedUseAnalyzer<'program> { keys.sort(); keys } + IrExprKind::FunctionItem { .. } => { + let mut keys = HashSet::new(); + if let IrType::Function { params, ret } = &expr.ty + && let Some(key) = IrEmitter::callable_name_signature_key(params, ret) + { + keys.insert(key); + } + let mut keys = keys.into_iter().collect::>(); + keys.sort(); + keys + } IrExprKind::InteropCoerce { expr, .. } | IrExprKind::NumericResize { expr, .. } | IrExprKind::Cast { expr, .. } => self.callable_name_function_arg_signature_keys(expr), + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + self.callable_name_function_arg_signature_keys(value) + } _ => Vec::new(), } } @@ -1771,6 +1802,10 @@ impl<'a> IrEmitter<'a> { | IrExprKind::Cast { expr: operand, .. } | IrExprKind::NumericResize { expr: operand, .. } | IrExprKind::InteropCoerce { expr: operand, .. } => Self::collect_union_types_from_expr(operand, out), + IrExprKind::RegisterCallableName { callable, .. } => Self::collect_union_types_from_expr(callable, out), + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + Self::collect_union_types_from_expr(value, out); + } IrExprKind::Index { object, index } => { Self::collect_union_types_from_expr(object, out); Self::collect_union_types_from_expr(index, out); @@ -1922,6 +1957,7 @@ impl<'a> IrEmitter<'a> { | IrExprKind::String(_) | IrExprKind::Bytes(_) | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Var { .. } | IrExprKind::StaticRead { .. } | IrExprKind::StaticBinding { .. } @@ -2446,18 +2482,22 @@ impl<'a> IrEmitter<'a> { fn emit_callable_name_helpers( &self, emitted_callable_names: &HashSet, + dynamic_only_callable_names: &HashSet, keys: &[String], ) -> Vec { keys.iter() .filter_map(|key| { let (params, ret) = self.callable_name_signature_for_key(key)?; let helper = Self::callable_name_helper_ident(key); + let registry = Self::callable_name_registry_ident(key); + let register = Self::callable_name_register_ident(key); let fn_ty = self.emit_callable_fn_type(¶ms, &ret); let mut candidates = self .callable_name_local_registry() .iter() .filter(|(name, signature)| { emitted_callable_names.contains(*name) + && !dynamic_only_callable_names.contains(*name) && signature.params.len() == params.len() && signature.params.iter().map(|param| ¶m.ty).eq(params.iter()) && signature.return_type == ret @@ -2468,9 +2508,20 @@ impl<'a> IrEmitter<'a> { }) .collect::>(); candidates.sort_by(|left, right| left.0.cmp(&right.0)); - let has_candidates = !candidates.is_empty(); - let mut body = quote! { None }; + let dynamic_lookup = quote! {{ + let __incan_entries = #registry() + .lock() + .unwrap_or_else(|__incan_poisoned| __incan_poisoned.into_inner()); + __incan_entries.iter().rev().find_map(|(__incan_registered, __incan_name)| { + if std::ptr::fn_addr_eq(*__incan_registered, callable) { + Some(*__incan_name) + } else { + None + } + }) + }}; + let mut body = dynamic_lookup; for (candidate, source_name) in candidates.into_iter().rev() { let candidate_ident = Self::rust_ident(&candidate); let source_literal = proc_macro2::Literal::string(&source_name); @@ -2482,11 +2533,6 @@ impl<'a> IrEmitter<'a> { } }; } - let callable_param = if has_candidates { - Self::rust_ident("callable") - } else { - Self::rust_ident("_callable") - }; let visibility = if self.callable_name_resolutions.get(key).is_some_and(|resolution| { self.callable_name_used_signature_keys.contains(key) @@ -2503,8 +2549,29 @@ impl<'a> IrEmitter<'a> { }); Some(quote! { + fn #registry() -> &'static std::sync::Mutex> { + static __INCAN_CALLABLE_NAMES: + std::sync::OnceLock>> = + std::sync::OnceLock::new(); + __INCAN_CALLABLE_NAMES.get_or_init(|| std::sync::Mutex::new(Vec::new())) + } + + fn #register(callable: #fn_ty, source_name: &'static str) { + let mut __incan_entries = #registry() + .lock() + .unwrap_or_else(|__incan_poisoned| __incan_poisoned.into_inner()); + if let Some((_, __incan_name)) = __incan_entries + .iter_mut() + .find(|(__incan_registered, _)| std::ptr::fn_addr_eq(*__incan_registered, callable)) + { + *__incan_name = source_name; + } else { + __incan_entries.push((callable, source_name)); + } + } + #private_interfaces_allow - #visibility fn #helper(#callable_param: #fn_ty) -> Option<&'static str> { + #visibility fn #helper(callable: #fn_ty) -> Option<&'static str> { #body } }) @@ -2685,7 +2752,18 @@ impl<'a> IrEmitter<'a> { _ => None, }) .collect(); - items.extend(self.emit_callable_name_helpers(&emitted_callable_names, &callable_name_helper_keys)); + let dynamic_only_callable_names: HashSet = emitted_declarations + .iter() + .filter_map(|decl| match &decl.kind { + IrDeclKind::Function(func) if func.is_async || !func.type_params.is_empty() => Some(func.name.clone()), + _ => None, + }) + .collect(); + items.extend(self.emit_callable_name_helpers( + &emitted_callable_names, + &dynamic_only_callable_names, + &callable_name_helper_keys, + )); if uses_generic_callable_name_trait && let Some(trait_item) = self.emit_generic_callable_name_trait(&callable_name_helper_keys) { diff --git a/src/backend/ir/emit/statements.rs b/src/backend/ir/emit/statements.rs index c0bf6e20f..a66728a0f 100644 --- a/src/backend/ir/emit/statements.rs +++ b/src/backend/ir/emit/statements.rs @@ -776,6 +776,8 @@ fn expr_uses_binding_name(expr: &super::super::expr::IrExpr, binding_name: &str) super::super::expr::FormatPart::Expr { expr, .. } => expr_uses_binding_name(expr, binding_name), super::super::expr::FormatPart::Literal(_) => false, }), + IrExprKind::RegisterCallableName { callable, .. } => expr_uses_binding_name(callable, binding_name), + IrExprKind::CacheGenericDecoratedFunction { value, .. } => expr_uses_binding_name(value, binding_name), IrExprKind::Unit | IrExprKind::None | IrExprKind::Bool(_) @@ -786,6 +788,7 @@ fn expr_uses_binding_name(expr: &super::super::expr::IrExpr, binding_name: &str) | IrExprKind::String(_) | IrExprKind::Bytes(_) | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Literal(_) | IrExprKind::FieldsList(_) | IrExprKind::SerdeToJson diff --git a/src/backend/ir/expr.rs b/src/backend/ir/expr.rs index 48d172ad1..1507f6cc2 100644 --- a/src/backend/ir/expr.rs +++ b/src/backend/ir/expr.rs @@ -144,6 +144,37 @@ pub enum IrExprKind { function_name: String, }, + /// Reference a free function item with optional explicit type arguments. + /// + /// Generic decorated wrappers need to pass `__incan_original_name::` as a callable value after the wrapper has + /// a concrete type-parameter environment. A plain variable reference cannot carry that turbofish. + FunctionItem { + name: String, + type_args: Vec, + }, + + /// Register a generated function pointer with its source callable name. + /// + /// Generic decorated wrappers instantiate `__incan_original_name::` at runtime. Rust can coerce that + /// monomorphized item to a function pointer, but a global function-pointer trait impl cannot name the originating + /// generic declaration. This expression records explicit compiler metadata for that concrete pointer before the + /// decorator sees it. + RegisterCallableName { + callable: Box, + source_name: String, + }, + + /// Cache one decorated generic function value by concrete type-argument key. + /// + /// Generic decorators that return the same callable surface are still declaration-side metadata hooks. When the + /// callable signature itself does not mention the generic type parameters, generated Rust can keep one decorated + /// function pointer per concrete type-argument tuple and avoid replaying decorator side effects on every call. + CacheGenericDecoratedFunction { + cache_name: String, + type_param_names: Vec, + value: Box, + }, + // Binary operations BinOp { op: BinOp, diff --git a/src/backend/ir/lower/decl/functions.rs b/src/backend/ir/lower/decl/functions.rs index 716f9362f..81ebcd579 100644 --- a/src/backend/ir/lower/decl/functions.rs +++ b/src/backend/ir/lower/decl/functions.rs @@ -212,10 +212,17 @@ fn collect_generic_callable_name_type_params_from_expr(expr: &super::super::supe } } } + IrExprKind::RegisterCallableName { callable, .. } => { + collect_generic_callable_name_type_params_from_expr(callable, out); + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + collect_generic_callable_name_type_params_from_expr(value, out); + } IrExprKind::Var { .. } | IrExprKind::StaticRead { .. } | IrExprKind::StaticBinding { .. } | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Unit | IrExprKind::None | IrExprKind::Bool(_) @@ -500,32 +507,7 @@ impl AstLowering { if !self.is_user_defined_decorator_candidate(&decorator.node) { continue; } - let callable = if decorator.node.is_call { - let args = Self::decorator_call_args(decorator)?; - let path = &decorator.node.path.segments; - if path.len() >= 2 { - let base_path = ImportPath { - parent_levels: decorator.node.path.parent_levels, - is_absolute: decorator.node.path.is_absolute, - segments: path[..path.len() - 1].to_vec(), - }; - let base = - Self::decorator_path_expr_from_import_path(&base_path, Self::decorator_synthetic_callee_span()); - let method = path.last().cloned().unwrap_or_default(); - Spanned::new( - Expr::MethodCall(Box::new(base), method, decorator.node.type_args.clone(), args), - decorator.span, - ) - } else { - let callee = Self::decorator_path_expr(&decorator.node, Self::decorator_synthetic_callee_span()); - Spanned::new( - Expr::Call(Box::new(callee), decorator.node.type_args.clone(), args), - decorator.span, - ) - } - } else { - Self::decorator_path_expr(&decorator.node, decorator.span) - }; + let callable = Self::decorator_callable_expr(decorator)?; current = Spanned::new( Expr::Call(Box::new(callable), Vec::new(), vec![ast::CallArg::Positional(current)]), Self::decorator_synthetic_callee_span(), @@ -534,6 +516,38 @@ impl AstLowering { Ok(current) } + /// Build the callable expression for one decorator before it is applied to the decorated function value. + pub(in crate::backend::ir::lower) fn decorator_callable_expr( + decorator: &Spanned, + ) -> Result, LoweringError> { + if decorator.node.is_call { + let args = Self::decorator_call_args(decorator)?; + let path = &decorator.node.path.segments; + if path.len() >= 2 { + let base_path = ImportPath { + parent_levels: decorator.node.path.parent_levels, + is_absolute: decorator.node.path.is_absolute, + segments: path[..path.len() - 1].to_vec(), + }; + let base = + Self::decorator_path_expr_from_import_path(&base_path, Self::decorator_synthetic_callee_span()); + let method = path.last().cloned().unwrap_or_default(); + Ok(Spanned::new( + Expr::MethodCall(Box::new(base), method, decorator.node.type_args.clone(), args), + decorator.span, + )) + } else { + let callee = Self::decorator_path_expr(&decorator.node, Self::decorator_synthetic_callee_span()); + Ok(Spanned::new( + Expr::Call(Box::new(callee), decorator.node.type_args.clone(), args), + decorator.span, + )) + } + } else { + Ok(Self::decorator_path_expr(&decorator.node, decorator.span)) + } + } + /// Convert parsed decorator arguments into ordinary call arguments for lowering. pub(in crate::backend::ir::lower) fn decorator_call_args( decorator: &Spanned, diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index a4b37b977..520f8b303 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -865,12 +865,17 @@ impl AstLowering { arg_ir.expr = self.wrap_with_rust_arg_coercion(arg_ir.expr.clone(), arg_span)?; } } - let expr_ty = self + let mut expr_ty = self .type_info .as_ref() .and_then(|info| info.expr_type(expr_span)) .map(|ty| self.lower_resolved_type(ty)) .unwrap_or(IrType::Unknown); + if magic_methods::from_str(&method_name) == Some(MagicMethodId::ClassName) + && matches!(expr_ty, IrType::String) + { + expr_ty = IrType::StaticStr; + } let dispatch = self .type_info .as_ref() diff --git a/src/backend/ir/lower/mod.rs b/src/backend/ir/lower/mod.rs index 55763f011..a040ccfe7 100644 --- a/src/backend/ir/lower/mod.rs +++ b/src/backend/ir/lower/mod.rs @@ -35,7 +35,7 @@ mod types; use std::collections::{HashMap, HashSet}; use super::TypedExpr; -use super::decl::{FunctionParam, IrDecl, IrDeclKind, IrImportOrigin, IrImportQualifier}; +use super::decl::{FunctionParam, IrDecl, IrDeclKind, IrImportOrigin, IrImportQualifier, IrTypeParam}; use super::expr::{IrCallArg, IrCallArgKind, IrExprKind, VarAccess, VarRefKind}; use super::stmt::{IrStmt, IrStmtKind}; use super::types::IrType; @@ -1743,6 +1743,24 @@ impl AstLowering { ret: Box::new(self.lower_resolved_type(&callable_ret)), }; + if !original.type_params.is_empty() { + let wrapper = self.generic_decorated_function_wrapper( + f, + &original_name, + &callable_params, + &original_params, + callable_ret.as_ref(), + &original.params, + &original.return_type, + original.type_params.clone(), + decorated_ty, + )?; + return Ok(vec![ + IrDecl::new(IrDeclKind::Function(original)), + IrDecl::new(IrDeclKind::Function(wrapper)), + ]); + } + let decorator_expr = self.decorator_application_expr(&f.name, &f.decorators)?; let mut value = self.lower_expr_spanned(&decorator_expr)?; value.ty = decorated_ty.clone(); @@ -1767,6 +1785,152 @@ impl AstLowering { ]) } + /// Lower a generic decorated function wrapper by applying decorators in the wrapper's concrete type-parameter + /// environment. + /// + /// A module-level static can store a monomorphic decorated function value, but it cannot store "the decorated + /// version of `f[T]` for every `T`". For generic declarations, the wrapper keeps the source type parameters and + /// applies the decorator chain to `__incan_original_f::` at the call site before invoking the result. + #[allow(clippy::too_many_arguments)] + fn generic_decorated_function_wrapper( + &mut self, + f: &ast::FunctionDecl, + original_name: &str, + callable_params: &[CallableParam], + original_params: &[CallableParam], + callable_ret: &crate::frontend::symbols::ResolvedType, + original_function_params: &[FunctionParam], + original_return_type: &IrType, + type_params: Vec, + decorated_ty: IrType, + ) -> Result { + let defaults = self.decorated_param_defaults_for_surface(callable_params, original_params, &f.params); + let params = self.function_params_from_callable_surface(callable_params, &defaults); + let return_type = self.lower_resolved_type(callable_ret); + let type_args = type_params + .iter() + .map(|param| IrType::Generic(param.name.clone())) + .collect::>(); + let original_ty = IrType::Function { + params: original_function_params.iter().map(|param| param.ty.clone()).collect(), + ret: Box::new(original_return_type.clone()), + }; + let original_ref = TypedExpr::new( + IrExprKind::FunctionItem { + name: original_name.to_string(), + type_args, + }, + original_ty.clone(), + ); + let original_ref = TypedExpr::new( + IrExprKind::Cast { + expr: Box::new(original_ref), + to_type: original_ty.clone(), + }, + original_ty, + ); + let register_callable_name = TypedExpr::new( + IrExprKind::RegisterCallableName { + callable: Box::new(original_ref.clone()), + source_name: f.name.clone(), + }, + IrType::Unit, + ); + let mut decorated_func = + self.lower_decorator_application_value(&f.decorators, original_ref, decorated_ty.clone())?; + if !decorated_ty.contains_generic_parameter() { + decorated_func = TypedExpr::new( + IrExprKind::CacheGenericDecoratedFunction { + cache_name: f.name.clone(), + type_param_names: type_params.iter().map(|param| param.name.clone()).collect(), + value: Box::new(decorated_func), + }, + decorated_ty, + ); + } + let args = params + .iter() + .map(|param| IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: TypedExpr::new( + IrExprKind::Var { + name: param.name.clone(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + param.ty.clone(), + ), + }) + .collect(); + let call = TypedExpr::new( + IrExprKind::Call { + func: Box::new(decorated_func), + type_args: Vec::new(), + args, + callable_signature: None, + canonical_path: None, + }, + return_type.clone(), + ); + + Ok(super::decl::IrFunction { + name: f.name.clone(), + params, + return_type, + body: vec![ + IrStmt::new(IrStmtKind::Expr(register_callable_name)), + IrStmt::new(IrStmtKind::Return(Some(call))), + ], + is_async: f.is_async(), + is_generator: false, + visibility: Self::map_visibility(f.visibility), + type_params, + is_extern: false, + rust_attributes: Vec::new(), + lint_allows: Vec::new(), + }) + } + + /// Lower the callable value for one decorator without applying it to the decorated function. + fn lower_decorator_callable_value( + &mut self, + decorator: &ast::Spanned, + ) -> Result { + let expr = Self::decorator_callable_expr(decorator)?; + self.lower_expr_spanned(&expr) + } + + /// Lower the bottom-up decorator application chain starting from an already-specialized function value. + fn lower_decorator_application_value( + &mut self, + decorators: &[ast::Spanned], + mut current: TypedExpr, + final_ty: IrType, + ) -> Result { + for decorator in decorators.iter().rev() { + if !self.is_user_defined_decorator_candidate(&decorator.node) { + continue; + } + let callable = self.lower_decorator_callable_value(decorator)?; + current = TypedExpr::new( + IrExprKind::Call { + func: Box::new(callable), + type_args: Vec::new(), + args: vec![IrCallArg { + name: None, + kind: IrCallArgKind::Positional, + expr: current, + }], + callable_signature: None, + canonical_path: None, + }, + final_ty.clone(), + ); + } + Ok(current) + } + /// Lower the public function wrapper that dispatches through the decorated callable static. fn decorated_function_wrapper( &mut self, diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index 124f8af94..20144a3ca 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -1488,10 +1488,29 @@ fn collect_backend_clone_bounds_in_expr( } } } + IrExprKind::RegisterCallableName { callable, .. } => { + collect_backend_clone_bounds_in_expr( + callable, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + collect_backend_clone_bounds_in_expr( + value, + type_param_names, + self_clone_params, + clone_context, + clone_params, + ); + } IrExprKind::Var { .. } | IrExprKind::StaticRead { .. } | IrExprKind::StaticBinding { .. } | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Unit | IrExprKind::None | IrExprKind::Bool(_) @@ -1825,6 +1844,14 @@ fn reflection_magic_trait_bound(method: &str) -> Option<&'static str> { } } +fn type_reflection_magic_trait_bound(method: &str) -> Option<&'static str> { + match magic_methods::from_str(method) { + Some(magic_methods::MagicMethodId::ClassName) => Some(tb::INCAN_TYPE_CLASS_NAME), + Some(magic_methods::MagicMethodId::Fields) => Some(tb::INCAN_TYPE_FIELD_METADATA), + _ => None, + } +} + /// Scan an expression for trait-bound-relevant operations on type parameters. fn scan_expr_for_bounds( expr: &IrExpr, @@ -1876,13 +1903,30 @@ fn scan_expr_for_bounds( IrExprKind::MethodCall { receiver, method, args, .. } => { + let receiver_is_type_name = matches!( + receiver.kind, + IrExprKind::Var { + ref_kind: VarRefKind::TypeName, + .. + } + ); if let Some(tp_name) = expr_type_param_name(receiver, type_params, params) { - if method == "clone" { + if method == "clone" && !receiver_is_type_name { add_bound(bounds_map, &tp_name, IrTraitBound::simple(tb::CLONE)); } - if let Some(bound) = reflection_magic_trait_bound(method) { + let reflection_bound = if receiver_is_type_name { + type_reflection_magic_trait_bound(method) + } else { + reflection_magic_trait_bound(method) + }; + if let Some(bound) = reflection_bound { add_bound(bounds_map, &tp_name, IrTraitBound::simple(bound)); } + } else if receiver_is_type_name + && let Some(tp_name) = type_name_expr_type_param_name(receiver, type_params) + && let Some(bound) = type_reflection_magic_trait_bound(method) + { + add_bound(bounds_map, &tp_name, IrTraitBound::simple(bound)); } else if method == "clone" && matches!(receiver.ty, IrType::Unknown) && matches!(&receiver.kind, IrExprKind::Var { .. } | IrExprKind::Field { .. }) @@ -2115,6 +2159,13 @@ fn scan_expr_for_bounds( scan_expr_for_bounds(expr, type_params, params, bounds_map); } + IrExprKind::RegisterCallableName { callable, .. } => { + scan_expr_for_bounds(callable, type_params, params, bounds_map); + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + scan_expr_for_bounds(value, type_params, params, bounds_map); + } + // ---- Range: recurse ---- IrExprKind::Range { start, end, .. } => { if let Some(s) = start { @@ -2130,6 +2181,7 @@ fn scan_expr_for_bounds( | IrExprKind::StaticRead { .. } | IrExprKind::StaticBinding { .. } | IrExprKind::AssociatedFunction { .. } + | IrExprKind::FunctionItem { .. } | IrExprKind::Unit | IrExprKind::None | IrExprKind::Bool(_) @@ -2187,6 +2239,18 @@ fn expr_type_param_name( None } +fn type_name_expr_type_param_name(expr: &IrExpr, type_params: &HashSet<&str>) -> Option { + let IrExprKind::Var { + name, + ref_kind: VarRefKind::TypeName, + .. + } = &expr.kind + else { + return None; + }; + type_params.contains(name.as_str()).then(|| name.clone()) +} + fn type_param_name_from_ir_type(ty: &IrType, type_params: &HashSet<&str>) -> Option { match ty { IrType::Generic(name) if type_params.contains(name.as_str()) => Some(name.clone()), @@ -2667,6 +2731,21 @@ fn collect_calls_in_expr( recurse_expr(&arg.expr, result); } } + IrExprKind::FunctionItem { name, type_args } => { + if let Some(callee_key) = resolve_called_generic_key(name, None, function_bounds) { + let mut mapping = HashMap::new(); + if let Some(callee_type_params) = function_bounds.get(callee_key.as_str()) { + for (callee_tp, caller_ty) in callee_type_params.iter().zip(type_args.iter()) { + if let Some(caller_tp) = type_param_name_from_ir_type(caller_ty, type_params) { + mapping.insert(callee_tp.name.clone(), caller_tp); + } + } + } + if !mapping.is_empty() { + result.push((callee_key, mapping)); + } + } + } IrExprKind::BinOp { left, right, .. } => { recurse_expr(left, result); recurse_expr(right, result); @@ -2686,6 +2765,58 @@ fn collect_calls_in_expr( recurse_expr(&arg.expr, result); } } + IrExprKind::BuiltinCall { args, .. } | IrExprKind::Tuple(args) | IrExprKind::Set(args) => { + for arg in args { + recurse_expr(arg, result); + } + } + IrExprKind::Field { object, .. } => { + recurse_expr(object, result); + } + IrExprKind::Index { object, index } => { + recurse_expr(object, result); + recurse_expr(index, result); + } + IrExprKind::Slice { + target, + start, + end, + step, + } => { + recurse_expr(target, result); + if let Some(start) = start { + recurse_expr(start, result); + } + if let Some(end) = end { + recurse_expr(end, result); + } + if let Some(step) = step { + recurse_expr(step, result); + } + } + IrExprKind::List(items) => { + for item in items { + match item { + IrListEntry::Element(value) | IrListEntry::Spread(value) => recurse_expr(value, result), + } + } + } + IrExprKind::Dict(entries) => { + for entry in entries { + match entry { + IrDictEntry::Pair(key, value) => { + recurse_expr(key, result); + recurse_expr(value, result); + } + IrDictEntry::Spread(value) => recurse_expr(value, result), + } + } + } + IrExprKind::Struct { fields, .. } => { + for (_, value) in fields { + recurse_expr(value, result); + } + } IrExprKind::Format { parts } => { for part in parts { if let FormatPart::Expr { expr, .. } = part { @@ -2717,6 +2848,50 @@ fn collect_calls_in_expr( recurse_stmt(stmt, result); } } + IrExprKind::ListComp { + element, + iterable, + filter, + .. + } => { + recurse_expr(element, result); + recurse_expr(iterable, result); + if let Some(filter) = filter { + recurse_expr(filter, result); + } + } + IrExprKind::DictComp { + key, + value, + iterable, + filter, + .. + } => { + recurse_expr(key, result); + recurse_expr(value, result); + recurse_expr(iterable, result); + if let Some(filter) = filter { + recurse_expr(filter, result); + } + } + IrExprKind::Generator { element, clauses } => { + recurse_expr(element, result); + for clause in clauses { + match clause { + IrGeneratorClause::For { iterable, .. } => recurse_expr(iterable, result), + IrGeneratorClause::If(filter) => recurse_expr(filter, result), + } + } + } + IrExprKind::Match { scrutinee, arms } => { + recurse_expr(scrutinee, result); + for arm in arms { + recurse_expr(&arm.body, result); + if let Some(guard) = &arm.guard { + recurse_expr(guard, result); + } + } + } IrExprKind::Closure { body, .. } => { recurse_expr(body, result); } @@ -2726,9 +2901,20 @@ fn collect_calls_in_expr( recurse_expr(&arm.body, result); } } - IrExprKind::InteropCoerce { expr, .. } => { + IrExprKind::Await(expr) | IrExprKind::Try(expr) => { recurse_expr(expr, result); } + IrExprKind::Cast { expr, .. } + | IrExprKind::NumericResize { expr, .. } + | IrExprKind::InteropCoerce { expr, .. } => { + recurse_expr(expr, result); + } + IrExprKind::RegisterCallableName { callable, .. } => { + recurse_expr(callable, result); + } + IrExprKind::CacheGenericDecoratedFunction { value, .. } => { + recurse_expr(value, result); + } // Other expression kinds are not recursed into for transitive inference. // The primary call pattern (direct function calls) is covered above. _ => {} diff --git a/src/frontend/symbols.rs b/src/frontend/symbols.rs index 346124370..4eae9f9f8 100644 --- a/src/frontend/symbols.rs +++ b/src/frontend/symbols.rs @@ -706,7 +706,7 @@ pub struct MethodInfo { } /// Resolved type-parameter bound metadata preserved for export/import paths. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct TypeBoundInfo { pub name: String, pub source_name: Option, diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index c41c3dd77..e06a4fbda 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -3774,11 +3774,13 @@ impl TypeChecker { return; } - let Some(original_ty) = self.lookup_symbol(&func.name).and_then(|symbol| match &symbol.kind { - SymbolKind::Function(info) => Some(function_info_callable_type(info)), - SymbolKind::Variable(info) => Some(info.ty.clone()), - _ => None, - }) else { + let Some((original_ty, original_function_info)) = + self.lookup_symbol(&func.name).and_then(|symbol| match &symbol.kind { + SymbolKind::Function(info) => Some((function_info_callable_type(info), Some(info.clone()))), + SymbolKind::Variable(info) => Some((info.ty.clone(), None)), + _ => None, + }) + else { return; }; @@ -3797,6 +3799,16 @@ impl TypeChecker { DecoratedFunctionBindingInfo { ty: binding_ty.clone(), original_ty, + type_params: original_function_info + .as_ref() + .map_or_else(Vec::new, |info| info.type_params.clone()), + type_param_bounds: original_function_info + .as_ref() + .map_or_else(HashMap::new, |info| info.type_param_bounds.clone()), + type_param_bound_details: original_function_info + .as_ref() + .map_or_else(HashMap::new, |info| info.type_param_bound_details.clone()), + is_async: original_function_info.as_ref().is_some_and(|info| info.is_async), }, ); symbol.kind = SymbolKind::Variable(VariableInfo { diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 227c24ccd..4593919ab 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -1854,13 +1854,25 @@ impl TypeChecker { ResolvedType::Ref(_) | ResolvedType::RefMut(_) | ResolvedType::Function(_, _) | ResolvedType::SelfType => { true } - ResolvedType::TypeVar(_) | ResolvedType::CallSiteInfer => false, + ResolvedType::TypeVar(name) => self.active_type_param_has_builtin_bound(name, TraitId::Clone), + ResolvedType::CallSiteInfer => false, // RFC 041: provenance is known, but Incan does not yet query Rust for `Copy`/`Clone`; do not assume. ResolvedType::RustPath(_) => false, ResolvedType::Unknown => true, } } + fn active_type_param_has_builtin_bound(&self, type_param: &str, trait_id: TraitId) -> bool { + let expected = core_traits::as_str(trait_id); + self.current_type_param_bound_details.iter().rev().any(|frame| { + frame.get(type_param).is_some_and(|bounds| { + bounds + .iter() + .any(|bound| bound.name == expected || Self::type_bound_source_name(bound) == expected) + }) + }) + } + /// [`ResolvedType::SelfType`] in a trait method signature means the receiver type for this call site. fn concrete_type_for_trait_self(&self, receiver: &ResolvedType) -> ResolvedType { match receiver { @@ -2485,7 +2497,7 @@ impl TypeChecker { index: &Spanned, span: Span, ) -> ResolvedType { - let base_ty = self.check_expr(base); + let base_ty = self.check_type_receiver_expr(base); if let Some(ty) = self.resolve_type_index_expression(&base_ty, base) { return ty; } @@ -2629,7 +2641,7 @@ impl TypeChecker { field: &str, span: Span, ) -> ResolvedType { - let base_ty = self.check_expr(base); + let base_ty = self.check_type_receiver_expr(base); // Imported modules use symbol-driven metadata resolution. if let Some((module_name, module_path)) = self.imported_module_for_expr(base) { @@ -2682,6 +2694,9 @@ impl TypeChecker { if let Some(sig) = self.rust_associated_function_signature(path, field) { return self.resolved_function_type_from_rust_sig_for_path(&sig, false, path); } + if let Some(params) = self.rust_variant_callable_params(path, field) { + return ResolvedType::Function(params, Box::new(ResolvedType::RustPath(path.to_string()))); + } if let RustItemKind::Type(info) = &meta.kind && let Some(rust_field) = info.fields.iter().find(|f| f.name == field) { @@ -2941,7 +2956,7 @@ impl TypeChecker { return self.check_builtin_list_repeat_call(args, span); } - let base_ty = self.check_expr(base); + let base_ty = self.check_type_receiver_expr(base); // If the receiver type is Unknown, be permissive and do not error on methods. if matches!(base_ty, ResolvedType::Unknown) { @@ -3021,6 +3036,24 @@ impl TypeChecker { } if let Some(path) = self.rust_canonical_path_for_receiver_type(&base_ty) { + if let Some(params) = self.rust_variant_callable_params(&path, method) { + if !type_args.is_empty() { + self.errors + .push(errors::explicit_call_site_type_args_not_supported(span)); + } + let arg_types = self.check_call_arg_types_for_params(args, ¶ms); + let mut type_bindings = std::collections::HashMap::new(); + self.validate_callable_arg_bindings( + format!("rust::{path}.{method}").as_str(), + ¶ms, + args, + &arg_types, + &mut type_bindings, + span, + ); + self.type_info.record_call_site_callable_params_exact(span, ¶ms); + return ResolvedType::RustPath(path); + } if let Some(ret) = Self::known_rust_path_method_return(path.as_str(), method) { return ret; } diff --git a/src/frontend/typechecker/check_expr/basics.rs b/src/frontend/typechecker/check_expr/basics.rs index 75e6fbe9b..22383f902 100644 --- a/src/frontend/typechecker/check_expr/basics.rs +++ b/src/frontend/typechecker/check_expr/basics.rs @@ -62,7 +62,22 @@ impl TypeChecker { ResolvedType::Function(info.params.clone(), Box::new(info.return_type.clone())), ) } - SymbolKind::Type(_) => (IdentKind::TypeName, ResolvedType::Named(name.to_string())), + SymbolKind::Type(info) => { + if !self.is_type_receiver_span(span) { + self.errors.push(errors::type_name_used_as_value(name, span)); + self.type_info + .expressions + .ident_kinds + .insert((span.start, span.end), IdentKind::TypeName); + return ResolvedType::Unknown; + } + let ty = if matches!(info, TypeInfo::Builtin) && sym.scope > 0 { + ResolvedType::TypeVar(name.to_string()) + } else { + ResolvedType::Named(name.to_string()) + }; + (IdentKind::TypeName, ty) + } SymbolKind::Variant(info) => (IdentKind::Variant, ResolvedType::Named(info.enum_name.clone())), SymbolKind::Field(info) => (IdentKind::Value, info.ty.clone()), SymbolKind::Property(info) => (IdentKind::Value, info.return_type.clone()), @@ -76,7 +91,17 @@ impl TypeChecker { (IdentKind::Module, ResolvedType::Named(name.to_string())) } } - SymbolKind::Trait(_) => (IdentKind::Trait, ResolvedType::Named(name.to_string())), + SymbolKind::Trait(_) => { + if !self.is_type_receiver_span(span) { + self.errors.push(errors::type_name_used_as_value(name, span)); + self.type_info + .expressions + .ident_kinds + .insert((span.start, span.end), IdentKind::Trait); + return ResolvedType::Unknown; + } + (IdentKind::Trait, ResolvedType::Named(name.to_string())) + } SymbolKind::RustItem(info) => { if let Some(meta) = &info.metadata && meta.visibility == incan_core::interop::RustVisibility::Restricted diff --git a/src/frontend/typechecker/check_expr/calls.rs b/src/frontend/typechecker/check_expr/calls.rs index 9d1399146..3a472f127 100644 --- a/src/frontend/typechecker/check_expr/calls.rs +++ b/src/frontend/typechecker/check_expr/calls.rs @@ -6,7 +6,7 @@ use crate::frontend::ast::{CallArg, Expr, Span, Spanned, Type}; use crate::frontend::diagnostics::errors; use crate::frontend::resolved_type_subst::substitute_resolved_type; -use crate::frontend::symbols::{FieldInfo, ResolvedType, SymbolKind, TypeInfo}; +use crate::frontend::symbols::{FieldInfo, FunctionInfo, ResolvedType, SymbolKind, TypeInfo}; use crate::frontend::typechecker::IdentKind; use incan_core::interop::{RustFieldInfo, RustItemKind, RustTypeInfo}; use incan_core::lang::derives::{self, DeriveId}; @@ -63,7 +63,7 @@ impl TypeChecker { // and the field name matches a variant, treat this as a constructor and // return the enum type. if let Expr::Field(base, member_name) = &callee.node { - let base_ty = self.check_expr(base); + let base_ty = self.check_type_receiver_expr(base); let base_is_enum_type_name = self.is_enum_type_name_expr_for_call(base); if let ResolvedType::Named(enum_name) = &base_ty && let Some(TypeInfo::Enum(enum_info)) = self.lookup_type_info(enum_name) @@ -375,6 +375,28 @@ impl TypeChecker { } } + if let Expr::Ident(name) = &callee.node + && !type_args.is_empty() + && let Some(binding) = self + .type_info + .declarations + .decorated_function_bindings + .get(name) + .cloned() + && !binding.type_params.is_empty() + && let ResolvedType::Function(params, ret) = binding.ty + { + let info = FunctionInfo { + params, + return_type: *ret, + is_async: binding.is_async, + type_params: binding.type_params, + type_param_bounds: binding.type_param_bounds, + type_param_bound_details: binding.type_param_bound_details, + }; + return self.validate_function_call(name, &info, type_args, args, span); + } + if !type_args.is_empty() { self.errors .push(errors::explicit_call_site_type_args_not_supported(span)); diff --git a/src/frontend/typechecker/check_expr/mod.rs b/src/frontend/typechecker/check_expr/mod.rs index e47f65ec4..9473dac5c 100644 --- a/src/frontend/typechecker/check_expr/mod.rs +++ b/src/frontend/typechecker/check_expr/mod.rs @@ -236,6 +236,21 @@ impl TypeChecker { ty } + /// Type-check an expression used as a type-owned receiver, such as `Type.method()` or `Enum.Variant`. + pub(crate) fn check_type_receiver_expr(&mut self, expr: &Spanned) -> ResolvedType { + self.type_receiver_spans.push((expr.span.start, expr.span.end)); + let ty = self.check_expr(expr); + self.type_receiver_spans.pop(); + ty + } + + pub(crate) fn is_type_receiver_span(&self, span: Span) -> bool { + self.type_receiver_spans + .iter() + .rev() + .any(|&(start, end)| start == span.start && end == span.end) + } + /// Type-check an expression with an expected destination type when one is already known. /// /// This is intentionally narrow: only expression forms that benefit from contextual typing without broad inference diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 9c3f293ae..d06b4ddae 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -161,6 +161,8 @@ pub struct TypeChecker { pub(crate) await_operand_span: Option<(usize, usize)>, /// Nesting depth for expressions being checked as call arguments. pub(crate) call_argument_depth: usize, + /// Expression spans where type-like identifiers are valid namespace/type owners. + pub(crate) type_receiver_spans: Vec<(usize, usize)>, /// Stack of active loop contexts, innermost last. pub(crate) loop_stack: Vec, /// Active trait @requires context for default method bodies. @@ -298,6 +300,7 @@ impl TypeChecker { in_async_body: false, await_operand_span: None, call_argument_depth: 0, + type_receiver_spans: Vec::new(), loop_stack: Vec::new(), current_trait_requires: None, current_trait_properties: None, @@ -948,6 +951,26 @@ impl TypeChecker { } } + /// Return callable-parameter metadata for one rust-inspect-backed enum variant constructor. + /// + /// Rust enum variants are callable constructors at the source surface, but they are not ordinary inherent + /// functions. Carrying their payload shapes through the same callable metadata path keeps backend argument + /// ownership planning target-driven instead of guessing from the source expression shape. + pub(crate) fn rust_variant_callable_params(&self, rust_path: &str, variant: &str) -> Option> { + let metadata = self.rust_item_metadata_for_path(rust_path)?; + let RustItemKind::Type(info) = &metadata.kind else { + return None; + }; + let variant = info.variants.iter().find(|candidate| candidate.name == variant)?; + Some( + variant + .fields + .iter() + .map(|field| CallableParam::positional(self.resolved_type_from_rust_shape(field))) + .collect(), + ) + } + /// Resolve a Rust-origin method signature from cached metadata. pub(crate) fn rust_method_signature(&self, rust_path: &str, method: &str) -> Option { let metadata = self.rust_item_metadata_for_path(rust_path)?; diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index f26020c1c..3b3bd8e93 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -4962,6 +4962,97 @@ def reflected_class_name[T](value: T) -> str: Ok(()) } +#[test] +fn test_type_parameter_reflection_magic_methods_record_surface_types() -> Result<(), Box> { + let source = r#" +def reflected_field_count[T]() -> int: + fields = T.__fields__() + return len(fields) + +def reflected_class_name[T]() -> str: + return T.__class_name__() +"#; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; + let mut checker = TypeChecker::new(); + checker + .check_program(&ast) + .map_err(|errs| std::io::Error::other(format!("check_program failed: {errs:?}")))?; + let info = checker.type_info(); + assert!( + info.expressions + .expr_types + .values() + .any(|ty| matches!(ty, ResolvedType::Str)), + "expected type-parameter __class_name__() to resolve to str, got {:?}", + info.expressions.expr_types + ); + assert!( + info.expressions.expr_types.values().any(|ty| { + matches!( + ty, + ResolvedType::FrozenList(inner) + if matches!(inner.as_ref(), ResolvedType::Named(name) if name == "FieldInfo") + ) + }), + "expected type-parameter __fields__() to resolve to FrozenList[FieldInfo], got {:?}", + info.expressions.expr_types + ); + Ok(()) +} + +#[test] +fn test_bare_model_type_name_is_not_a_value() -> Result<(), Box> { + let source = r#" +model User: + name: str + +def accepts_any[T](value: T) -> str: + return value.__class_name__() + +def main() -> None: + accepts_any(User) +"#; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; + let mut checker = TypeChecker::new(); + let Err(errs) = checker.check_program(&ast) else { + return Err(std::io::Error::other("expected bare model type name to be rejected").into()); + }; + assert!( + errs.iter() + .any(|err| err.message.contains("Cannot use type 'User' as a value")), + "expected bare model value diagnostic, got {errs:?}" + ); + Ok(()) +} + +#[test] +fn test_type_receiver_context_does_not_leak_into_nested_values() -> Result<(), Box> { + let source = r#" +model User: + name: str + +def accepts_any[T](value: T) -> str: + return value.__class_name__() + +def main() -> None: + accepts_any(User).upper() +"#; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("lex failed: {errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("parse failed: {errs:?}")))?; + let mut checker = TypeChecker::new(); + let Err(errs) = checker.check_program(&ast) else { + return Err(std::io::Error::other("expected nested bare model type name to be rejected").into()); + }; + assert!( + errs.iter() + .any(|err| err.message.contains("Cannot use type 'User' as a value")), + "expected nested bare model value diagnostic, got {errs:?}" + ); + Ok(()) +} + #[test] fn test_reflection_fieldinfo_members_typecheck_without_explicit_import() -> Result<(), Box> { let source = r#" @@ -6353,6 +6444,15 @@ def add(mut xs: List[Mutex], value: Mutex) -> None: ); } +#[test] +fn test_list_append_accepts_clone_bound_type_param() { + let source = r#" +def add_item[T with Clone](mut items: List[T], item: T) -> None: + items.append(item) +"#; + assert_check_ok(source); +} + #[test] fn test_list_repeat_infers_list_element_type() { let source = r#" diff --git a/src/frontend/typechecker/trait_bound_relations.rs b/src/frontend/typechecker/trait_bound_relations.rs index eddfd8f8b..8e36d46b4 100644 --- a/src/frontend/typechecker/trait_bound_relations.rs +++ b/src/frontend/typechecker/trait_bound_relations.rs @@ -190,7 +190,7 @@ impl TypeChecker { } /// Return the resolved source trait item name for a bound, falling back to the visible spelling. - fn type_bound_source_name(bound: &TypeBoundInfo) -> &str { + pub(in crate::frontend::typechecker) fn type_bound_source_name(bound: &TypeBoundInfo) -> &str { bound .source_name .as_deref() diff --git a/src/frontend/typechecker/type_info.rs b/src/frontend/typechecker/type_info.rs index f7383571d..f64ca9cfc 100644 --- a/src/frontend/typechecker/type_info.rs +++ b/src/frontend/typechecker/type_info.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use crate::frontend::ast::{ParamKind, Span}; -use crate::frontend::symbols::{CallableParam, NewtypePrimitiveConstraint, ResolvedType}; +use crate::frontend::symbols::{CallableParam, NewtypePrimitiveConstraint, ResolvedType, TypeBoundInfo}; use crate::frontend::testing_markers::TestingFixtureScope; use incan_core::interop::{CoercionPolicy, RustFunctionSig}; @@ -454,6 +454,14 @@ pub struct DecoratedFunctionBindingInfo { pub ty: ResolvedType, /// Original callable type before decorators are applied. pub original_ty: ResolvedType, + /// Source-declared type parameters preserved for explicit call-site generic arguments. + pub type_params: Vec, + /// Explicit source-declared bounds per type parameter. + pub type_param_bounds: HashMap>, + /// Resolved source-declared bounds, preserving generic type arguments. + pub type_param_bound_details: HashMap>, + /// Whether the original declaration is async. + pub is_async: bool, } /// Lowering metadata for one RFC 036 decorated method binding. diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 78f97994f..18ea0e7f9 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1931,6 +1931,311 @@ def main() -> None: Ok(()) } +#[test] +fn run_type_parameter_reflection_calls_issue715() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "type_parameter_reflection_issue715", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + fs::write( + src_dir.join("schema_helpers.incn"), + r#"pub def class_name_for[T]() -> str: + return T.__class_name__() + + +pub def field_count_for[T]() -> int: + return len(T.__fields__()) + + +pub def print_schema[T]() -> None: + println(str(T.__class_name__())) + for info in T.__fields__(): + println(f"{info.name}|{info.wire_name}|{info.type_name}|{info.has_default}") +"#, + )?; + fs::write( + &main_path, + r#"from schema_helpers import class_name_for, field_count_for, print_schema + + +model MySchema: + id [description="Stable id"]: int + status [alias="state"]: str = "new" + + +class BareSchema: + value: int + + +def local_field_count[T]() -> int: + return len(T.__fields__()) + + +def main() -> None: + println(class_name_for[MySchema]()) + println(field_count_for[MySchema]()) + println(local_field_count[MySchema]()) + print_schema[MySchema]() + println(class_name_for[BareSchema]()) + println(field_count_for[BareSchema]()) +"#, + )?; + + let check_output = run_incan( + tmp.path(), + &["--check", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&check_output, "incan --check for type-parameter reflection issue715"); + + let run_output = run_incan( + tmp.path(), + &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&run_output, "incan run for type-parameter reflection issue715"); + let stdout = String::from_utf8_lossy(&run_output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!( + lines, + vec![ + "MySchema", + "2", + "2", + "MySchema", + "id|id|int|false", + "status|state|str|true", + "BareSchema", + "1", + ], + "unexpected type-parameter reflection output:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn run_decorated_type_parameter_reflection_calls_issue715() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "decorated_type_parameter_reflection_issue715", "")?; + let src_dir = main_path.parent().ok_or("main path had no parent")?; + fs::write( + src_dir.join("reflection_helpers.incn"), + r#"def requires_clone[T with Clone]() -> str: + return "clone" + + +pub def reflected_schema_marker[T]() -> str: + return f"{T.__class_name__()}:{len(T.__fields__())}:{requires_clone[T]()}" +"#, + )?; + fs::write( + &main_path, + r#"from reflection_helpers import reflected_schema_marker + + +static decorated_names: list[str] = [] + + +def register[F]() -> ((F) -> F): + return (func) => remember[F](func) + + +def remember[F](func: F) -> F: + decorated_names.append(func.__name__) + return func + + +@register() +def class_name_for[T]() -> str: + return str(T.__class_name__()) + + +@register() +def field_count_for[T]() -> int: + return len(T.__fields__()) + + +def requires_clone[T with Clone]() -> str: + return "clone" + + +@register() +def clone_marker_for[T]() -> str: + return requires_clone[T]() + + +@register() +def imported_reflection_for[T]() -> str: + return reflected_schema_marker[T]() + + +model MySchema: + id: int + status: str + + +def main() -> None: + println(class_name_for[MySchema]()) + println(field_count_for[MySchema]()) + println(clone_marker_for[MySchema]()) + println(imported_reflection_for[MySchema]()) + println(imported_reflection_for[MySchema]()) + println(decorated_names[0]) + println(decorated_names[1]) + println(decorated_names[2]) + println(decorated_names[3]) + println(len(decorated_names)) +"#, + )?; + + let run_output = run_incan( + tmp.path(), + &["run", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success( + &run_output, + "incan run for decorated type-parameter reflection issue715", + ); + let stdout = String::from_utf8_lossy(&run_output.stdout); + let lines = stdout.lines().collect::>(); + assert_eq!( + lines, + vec![ + "MySchema", + "2", + "clone", + "MySchema:2:clone", + "MySchema:2:clone", + "class_name_for", + "field_count_for", + "clone_marker_for", + "imported_reflection_for", + "4", + ], + "unexpected decorated type-parameter reflection output:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn check_bare_model_type_value_rejected_issue714() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "model_type_value_issue714", "")?; + fs::write( + &main_path, + r#"model MySchema: + id: int + status: str + + +def accepts_any[T](value: T) -> str: + return str(value.__class_name__()) + + +def main() -> None: + println(accepts_any(MySchema)) +"#, + )?; + + let check_output = run_incan( + tmp.path(), + &["--check", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_failure(&check_output, "incan --check for bare model type value issue714"); + let stderr = String::from_utf8_lossy(&check_output.stderr); + assert!( + stderr.contains("Cannot use type 'MySchema' as a value"), + "expected bare model type value diagnostic, got:\n{stderr}" + ); + Ok(()) +} + +#[test] +fn build_inline_fstring_rust_str_argument_issue716() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project(tmp.path(), "inline_fstring_rust_str_argument_issue716", "")?; + fs::write( + &main_path, + r#"from rust::incan_stdlib::errors import raise_value_error + + +def fail_inline(value: str) -> int: + return raise_value_error(f"bad value `{value}`") + + +def fail_local(value: str) -> int: + message = f"bad value `{value}`" + return raise_value_error(message) + + +def main() -> None: + fail_inline("x") +"#, + )?; + + let build_output = run_incan( + tmp.path(), + &["build", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success( + &build_output, + "incan build for inline f-string Rust &str argument issue716", + ); + Ok(()) +} + +#[test] +fn build_inline_fstring_rust_string_variant_issue716() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let helper_dir = tmp.path().join("rust").join("tiny_error"); + fs::create_dir_all(helper_dir.join("src"))?; + fs::write( + helper_dir.join("Cargo.toml"), + "[package]\nname = \"tiny_error\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + )?; + fs::write( + helper_dir.join("src").join("lib.rs"), + r#"pub enum TinyError { + Execution(String), +} + +pub fn consume(err: TinyError) -> i64 { + match err { + TinyError::Execution(message) => message.len() as i64, + } +} +"#, + )?; + let main_path = write_minimal_project( + tmp.path(), + "inline_fstring_rust_string_variant_issue716", + r#" +[rust-dependencies] +tiny_error = { path = "rust/tiny_error" } +"#, + )?; + fs::write( + &main_path, + r#"from rust::tiny_error import TinyError, consume + + +def make_error(value: str) -> int: + return consume(TinyError.Execution(f"bad value `{value}`")) + + +def main() -> None: + println(str(make_error("x"))) +"#, + )?; + + let build_output = run_incan( + tmp.path(), + &["build", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success( + &build_output, + "incan build for inline f-string Rust String enum variant issue716", + ); + Ok(()) +} + #[test] fn build_public_alias_of_imported_item_reexports_original_path_issue617() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/fixtures/vocab_guardrails/semantic_string_audit.json b/tests/fixtures/vocab_guardrails/semantic_string_audit.json index b1a1ce389..a84f6fd10 100644 --- a/tests/fixtures/vocab_guardrails/semantic_string_audit.json +++ b/tests/fixtures/vocab_guardrails/semantic_string_audit.json @@ -220,7 +220,7 @@ "path": "src/backend/ir/trait_bound_inference.rs", "category": "clone/as_ref/self and reflection trait-bound inference compatibility", "expected_count": 4, - "expected_fingerprint": "0xf6bac5fbb4750df0" + "expected_fingerprint": "0x7b05117f4d9db0da" }, { "path": "src/cli/commands/common.rs", diff --git a/tests/snapshots/codegen_snapshot_tests__classes.snap b/tests/snapshots/codegen_snapshot_tests__classes.snap index 74996106a..a4ba06124 100644 --- a/tests/snapshots/codegen_snapshot_tests__classes.snap +++ b/tests/snapshots/codegen_snapshot_tests__classes.snap @@ -14,6 +14,11 @@ struct Point { } impl incan_stdlib::reflection::HasClassName for Point { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Point { + fn __class_name__() -> &'static str { "Point" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Point { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Point { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("x"), @@ -51,6 +63,11 @@ struct Rectangle { } impl incan_stdlib::reflection::HasClassName for Rectangle { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Rectangle { + fn __class_name__() -> &'static str { "Rectangle" } } @@ -58,6 +75,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Rectangle { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Rectangle { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("width"), @@ -87,6 +111,11 @@ struct Circle { } impl incan_stdlib::reflection::HasClassName for Circle { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Circle { + fn __class_name__() -> &'static str { "Circle" } } @@ -94,6 +123,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Circle { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Circle { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("radius"), @@ -122,6 +158,11 @@ struct Counter { } impl incan_stdlib::reflection::HasClassName for Counter { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Counter { + fn __class_name__() -> &'static str { "Counter" } } @@ -129,6 +170,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Counter { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Counter { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("count"), @@ -160,6 +208,11 @@ struct Stack { } impl incan_stdlib::reflection::HasClassName for Stack { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Stack { + fn __class_name__() -> &'static str { "Stack" } } @@ -167,6 +220,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Stack { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Stack { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("items"), @@ -202,6 +262,11 @@ struct Calculator { } impl incan_stdlib::reflection::HasClassName for Calculator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Calculator { + fn __class_name__() -> &'static str { "Calculator" } } @@ -209,6 +274,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Calculator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Calculator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -236,6 +308,11 @@ struct Person { } impl incan_stdlib::reflection::HasClassName for Person { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Person { + fn __class_name__() -> &'static str { "Person" } } @@ -243,6 +320,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Person { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Person { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -282,6 +366,11 @@ struct Employee { } impl incan_stdlib::reflection::HasClassName for Employee { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Employee { + fn __class_name__() -> &'static str { "Employee" } } @@ -289,6 +378,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Employee { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Employee { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("person"), diff --git a/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap b/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap index bb437dcb1..ffd09f3ac 100644 --- a/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap +++ b/tests/snapshots/codegen_snapshot_tests__constructor_field_defaults.snap @@ -16,6 +16,11 @@ struct Settings { } impl incan_stdlib::reflection::HasClassName for Settings { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Settings { + fn __class_name__() -> &'static str { "Settings" } } @@ -23,6 +28,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Settings { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Settings { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("theme"), diff --git a/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap b/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap index acb58a1f1..c5e87662e 100644 --- a/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap +++ b/tests/snapshots/codegen_snapshot_tests__explicit_call_site_generics.snap @@ -14,6 +14,11 @@ fn id(x: T) -> T { struct Boxed {} impl incan_stdlib::reflection::HasClassName for Boxed { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Boxed { + fn __class_name__() -> &'static str { "Boxed" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Boxed { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Boxed { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } diff --git a/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap b/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap index 6f0483319..064239358 100644 --- a/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap +++ b/tests/snapshots/codegen_snapshot_tests__fixed_call_unpack.snap @@ -29,6 +29,11 @@ fn route(path: String, method: String) -> String { struct Counter {} impl incan_stdlib::reflection::HasClassName for Counter { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Counter { + fn __class_name__() -> &'static str { "Counter" } } @@ -36,6 +41,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Counter { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Counter { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } diff --git a/tests/snapshots/codegen_snapshot_tests__generic_methods.snap b/tests/snapshots/codegen_snapshot_tests__generic_methods.snap index 80193f3fb..09c1e667e 100644 --- a/tests/snapshots/codegen_snapshot_tests__generic_methods.snap +++ b/tests/snapshots/codegen_snapshot_tests__generic_methods.snap @@ -11,6 +11,11 @@ incan_stdlib::__incan_stdlib_version_check!(""); struct Box {} impl incan_stdlib::reflection::HasClassName for Box { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Box { + fn __class_name__() -> &'static str { "Box" } } @@ -18,6 +23,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Box { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Box { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } @@ -34,6 +46,11 @@ struct Shelf { } impl incan_stdlib::reflection::HasClassName for Shelf { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Shelf { + fn __class_name__() -> &'static str { "Shelf" } } @@ -41,6 +58,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Shelf { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Shelf { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("item"), @@ -67,6 +91,11 @@ pub trait Echo { struct Pair {} impl incan_stdlib::reflection::HasClassName for Pair { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Pair { + fn __class_name__() -> &'static str { "Pair" } } @@ -74,6 +103,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Pair { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Pair { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } @@ -90,6 +126,11 @@ struct EchoBox { } impl incan_stdlib::reflection::HasClassName for EchoBox { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for EchoBox { + fn __class_name__() -> &'static str { "EchoBox" } } @@ -97,6 +138,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for EchoBox { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for EchoBox { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("marker"), diff --git a/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap b/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap index 3529a5e9c..702f4c3d6 100644 --- a/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap +++ b/tests/snapshots/codegen_snapshot_tests__generic_model_field_access.snap @@ -13,6 +13,11 @@ pub struct Boxed { } impl incan_stdlib::reflection::HasClassName for Boxed { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Boxed { + fn __class_name__() -> &'static str { "Boxed" } } @@ -20,6 +25,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Boxed { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Boxed { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap b/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap index b3d1bf7fa..f2aead183 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue241_field_backed_method_arg_clone.snap @@ -11,6 +11,11 @@ incan_stdlib::__incan_stdlib_version_check!(""); struct Cursor {} impl incan_stdlib::reflection::HasClassName for Cursor { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Cursor { + fn __class_name__() -> &'static str { "Cursor" } } @@ -18,6 +23,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Cursor { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Cursor { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } @@ -33,6 +45,11 @@ struct Wrapper { } impl incan_stdlib::reflection::HasClassName for Wrapper { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Wrapper { + fn __class_name__() -> &'static str { "Wrapper" } } @@ -40,6 +57,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Wrapper { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Wrapper { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("_cursor"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap b/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap index f25d499ec..8100fc57a 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue246_class_field_visibility.snap @@ -14,6 +14,11 @@ pub struct LazyFrame { } impl incan_stdlib::reflection::HasClassName for LazyFrame { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for LazyFrame { + fn __class_name__() -> &'static str { "LazyFrame" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for LazyFrame { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for LazyFrame { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("_cursor"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap b/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap index 13554a683..4db308006 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue364_filtered_list_comp_borrow.snap @@ -14,6 +14,11 @@ struct StoredNode { } impl incan_stdlib::reflection::HasClassName for StoredNode { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for StoredNode { + fn __class_name__() -> &'static str { "StoredNode" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for StoredNode { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for StoredNode { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("store_id_raw"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap b/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap index a3ce07b55..44dde093c 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue366_clone_self_string_field.snap @@ -14,6 +14,11 @@ pub struct ActiveRegistration { } impl incan_stdlib::reflection::HasClassName for ActiveRegistration { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for ActiveRegistration { + fn __class_name__() -> &'static str { "ActiveRegistration" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for ActiveRegistration { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for ActiveRegistration { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("logical_name"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap b/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap index 541b25881..c3908cbec 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue380_len_comparison.snap @@ -31,6 +31,11 @@ pub struct Expr { } impl incan_stdlib::reflection::HasClassName for Expr { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Expr { + fn __class_name__() -> &'static str { "Expr" } } @@ -38,6 +43,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Expr { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Expr { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("kind"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap b/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap index 77e862e81..04ea1b6a3 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue389_for_tuple_unpack_enumerate.snap @@ -18,6 +18,11 @@ struct Binding { } impl incan_stdlib::reflection::HasClassName for Binding { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Binding { + fn __class_name__() -> &'static str { "Binding" } } @@ -25,6 +30,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Binding { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Binding { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), diff --git a/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap b/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap index 6972dcec7..4644e017f 100644 --- a/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap +++ b/tests/snapshots/codegen_snapshot_tests__issue483_list_comp_tuple_unpack_enumerate.snap @@ -18,6 +18,11 @@ struct Binding { } impl incan_stdlib::reflection::HasClassName for Binding { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Binding { + fn __class_name__() -> &'static str { "Binding" } } @@ -25,6 +30,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Binding { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Binding { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), diff --git a/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap b/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap index 6e521f1a1..b4964ab29 100644 --- a/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap +++ b/tests/snapshots/codegen_snapshot_tests__list_clone_model.snap @@ -14,6 +14,11 @@ struct Node { } impl incan_stdlib::reflection::HasClassName for Node { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Node { + fn __class_name__() -> &'static str { "Node" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Node { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Node { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), diff --git a/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap b/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap index 8ac0865eb..b45386d86 100644 --- a/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap +++ b/tests/snapshots/codegen_snapshot_tests__list_pop_clone_only_model.snap @@ -13,6 +13,11 @@ struct PopRegressItem { } impl incan_stdlib::reflection::HasClassName for PopRegressItem { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for PopRegressItem { + fn __class_name__() -> &'static str { "PopRegressItem" } } @@ -20,6 +25,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for PopRegressItem { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for PopRegressItem { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), diff --git a/tests/snapshots/codegen_snapshot_tests__model_struct.snap b/tests/snapshots/codegen_snapshot_tests__model_struct.snap index 0b2b9501c..5767c8f70 100644 --- a/tests/snapshots/codegen_snapshot_tests__model_struct.snap +++ b/tests/snapshots/codegen_snapshot_tests__model_struct.snap @@ -15,6 +15,11 @@ struct User { } impl incan_stdlib::reflection::HasClassName for User { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for User { + fn __class_name__() -> &'static str { "User" } } @@ -22,6 +27,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for User { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for User { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), diff --git a/tests/snapshots/codegen_snapshot_tests__models.snap b/tests/snapshots/codegen_snapshot_tests__models.snap index 25cd81b71..632fd97ad 100644 --- a/tests/snapshots/codegen_snapshot_tests__models.snap +++ b/tests/snapshots/codegen_snapshot_tests__models.snap @@ -17,6 +17,11 @@ struct User { } impl incan_stdlib::reflection::HasClassName for User { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for User { + fn __class_name__() -> &'static str { "User" } } @@ -24,6 +29,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for User { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for User { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -66,6 +78,11 @@ struct Product { } impl incan_stdlib::reflection::HasClassName for Product { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Product { + fn __class_name__() -> &'static str { "Product" } } @@ -73,6 +90,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Product { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Product { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), @@ -115,6 +139,11 @@ struct Config { } impl incan_stdlib::reflection::HasClassName for Config { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Config { + fn __class_name__() -> &'static str { "Config" } } @@ -122,6 +151,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Config { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Config { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("host"), @@ -165,6 +201,11 @@ struct OptionalData { } impl incan_stdlib::reflection::HasClassName for OptionalData { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for OptionalData { + fn __class_name__() -> &'static str { "OptionalData" } } @@ -172,6 +213,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for OptionalData { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for OptionalData { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("required"), @@ -214,6 +262,11 @@ struct StudentData { } impl incan_stdlib::reflection::HasClassName for StudentData { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for StudentData { + fn __class_name__() -> &'static str { "StudentData" } } @@ -221,6 +274,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for StudentData { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for StudentData { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -263,6 +323,11 @@ struct Address { } impl incan_stdlib::reflection::HasClassName for Address { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Address { + fn __class_name__() -> &'static str { "Address" } } @@ -270,6 +335,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Address { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Address { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("street"), @@ -311,6 +383,11 @@ struct Contact { } impl incan_stdlib::reflection::HasClassName for Contact { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Contact { + fn __class_name__() -> &'static str { "Contact" } } @@ -318,6 +395,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Contact { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Contact { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -357,6 +441,11 @@ struct Coordinate { } impl incan_stdlib::reflection::HasClassName for Coordinate { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Coordinate { + fn __class_name__() -> &'static str { "Coordinate" } } @@ -364,6 +453,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Coordinate { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Coordinate { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("latitude"), diff --git a/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap b/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap index 58d7d2736..4958964cb 100644 --- a/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap +++ b/tests/snapshots/codegen_snapshot_tests__newtype_implicit_coercion.snap @@ -34,6 +34,11 @@ struct Job { } impl incan_stdlib::reflection::HasClassName for Job { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Job { + fn __class_name__() -> &'static str { "Job" } } @@ -41,6 +46,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Job { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Job { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("attempts"), diff --git a/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap b/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap index 70e9cd157..a825d83e2 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc024_module_derive_json.snap @@ -23,6 +23,11 @@ struct Payload { } impl incan_stdlib::reflection::HasClassName for Payload { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Payload { + fn __class_name__() -> &'static str { "Payload" } } @@ -30,6 +35,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Payload { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Payload { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap b/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap index 78fc8a1a0..e863c4ab2 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc024_partial_alias_derive.snap @@ -21,6 +21,11 @@ struct Payload { } impl incan_stdlib::reflection::HasClassName for Payload { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Payload { + fn __class_name__() -> &'static str { "Payload" } } @@ -28,6 +33,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Payload { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Payload { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap b/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap index 31f30e473..e5e69bba2 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc043_rust_derive_passthrough.snap @@ -23,6 +23,11 @@ pub struct PayloadKey { } impl incan_stdlib::reflection::HasClassName for PayloadKey { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for PayloadKey { + fn __class_name__() -> &'static str { "PayloadKey" } } @@ -30,6 +35,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for PayloadKey { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for PayloadKey { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap b/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap index ea4cc4693..0362b4e13 100644 --- a/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap +++ b/tests/snapshots/codegen_snapshot_tests__rfc046_computed_properties.snap @@ -16,6 +16,11 @@ pub struct Money { } impl incan_stdlib::reflection::HasClassName for Money { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Money { + fn __class_name__() -> &'static str { "Money" } } @@ -23,6 +28,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Money { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Money { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("cents"), diff --git a/tests/snapshots/codegen_snapshot_tests__rust_allow.snap b/tests/snapshots/codegen_snapshot_tests__rust_allow.snap index 3a2059e13..b94a84a37 100644 --- a/tests/snapshots/codegen_snapshot_tests__rust_allow.snap +++ b/tests/snapshots/codegen_snapshot_tests__rust_allow.snap @@ -14,6 +14,11 @@ struct AllowModel { } impl incan_stdlib::reflection::HasClassName for AllowModel { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for AllowModel { + fn __class_name__() -> &'static str { "AllowModel" } } @@ -21,6 +26,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for AllowModel { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for AllowModel { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), @@ -48,6 +60,11 @@ struct allow_class { } impl incan_stdlib::reflection::HasClassName for allow_class { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for allow_class { + fn __class_name__() -> &'static str { "allow_class" } } @@ -55,6 +72,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for allow_class { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for allow_class { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap index e62c3225c..b5cb6801e 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_async_channel_compiled.snap @@ -33,6 +33,11 @@ pub struct SendError { } impl incan_stdlib::reflection::HasClassName for SendError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for SendError { + fn __class_name__() -> &'static str { "SendError" } } @@ -40,6 +45,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for SendError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for SendError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), @@ -91,6 +103,11 @@ impl Error for SendError { pub struct RecvError {} impl incan_stdlib::reflection::HasClassName for RecvError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for RecvError { + fn __class_name__() -> &'static str { "RecvError" } } @@ -98,6 +115,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for RecvError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for RecvError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } diff --git a/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap index e0e95d4f0..4e1492819 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_async_sync_compiled.snap @@ -40,6 +40,11 @@ pub use ::incan_stdlib::r#async::sync::barrier_wait as rust_barrier_wait; pub struct SemaphoreAcquireError {} impl incan_stdlib::reflection::HasClassName for SemaphoreAcquireError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for SemaphoreAcquireError { + fn __class_name__() -> &'static str { "SemaphoreAcquireError" } } @@ -47,6 +52,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for SemaphoreAcquireError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for SemaphoreAcquireError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } diff --git a/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap index c35691257..0dd3c68ff 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_async_time_compiled.snap @@ -22,6 +22,11 @@ pub use ::incan_stdlib::__private::tokio::time::timeout as tokio_timeout; pub struct TimeoutError {} impl incan_stdlib::reflection::HasClassName for TimeoutError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for TimeoutError { + fn __class_name__() -> &'static str { "TimeoutError" } } @@ -29,6 +34,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for TimeoutError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for TimeoutError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } @@ -63,6 +75,11 @@ pub struct Duration { } impl incan_stdlib::reflection::HasClassName for Duration { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Duration { + fn __class_name__() -> &'static str { "Duration" } } @@ -70,6 +87,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Duration { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Duration { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("secs"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap index 860c0fe39..544f68ed2 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_derives_collection_compiled.snap @@ -36,6 +36,11 @@ pub struct ListIterator { } impl incan_stdlib::reflection::HasClassName for ListIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for ListIterator { + fn __class_name__() -> &'static str { "ListIterator" } } @@ -43,6 +48,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for ListIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for ListIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("items"), @@ -124,6 +136,12 @@ pub struct MapIterator, U> { impl, U> incan_stdlib::reflection::HasClassName for MapIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl, U> incan_stdlib::reflection::HasTypeClassName +for MapIterator { + fn __class_name__() -> &'static str { "MapIterator" } } @@ -132,6 +150,14 @@ for MapIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl, U> incan_stdlib::reflection::HasTypeFieldMetadata +for MapIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -217,6 +243,12 @@ pub struct FilterIterator> { impl> incan_stdlib::reflection::HasClassName for FilterIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for FilterIterator { + fn __class_name__() -> &'static str { "FilterIterator" } } @@ -225,6 +257,14 @@ for FilterIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for FilterIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -320,6 +360,12 @@ pub struct FlatMapIterator, U> { impl, U> incan_stdlib::reflection::HasClassName for FlatMapIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl, U> incan_stdlib::reflection::HasTypeClassName +for FlatMapIterator { + fn __class_name__() -> &'static str { "FlatMapIterator" } } @@ -328,6 +374,14 @@ for FlatMapIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl, U> incan_stdlib::reflection::HasTypeFieldMetadata +for FlatMapIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -475,6 +529,12 @@ pub struct TakeIterator> { impl> incan_stdlib::reflection::HasClassName for TakeIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for TakeIterator { + fn __class_name__() -> &'static str { "TakeIterator" } } @@ -483,6 +543,14 @@ for TakeIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for TakeIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -593,6 +661,12 @@ pub struct SkipIterator> { impl> incan_stdlib::reflection::HasClassName for SkipIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for SkipIterator { + fn __class_name__() -> &'static str { "SkipIterator" } } @@ -601,6 +675,14 @@ for SkipIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for SkipIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -710,6 +792,15 @@ pub struct ChainIterator, Second: Iterator> { impl, Second: Iterator> incan_stdlib::reflection::HasClassName for ChainIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl< + T, + First: Iterator, + Second: Iterator, +> incan_stdlib::reflection::HasTypeClassName for ChainIterator { + fn __class_name__() -> &'static str { "ChainIterator" } } @@ -721,6 +812,17 @@ impl< fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl< + T, + First: Iterator, + Second: Iterator, +> incan_stdlib::reflection::HasTypeFieldMetadata for ChainIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("first"), @@ -848,6 +950,12 @@ pub struct EnumerateIterator> { impl> incan_stdlib::reflection::HasClassName for EnumerateIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for EnumerateIterator { + fn __class_name__() -> &'static str { "EnumerateIterator" } } @@ -856,6 +964,14 @@ for EnumerateIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for EnumerateIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -963,6 +1079,16 @@ pub struct ZipIterator, U, Right: Iterator> { impl, U, Right: Iterator> incan_stdlib::reflection::HasClassName for ZipIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl< + T, + Left: Iterator, + U, + Right: Iterator, +> incan_stdlib::reflection::HasTypeClassName for ZipIterator { + fn __class_name__() -> &'static str { "ZipIterator" } } @@ -975,6 +1101,18 @@ impl< fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl< + T, + Left: Iterator, + U, + Right: Iterator, +> incan_stdlib::reflection::HasTypeFieldMetadata for ZipIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("left"), @@ -1110,6 +1248,12 @@ pub struct TakeWhileIterator> { impl> incan_stdlib::reflection::HasClassName for TakeWhileIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for TakeWhileIterator { + fn __class_name__() -> &'static str { "TakeWhileIterator" } } @@ -1118,6 +1262,14 @@ for TakeWhileIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for TakeWhileIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -1238,6 +1390,12 @@ pub struct SkipWhileIterator> { impl> incan_stdlib::reflection::HasClassName for SkipWhileIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for SkipWhileIterator { + fn __class_name__() -> &'static str { "SkipWhileIterator" } } @@ -1246,6 +1404,14 @@ for SkipWhileIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for SkipWhileIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), @@ -1363,6 +1529,12 @@ pub struct BatchIterator> { impl> incan_stdlib::reflection::HasClassName for BatchIterator { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl> incan_stdlib::reflection::HasTypeClassName +for BatchIterator { + fn __class_name__() -> &'static str { "BatchIterator" } } @@ -1371,6 +1543,14 @@ for BatchIterator { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl> incan_stdlib::reflection::HasTypeFieldMetadata +for BatchIterator { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("source"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap index 79281783d..f8953450f 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_graph_compiled.snap @@ -64,6 +64,11 @@ pub struct GraphError { } impl incan_stdlib::reflection::HasClassName for GraphError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for GraphError { + fn __class_name__() -> &'static str { "GraphError" } } @@ -71,6 +76,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for GraphError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for GraphError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("detail"), @@ -126,6 +138,11 @@ pub struct GraphNode { } impl incan_stdlib::reflection::HasClassName for GraphNode { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for GraphNode { + fn __class_name__() -> &'static str { "GraphNode" } } @@ -133,6 +150,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for GraphNode { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for GraphNode { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), @@ -211,6 +235,11 @@ pub struct GraphEdge { } impl incan_stdlib::reflection::HasClassName for GraphEdge { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for GraphEdge { + fn __class_name__() -> &'static str { "GraphEdge" } } @@ -218,6 +247,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for GraphEdge { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for GraphEdge { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), @@ -314,6 +350,11 @@ pub struct DiGraph { } impl incan_stdlib::reflection::HasClassName for DiGraph { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for DiGraph { + fn __class_name__() -> &'static str { "DiGraph" } } @@ -321,6 +362,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for DiGraph { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for DiGraph { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("next_node_id"), @@ -784,6 +832,11 @@ pub struct Dag { } impl incan_stdlib::reflection::HasClassName for Dag { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Dag { + fn __class_name__() -> &'static str { "Dag" } } @@ -791,6 +844,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Dag { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Dag { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("graph"), @@ -932,6 +992,11 @@ pub struct MultiDiGraph { } impl incan_stdlib::reflection::HasClassName for MultiDiGraph { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for MultiDiGraph { + fn __class_name__() -> &'static str { "MultiDiGraph" } } @@ -939,6 +1004,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for MultiDiGraph { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for MultiDiGraph { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("graph"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap b/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap index e6751f72a..873a1a903 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_serde_json_import.snap @@ -27,6 +27,11 @@ struct Config { } impl incan_stdlib::reflection::HasClassName for Config { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Config { + fn __class_name__() -> &'static str { "Config" } } @@ -34,6 +39,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Config { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Config { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("host"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap b/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap index 30b039e41..11dfe4199 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_serde_with_serialize_trait.snap @@ -21,6 +21,11 @@ struct Payload { } impl incan_stdlib::reflection::HasClassName for Payload { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Payload { + fn __class_name__() -> &'static str { "Payload" } } @@ -28,6 +33,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Payload { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Payload { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap b/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap index bb09ad9e6..b22e5a356 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_traits_convert_usage.snap @@ -15,6 +15,11 @@ struct UserId { } impl incan_stdlib::reflection::HasClassName for UserId { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for UserId { + fn __class_name__() -> &'static str { "UserId" } } @@ -22,6 +27,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for UserId { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for UserId { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), @@ -55,6 +67,11 @@ struct PositiveInt { } impl incan_stdlib::reflection::HasClassName for PositiveInt { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for PositiveInt { + fn __class_name__() -> &'static str { "PositiveInt" } } @@ -62,6 +79,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for PositiveInt { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for PositiveInt { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap b/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap index 81c3bb74c..d152d99cc 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_uuid_compiled.snap @@ -123,6 +123,11 @@ pub struct UuidError { } impl incan_stdlib::reflection::HasClassName for UuidError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for UuidError { + fn __class_name__() -> &'static str { "UuidError" } } @@ -130,6 +135,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for UuidError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for UuidError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("kind"), @@ -451,6 +463,11 @@ struct _UuidText { } impl incan_stdlib::reflection::HasClassName for _UuidText { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for _UuidText { + fn __class_name__() -> &'static str { "_UuidText" } } @@ -458,6 +475,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for _UuidText { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for _UuidText { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("text"), @@ -593,6 +617,11 @@ struct _UuidBytesWriter { } impl incan_stdlib::reflection::HasClassName for _UuidBytesWriter { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for _UuidBytesWriter { + fn __class_name__() -> &'static str { "_UuidBytesWriter" } } @@ -600,6 +629,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for _UuidBytesWriter { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for _UuidBytesWriter { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("out"), diff --git a/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap b/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap index 7eb3ef9ff..e8691a4b5 100644 --- a/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap +++ b/tests/snapshots/codegen_snapshot_tests__trait_supertrait_assignability.snap @@ -28,6 +28,11 @@ struct Thing { } impl incan_stdlib::reflection::HasClassName for Thing { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Thing { + fn __class_name__() -> &'static str { "Thing" } } @@ -35,6 +40,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Thing { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Thing { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("x"), @@ -66,6 +78,11 @@ struct Row { } impl incan_stdlib::reflection::HasClassName for Row { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Row { + fn __class_name__() -> &'static str { "Row" } } @@ -73,6 +90,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Row { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Row { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), @@ -93,6 +117,11 @@ struct Wrapper { } impl incan_stdlib::reflection::HasClassName for Wrapper { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Wrapper { + fn __class_name__() -> &'static str { "Wrapper" } } @@ -100,6 +129,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Wrapper { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Wrapper { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap b/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap index 1fd93b002..fbda4f9c8 100644 --- a/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap +++ b/tests/snapshots/codegen_snapshot_tests__trait_supertraits.snap @@ -19,6 +19,11 @@ struct BoxedValue { } impl incan_stdlib::reflection::HasClassName for BoxedValue { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for BoxedValue { + fn __class_name__() -> &'static str { "BoxedValue" } } @@ -26,6 +31,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for BoxedValue { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for BoxedValue { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__traits.snap b/tests/snapshots/codegen_snapshot_tests__traits.snap index 7b08c0c41..cc880877c 100644 --- a/tests/snapshots/codegen_snapshot_tests__traits.snap +++ b/tests/snapshots/codegen_snapshot_tests__traits.snap @@ -21,6 +21,11 @@ struct Dog { } impl incan_stdlib::reflection::HasClassName for Dog { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Dog { + fn __class_name__() -> &'static str { "Dog" } } @@ -28,6 +33,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Dog { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Dog { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -50,6 +62,11 @@ struct Rectangle { } impl incan_stdlib::reflection::HasClassName for Rectangle { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Rectangle { + fn __class_name__() -> &'static str { "Rectangle" } } @@ -57,6 +74,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Rectangle { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Rectangle { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("width"), @@ -102,6 +126,11 @@ struct Circle { } impl incan_stdlib::reflection::HasClassName for Circle { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Circle { + fn __class_name__() -> &'static str { "Circle" } } @@ -109,6 +138,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Circle { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Circle { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("radius"), @@ -147,6 +183,11 @@ struct Square { } impl incan_stdlib::reflection::HasClassName for Square { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Square { + fn __class_name__() -> &'static str { "Square" } } @@ -154,6 +195,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Square { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Square { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("side"), @@ -208,6 +256,11 @@ struct Carton { } impl incan_stdlib::reflection::HasClassName for Carton { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Carton { + fn __class_name__() -> &'static str { "Carton" } } @@ -215,6 +268,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Carton { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Carton { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("width"), @@ -277,6 +337,11 @@ struct Document { } impl incan_stdlib::reflection::HasClassName for Document { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Document { + fn __class_name__() -> &'static str { "Document" } } @@ -284,6 +349,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Document { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Document { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 2] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("title"), diff --git a/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap b/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap index 4c22c67fb..e194fd984 100644 --- a/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap +++ b/tests/snapshots/codegen_snapshot_tests__uppercase_var_field_access.snap @@ -13,6 +13,11 @@ struct Person { } impl incan_stdlib::reflection::HasClassName for Person { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Person { + fn __class_name__() -> &'static str { "Person" } } @@ -20,6 +25,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Person { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Person { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap index 1e03012e6..1946d42c6 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_method_decorators.snap @@ -37,6 +37,11 @@ struct Box { } impl incan_stdlib::reflection::HasClassName for Box { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Box { + fn __class_name__() -> &'static str { "Box" } } @@ -44,6 +49,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Box { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Box { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap index af8216f72..726260c72 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_mutable_method_decorators.snap @@ -37,6 +37,11 @@ struct Counter { } impl incan_stdlib::reflection::HasClassName for Counter { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Counter { + fn __class_name__() -> &'static str { "Counter" } } @@ -44,6 +49,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Counter { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Counter { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap b/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap index af4e95f49..402af9176 100644 --- a/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap +++ b/tests/snapshots/codegen_snapshot_tests__user_defined_operators.snap @@ -13,6 +13,11 @@ struct Money { } impl incan_stdlib::reflection::HasClassName for Money { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Money { + fn __class_name__() -> &'static str { "Money" } } @@ -20,6 +25,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Money { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Money { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("cents"), @@ -50,6 +62,11 @@ struct User { } impl incan_stdlib::reflection::HasClassName for User { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for User { + fn __class_name__() -> &'static str { "User" } } @@ -57,6 +74,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for User { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for User { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("id"), @@ -82,6 +106,11 @@ struct Row { } impl incan_stdlib::reflection::HasClassName for Row { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Row { + fn __class_name__() -> &'static str { "Row" } } @@ -89,6 +118,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Row { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Row { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), @@ -117,6 +153,11 @@ struct OpBox { } impl incan_stdlib::reflection::HasClassName for OpBox { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for OpBox { + fn __class_name__() -> &'static str { "OpBox" } } @@ -124,6 +165,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for OpBox { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for OpBox { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("value"), diff --git a/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap b/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap index 2760d994f..67d3b209e 100644 --- a/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap +++ b/tests/snapshots/codegen_snapshot_tests__variadic_calls.snap @@ -43,6 +43,11 @@ fn collect_via_callable( struct Collector {} impl incan_stdlib::reflection::HasClassName for Collector { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Collector { + fn __class_name__() -> &'static str { "Collector" } } @@ -50,6 +55,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Collector { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Collector { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 0] = []; incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) } diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap index 3d2ed59a7..67f13defd 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_compression_core_source.snap @@ -103,6 +103,11 @@ pub struct CompressionError { } impl incan_stdlib::reflection::HasClassName for CompressionError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for CompressionError { + fn __class_name__() -> &'static str { "CompressionError" } } @@ -110,6 +115,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for CompressionError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for CompressionError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 4] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("kind"), diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap index 0c9a8a37e..abfb16242 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_io_source.snap @@ -29,6 +29,11 @@ pub struct IoError { } impl incan_stdlib::reflection::HasClassName for IoError { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for IoError { + fn __class_name__() -> &'static str { "IoError" } } @@ -36,6 +41,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for IoError { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for IoError { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 5] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("kind"), @@ -183,6 +195,11 @@ pub struct _BytesIO { } impl incan_stdlib::reflection::HasClassName for _BytesIO { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for _BytesIO { + fn __class_name__() -> &'static str { "_BytesIO" } } @@ -190,6 +207,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for _BytesIO { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for _BytesIO { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("handle"), diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap index 271a4aa2b..b3d176fa3 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_telemetry_core_source.snap @@ -139,6 +139,11 @@ pub struct TelemetryValue { } impl incan_stdlib::reflection::HasClassName for TelemetryValue { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for TelemetryValue { + fn __class_name__() -> &'static str { "TelemetryValue" } } @@ -146,6 +151,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for TelemetryValue { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for TelemetryValue { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 8] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("kind"), @@ -589,6 +601,11 @@ pub struct Resource { } impl incan_stdlib::reflection::HasClassName for Resource { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Resource { + fn __class_name__() -> &'static str { "Resource" } } @@ -596,6 +613,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Resource { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Resource { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("attributes"), @@ -662,6 +686,11 @@ pub struct InstrumentationScope { } impl incan_stdlib::reflection::HasClassName for InstrumentationScope { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for InstrumentationScope { + fn __class_name__() -> &'static str { "InstrumentationScope" } } @@ -669,6 +698,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for InstrumentationScope { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for InstrumentationScope { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("name"), @@ -783,6 +819,11 @@ pub struct SpanContext { } impl incan_stdlib::reflection::HasClassName for SpanContext { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for SpanContext { + fn __class_name__() -> &'static str { "SpanContext" } } @@ -790,6 +831,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for SpanContext { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for SpanContext { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 3] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("trace_id"), diff --git a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap index 2fb5fb3af..68bf684e3 100644 --- a/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap +++ b/tests/snapshots/stdlib_generated_rust_snapshot_tests__std_web_prelude_import.snap @@ -23,6 +23,11 @@ struct Search { } impl incan_stdlib::reflection::HasClassName for Search { fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Search { + fn __class_name__() -> &'static str { "Search" } } @@ -30,6 +35,13 @@ impl incan_stdlib::reflection::HasFieldMetadata for Search { fn __fields__( &self, ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Search { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ incan_stdlib::reflection::FieldInfo { name: incan_stdlib::frozen::FrozenStr::new("q"), diff --git a/workspaces/docs-site/docs/language/reference/reflection.md b/workspaces/docs-site/docs/language/reference/reflection.md index a1db8f6f1..a035d1db6 100644 --- a/workspaces/docs-site/docs/language/reference/reflection.md +++ b/workspaces/docs-site/docs/language/reference/reflection.md @@ -32,7 +32,7 @@ def main() -> None: println(f"{info.name}: {info.type_name}") ``` -## Generic Reflection +## Generic Value Reflection Generic helpers may call `value.__class_name__()` and `value.__fields__()` on a type parameter. The compiler treats those calls as reflection capabilities and emits the required runtime bounds for the generated Rust function, so the generic helper has the same field metadata result as a direct concrete call when it is instantiated with a reflectable model or class. @@ -41,6 +41,31 @@ def reflected_field_count[T](value: T) -> int: return len(value.__fields__()) ``` +## Generic Type Reflection + +Generic schema helpers may also reflect on an explicit type argument without constructing a dummy value. This is the intended shape for APIs that need a model's schema rather than one model instance. + +```incan +def schema_field_count[T]() -> int: + return len(T.__fields__()) + +def schema_name[T]() -> str: + return T.__class_name__() +``` + +Callers instantiate those helpers with a reflectable model or class type: + +```incan +model User: + name: str + email: str + +println(schema_name[User]()) +println(schema_field_count[User]()) +``` + +Model and class type names are still not ordinary runtime values. Use them as constructor callees, type arguments, or type-owned reflection receivers; a bare expression such as `use_value(User)` is rejected unless Incan grows a deliberate first-class type-object feature. + ### `FieldInfo` structure Each `FieldInfo` record contains: diff --git a/workspaces/docs-site/docs/language/reference/stdlib/reflection.md b/workspaces/docs-site/docs/language/reference/stdlib/reflection.md index 0bf7dbeec..f8d95fd77 100644 --- a/workspaces/docs-site/docs/language/reference/stdlib/reflection.md +++ b/workspaces/docs-site/docs/language/reference/stdlib/reflection.md @@ -16,7 +16,7 @@ Import with: from std.reflection import FieldInfo ``` -You only need to import `FieldInfo` when you want to spell the type explicitly in an annotation. Calling `obj.__fields__()` and inspecting the returned records does not require an explicit import. +You only need to import `FieldInfo` when you want to spell the type explicitly in an annotation. Calling `obj.__fields__()` or generic type-level reflection such as `T.__fields__()` and inspecting the returned records does not require an explicit import. ## Types diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 42f51476d..489cf3c27 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -39,8 +39,8 @@ Use this section as the map. The release note names each larger feature, says wh - **Value enums**: Keep enum type safety while exposing canonical `str` or `int` representations for external values. Read [Enums](../language/explanation/enums.md) and [Modeling with enums](../language/how-to/modeling_with_enums.md) ([RFC 032], #317). - **Enum methods and trait adoption**: Put enum-owned behavior on the enum and let enums adopt the same trait protocols as other source types. Read [Enums](../language/explanation/enums.md) and [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) ([RFC 050], #334). - **Computed properties and protocol hooks**: Define property-like readers and dunder-backed operator/protocol behavior without pushing users into Rust-shaped wrappers. Read [Traits as language hooks](../language/explanation/traits_as_language_hooks.md) and [Derives and traits](../language/reference/derives_and_traits.md) ([RFC 046], [RFC 068], [RFC 028], #86, #162, #203). -- **Model and class reflection**: Inspect source field metadata and class names from concrete values or generic helpers with `__fields__()` and `__class_name__()`. Read [Reflection](../language/reference/reflection.md) and [`std.reflection`](../language/reference/stdlib/reflection.md) (#712). -- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape, concrete decorated callable values expose `__name__` for registry-style decorators, and decorated wrappers preserve source default-argument calls when the callable surface is unchanged. Read [Decorators](../language/reference/language.md#decorators), [Functions](../language/reference/functions.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640, #694, #703). +- **Model and class reflection**: Inspect source field metadata and class names from concrete values, generic value helpers, or explicit model type arguments with `__fields__()` and `__class_name__()`. Read [Reflection](../language/reference/reflection.md) and [`std.reflection`](../language/reference/stdlib/reflection.md) (#712, #714, #715). +- **Decorators**: Typecheck user-defined decorators for functions, async functions, and methods so later references see the decorated callable shape, concrete decorated callable values expose `__name__` for registry-style decorators, decorated generic wrappers keep explicit type-argument calls, and decorated wrappers preserve source default-argument calls when the callable surface is unchanged. Read [Decorators](../language/reference/language.md#decorators), [Functions](../language/reference/functions.md), and [Checked API metadata](../tooling/reference/checked_api_metadata.md) ([RFC 036], #170, #640, #694, #703, #715). - **Symbol aliases**: Export an existing callable or type-like symbol under another name without pretending it is a hand-written wrapper. Read [Symbol aliases](../language/reference/symbol_aliases.md) ([RFC 083], #437). - **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). - **Variadics and call unpacking**: Describe call shapes that accept or forward flexible argument lists without losing static checks. Read [Functions](../language/reference/functions.md) ([RFC 038], #83). @@ -85,12 +85,12 @@ This section is grouped by outcome rather than by every minimized repro. Issue n ### Compiler Correctness -- **Argument planning is shared**: Ownership and Rust-boundary coercion now route through one argument-use plan instead of parallel caller/emitter heuristics. +- **Argument planning is shared**: Ownership and Rust-boundary coercion now route through one argument-use plan instead of parallel caller/emitter heuristics, including temporary string expressions passed to Rust `&str` parameters and Rust enum variants that own `String` payloads (#716). - **Duckborrowing covers more real use sites**: Generated Rust handles arguments, returns, assignments, match scrutinees, aggregate elements, lookups, comprehensions, mutable aggregate parameters, Rust calls, and generated `Clone` bounds with fewer user-authored workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Release smoke paths are less fragile**: InQL and release smoke testing fixed loop-item fields, union call arguments, storage-rooted match scrutinees, static list index assignment, typed `assert false` lowering, and const model metadata constructors (#620, #621, #622, #627, #630, #644, #671, #674). - **Generic and trait flow keeps more type information**: Instantiated receivers, generic fields, generic methods, `list[Self]`, trait/supertrait upcasts, imported prost oneofs, explicit generic cycles, and static factory locals now survive typechecking and lowering more consistently (#237, #231, #253, #230, #184, #218, #279, #252, #255). - **Runtime-boundary errors are clearer**: `std.regex` text borrowing, collection f-string formatting, and collection/string conversion diagnostics fail closer to the Incan source instead of surfacing as obscure Rust/runtime errors (#624, #625, #71, #81). -- **Reflection is capability-backed**: Generic `T.__class_name__()` and `T.__fields__()` calls infer runtime reflection bounds, and field-only classes emit the same reflection support as models (#712). +- **Reflection is capability-backed**: Generic value reflection and explicit type-argument reflection infer the right generated Rust bounds, while bare model names in value position now fail at the Incan diagnostic layer instead of leaking Rust type paths (#712, #714, #715). - **Enum patterns use enum-owned metadata**: Qualified enum patterns now read variant payloads from the enum's semantic metadata instead of ambient variant symbols, keeping imported stdlib enums stable when project enums reuse variant names (#710). - **Stringly compiler behavior is guarded**: Remaining semantic string comparisons in high-risk compiler paths are fingerprinted so new string-based behavior has to be centralized or explicitly classified. From e3a1fb4baa78362c93b52ba99a35ecfc4ffeb8c7 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 30 May 2026 15:06:31 +0200 Subject: [PATCH 49/58] bugfix - preserve Rust keyword call arguments (#718) (#719) --- .../check_expr/calls/rust_boundary.rs | 224 ++++++++++++++++-- tests/cli_integration.rs | 7 +- .../docs/language/how-to/rust_interop.md | 12 + .../docs-site/docs/release_notes/0_3.md | 1 + 4 files changed, 221 insertions(+), 23 deletions(-) diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index ee2256de0..6b674052b 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -6,7 +6,7 @@ use crate::frontend::diagnostics::errors; use crate::frontend::symbols::{CallableParam, ResolvedType, TypeInfo}; use crate::frontend::typechecker::helpers::collection_type_id; use crate::frontend::typechecker::{RustArgCoercionInfo, RustArgCoercionKind}; -use incan_core::interop::{CoercionPolicy, RustFunctionSig, admitted_builtin_coercion}; +use incan_core::interop::{CoercionPolicy, RustFunctionSig, RustParam, admitted_builtin_coercion}; use incan_core::lang::types::collections::CollectionTypeId; use incan_core::lang::types::numerics; @@ -20,6 +20,12 @@ enum RustArgBoundaryMatch { NoMatch, } +struct RustCallArgBinding<'a> { + arg: &'a CallArg, + arg_ty: &'a ResolvedType, + param: &'a RustParam, +} + impl TypeChecker { /// Eagerly cache metadata for Rust path types returned by inspected Rust calls. /// @@ -378,24 +384,147 @@ impl TypeChecker { span: Span, params: &[incan_core::interop::RustParam], owner_path: &str, + force_exact: bool, ) { let params: Vec = params .iter() .map(|param| { - CallableParam::positional(self.resolved_rust_boundary_target_from_param_display_for_owner_path( + let ty = self.resolved_rust_boundary_target_from_param_display_for_owner_path( param.type_display.as_str(), owner_path, - )) + ); + CallableParam { + name: param.name.clone(), + ty, + kind: ParamKind::Normal, + has_default: false, + } }) .collect(); // Plain Rust type variables carry by-value shape, but they are not ordinary borrow-boundary snapshots. - if params.iter().any(|param| matches!(param.ty, ResolvedType::TypeVar(_))) { + if force_exact || params.iter().any(|param| matches!(param.ty, ResolvedType::TypeVar(_))) { self.type_info.record_call_site_callable_params_exact(span, ¶ms); } else { self.type_info.record_call_site_callable_params(span, ¶ms); } } + fn bind_rust_call_args<'a>( + &mut self, + callable_display: &str, + params: &'a [RustParam], + args: &'a [CallArg], + arg_types: &'a [ResolvedType], + span: Span, + ) -> Vec> { + let has_keyword_args = args + .iter() + .any(|arg| matches!(arg, CallArg::Named(_, _) | CallArg::KeywordUnpack(_))); + let has_unpack_args = args + .iter() + .any(|arg| matches!(arg, CallArg::PositionalUnpack(_) | CallArg::KeywordUnpack(_))); + if !has_keyword_args || has_unpack_args { + if arg_types.len() != params.len() { + self.errors.push(errors::builtin_arity( + callable_display, + params.len(), + arg_types.len(), + span, + )); + return Vec::new(); + } + return args + .iter() + .zip(arg_types.iter()) + .zip(params.iter()) + .map(|((arg, arg_ty), param)| RustCallArgBinding { arg, arg_ty, param }) + .collect(); + } + + let mut params_by_name = std::collections::HashMap::new(); + for (idx, param) in params.iter().enumerate() { + if let Some(name) = param.name.as_deref() { + params_by_name.insert(name, idx); + } + } + + let mut bound_spans: Vec> = vec![None; params.len()]; + let mut named_seen: std::collections::HashMap<&str, Span> = std::collections::HashMap::new(); + let mut positional_index = 0usize; + let mut unexpected_positional = 0usize; + let mut bindings = Vec::new(); + + for (arg, arg_ty) in args.iter().zip(arg_types.iter()) { + let arg_span = Self::call_arg_expr(arg).span; + match arg { + CallArg::Positional(_) => { + if positional_index >= params.len() { + unexpected_positional += 1; + continue; + } + let param = ¶ms[positional_index]; + if let Some(bound_span) = bound_spans[positional_index] { + let name = param.name.as_deref().unwrap_or(""); + self.errors + .push(errors::duplicate_call_argument(callable_display, name, bound_span)); + } else { + bound_spans[positional_index] = Some(arg_span); + bindings.push(RustCallArgBinding { arg, arg_ty, param }); + } + positional_index += 1; + } + CallArg::Named(name, _) => { + if let Some(first_span) = named_seen.insert(name.as_str(), arg_span) { + self.errors + .push(errors::duplicate_call_argument(callable_display, name, first_span)); + } + let Some(param_index) = params_by_name.get(name.as_str()).copied() else { + self.errors + .push(errors::unknown_keyword_argument(callable_display, name, arg_span)); + continue; + }; + if bound_spans[param_index].is_some() { + self.errors + .push(errors::duplicate_call_argument(callable_display, name, arg_span)); + continue; + } + let param = ¶ms[param_index]; + bound_spans[param_index] = Some(arg_span); + bindings.push(RustCallArgBinding { arg, arg_ty, param }); + } + CallArg::PositionalUnpack(_) | CallArg::KeywordUnpack(_) => {} + } + } + + if unexpected_positional > 0 { + self.errors.push(errors::builtin_arity( + callable_display, + params.len(), + params.len() + unexpected_positional, + span, + )); + } + + let mut missing_unnamed_param = false; + for (idx, param) in params.iter().enumerate() { + if bound_spans[idx].is_some() { + continue; + } + if let Some(name) = param.name.as_deref() { + self.errors + .push(errors::missing_required_argument(callable_display, name, span)); + } else { + missing_unnamed_param = true; + } + } + if missing_unnamed_param { + self.errors + .push(errors::builtin_arity(callable_display, params.len(), args.len(), span)); + } + + bindings + } + /// Return whether a lookup-style Rust method should preserve the probe argument's emitted shape. fn rust_lookup_probe_boundary_match(&self, arg_ty: &ResolvedType, target_ty: &ResolvedType) -> bool { let ResolvedType::Ref(inner) = target_ty else { @@ -448,20 +577,21 @@ impl TypeChecker { } else { &sig.params }; - self.record_rust_call_site_params(span, params, callable_display); + let has_keyword_args = args + .iter() + .any(|arg| matches!(arg, CallArg::Named(_, _) | CallArg::KeywordUnpack(_))); + self.record_rust_call_site_params(span, params, callable_display, has_keyword_args); - if arg_types.len() != params.len() { - self.errors.push(errors::builtin_arity( - callable_display, - params.len(), - arg_types.len(), - span, - )); + let binding_errors_before = self.errors.len(); + let bindings = self.bind_rust_call_args(callable_display, params, args, arg_types, span); + if self.errors.len() != binding_errors_before { return self.resolved_rust_call_type_from_sig(sig, callable_display, span); } - for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(params.iter()) { - let arg_expr = Self::call_arg_expr(arg); + for binding in bindings { + let arg_expr = Self::call_arg_expr(binding.arg); + let arg_ty = binding.arg_ty; + let param = binding.param; let param_display = self.rust_display_for_owner_path(param.type_display.as_str(), callable_display); let normalized = param_display.replace(' ', ""); let target_ty = self.resolved_rust_boundary_target_from_param_display_for_owner_path( @@ -513,15 +643,20 @@ impl TypeChecker { } let expected_params = self.rust_params_as_callable_params(&sig.params, path); let arg_types = self.check_call_arg_types_for_params(args, &expected_params); - self.record_rust_call_site_params(span, &sig.params, path); - if arg_types.len() != sig.params.len() { - self.errors - .push(errors::builtin_arity(path, sig.params.len(), arg_types.len(), span)); + let has_keyword_args = args + .iter() + .any(|arg| matches!(arg, CallArg::Named(_, _) | CallArg::KeywordUnpack(_))); + self.record_rust_call_site_params(span, &sig.params, path, has_keyword_args); + let binding_errors_before = self.errors.len(); + let bindings = self.bind_rust_call_args(path, &sig.params, args, &arg_types, span); + if self.errors.len() != binding_errors_before { return self.resolved_rust_call_type_from_sig(sig, path, span); } - for ((arg, arg_ty), param) in args.iter().zip(arg_types.iter()).zip(sig.params.iter()) { - let arg_expr = Self::call_arg_expr(arg); + for binding in bindings { + let arg_expr = Self::call_arg_expr(binding.arg); + let arg_ty = binding.arg_ty; + let param = binding.param; let param_display = self.rust_display_for_owner_path(param.type_display.as_str(), path); let normalized = param_display.replace(' ', ""); let target_ty = @@ -718,6 +853,55 @@ mod validate_rust_function_call_tests { ); } + #[test] + fn rust_function_call_binds_keyword_args_by_inspected_param_name() { + let mut checker = TypeChecker::new(); + let span = Span::new(0, 60); + let text_span = Span::new(10, 20); + let count_span = Span::new(30, 31); + let args = [ + CallArg::Named( + "text".to_string(), + Spanned::new(Expr::Literal(Literal::String("demo".to_string())), text_span), + ), + CallArg::Named( + "count".to_string(), + Spanned::new(Expr::Literal(Literal::Int(IntLiteral::synthetic(3))), count_span), + ), + ]; + let sig = RustFunctionSig { + params: vec![ + RustParam { + name: Some("count".to_string()), + type_display: "i64".to_string(), + }, + RustParam { + name: Some("text".to_string()), + type_display: "&str".to_string(), + }, + ], + return_type: "()".to_string(), + is_async: false, + is_unsafe: false, + }; + + let _ = checker.validate_rust_function_call("rust::crate::f", &sig, &args, span); + + assert!( + checker.errors.is_empty(), + "expected keyword Rust call args to bind by parameter name, errors={:?}", + checker.errors + ); + let recorded = checker + .type_info + .calls + .call_site_callable_params + .get(&(span.start, span.end)) + .expect("keyword Rust calls should record exact call-site params for lowering"); + let names: Vec<_> = recorded.iter().filter_map(|param| param.name.as_deref()).collect(); + assert_eq!(names, vec!["count", "text"]); + } + #[test] fn rust_arg_boundary_accepts_structural_list_to_vec() { let checker = TypeChecker::new(); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 18ea0e7f9..38ce9deab 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -663,13 +663,13 @@ def callback(args: list[ColumnarValue]) -> Result[ColumnarValue, CallbackError]: return Ok(args[0].clone()) def inline_arc_callback_value() -> int: - match create_udf("inline", Arc.from((args) => callback(args.to_vec()))): + match create_udf(callback=Arc.from((args) => callback(args.to_vec())), name="inline"): Ok(value) => return value.value() Err(_) => return -1 pub def arc_callback_case() -> str: implementation: SliceCallback = Arc.from((args) => callback(args.to_vec())) - match create_udf("assigned", implementation): + match create_udf(callback=implementation, name="assigned"): Ok(value) => return f"arc_callback:{value.value()}:{inline_arc_callback_value()}" Err(_) => return "arc_callback:err" "#, @@ -776,7 +776,8 @@ pub fn invoke(callback: SliceCallback) -> Result { callback(&args) } -pub fn create_udf(_name: &str, callback: crate::SliceCallback) -> Result { +pub fn create_udf(name: &str, callback: crate::SliceCallback) -> Result { + let _ = name; let args = vec![ColumnarValue::new(11)]; callback(&args) } diff --git a/workspaces/docs-site/docs/language/how-to/rust_interop.md b/workspaces/docs-site/docs/language/how-to/rust_interop.md index 4a029ed56..ad96bad9b 100644 --- a/workspaces/docs-site/docs/language/how-to/rust_interop.md +++ b/workspaces/docs-site/docs/language/how-to/rust_interop.md @@ -220,6 +220,18 @@ When a library exposes Rust-backed items, run `incan build --lib` before another Consumers load that shipped ABI metadata first for Rust-backed imported symbols. `rust_inspect` remains available for producer capture, local workspace imports, and explicit fallback/debug paths, but a packaged dependency should not require consumer-side workspace inspection for signatures that were already published in its `.incnlib`. +### Calling imported Rust functions + +Imported Rust free functions use ordinary Incan call syntax. When the compiler has inspected or shipped Rust signature metadata with parameter names, keyword arguments bind to those Rust parameters and lower to the positional Rust call shape that Cargo expects: + +```incan +from rust::demo import create_widget + +widget = create_widget(name="primary", enabled=true) +``` + +If the Rust metadata does not include the named parameter, the compiler rejects the keyword argument instead of guessing a positional mapping. + ### Qualified backing paths When a `rust::` import binds a Rust module (or other namespace), you can name a concrete type inside it with `::` after that binding: diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 489cf3c27..c5b250194 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -50,6 +50,7 @@ Use this section as the map. The release note names each larger feature, says wh - **Rust trait adoption from Incan source**: Newtypes and rusttypes can adopt Rust traits with `with Trait`, method-level `for Trait`, and associated type declarations. Read [Rust interop](../language/how-to/rust_interop.md), [Rust types for Python developers](../language/how-to/rust_types_for_python_devs.md), and [`std.traits`](../language/reference/stdlib/traits.md) ([RFC 043], #200). - **Derived and inspected Rust metadata**: Supported `@rust.derive(...)`, associated types, inspected Rust signatures, and metadata-backed call boundaries now survive further through generated calls. Read [Derives and traits](../language/explanation/derives_and_traits.md) and [Rust-shaped confidence](../language/explanation/rust_shaped_confidence.md) ([RFC 041], #175). +- **Rust imported calls follow Incan argument binding**: Imported Rust free functions can use keyword arguments when inspected or shipped Rust metadata provides parameter names; codegen lowers those calls to the positional Rust call shape (#718). - **Checked public API metadata**: Public declarations, aliases, partials, models, enum variants, decorator-backed callable context, and facade alias projections can be emitted for tools and downstream consumers. Read [Checked API metadata](../tooling/reference/checked_api_metadata.md) and [LSP protocol support](../tooling/reference/lsp_protocol_support.md) ([RFC 048], #205, #438, #694, #695). ### Standard Library From 21a4f2897fe154e8afb990f670f8c04435df05bd Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sat, 30 May 2026 19:35:11 +0200 Subject: [PATCH 50/58] bugfix - preserve decorated rest wrappers in v0.3 gate (#720) (#721) --- .github/workflows/ci.yml | 21 +++ Cargo.lock | 18 +-- Cargo.toml | 2 +- .../src/bin/generate_lang_reference.rs | 6 + crates/incan_core/src/lang/surface/methods.rs | 1 + crates/incan_core/src/lang/testing.rs | 1 + .../src/collections/ordinal_map.rs | 14 ++ crates/incan_stdlib/src/frozen.rs | 2 + crates/incan_stdlib/src/iter.rs | 1 + crates/incan_stdlib/src/testing.rs | 1 + crates/incan_stdlib/src/validation.rs | 9 ++ crates/incan_syntax/src/ast/visitor.rs | 1 + .../src/diagnostics/catalog/errors/types.rs | 14 ++ crates/incan_syntax/src/parser/expr.rs | 6 + crates/incan_syntax/src/parser/stmts.rs | 5 + crates/rust_inspect/src/extractor.rs | 6 + crates/rust_inspect/src/loader.rs | 1 + scripts/check_changed_rustdocs.py | 32 ++++- src/backend/ir/codegen.rs | 1 + src/backend/ir/codegen/dependency_metadata.rs | 2 + src/backend/ir/codegen/serde_activation.rs | 1 + src/backend/ir/emit/consts.rs | 2 + src/backend/ir/emit/decls/impls.rs | 1 + src/backend/ir/emit/expressions/calls.rs | 2 + .../emit/expressions/calls/testing_asserts.rs | 12 ++ .../ir/emit/expressions/comprehensions.rs | 6 + src/backend/ir/emit/expressions/indexing.rs | 2 + .../ir/emit/expressions/interop_coercions.rs | 1 + src/backend/ir/emit/expressions/methods.rs | 4 + .../expressions/methods/collection_methods.rs | 2 + .../ir/emit/expressions/methods/fast_paths.rs | 14 ++ src/backend/ir/emit/expressions/mod.rs | 4 + src/backend/ir/emit/mod.rs | 16 +++ src/backend/ir/emit/program.rs | 8 ++ src/backend/ir/emit/types.rs | 2 + src/backend/ir/lower/decl/functions.rs | 3 + src/backend/ir/lower/decl/methods.rs | 47 ++----- src/backend/ir/lower/expr/calls.rs | 2 + src/backend/ir/lower/expr/mod.rs | 2 + src/backend/ir/lower/mod.rs | 121 ++++++++++++------ src/backend/ir/lower/stmt.rs | 6 + src/backend/ir/mod.rs | 2 + src/backend/ir/trait_bound_inference.rs | 14 ++ src/backend/project/generator.rs | 3 + src/cli/commands/common.rs | 7 + src/cli/test_runner/execution.rs | 16 +++ src/cli/test_runner/module_graph.rs | 1 + src/dependency_resolver.rs | 6 + src/format/comments/buffer.rs | 2 + src/format/comments/mod.rs | 2 + src/format/comments/model.rs | 2 + src/format/comments/reattach.rs | 2 + src/format/comments/scanner.rs | 1 + src/format/formatter/declarations.rs | 6 + src/format/formatter/expressions.rs | 7 + src/format/formatter/mod.rs | 1 + src/format/formatter/statements.rs | 5 + src/frontend/api_metadata.rs | 54 ++++++++ src/frontend/ast_walk.rs | 2 + src/frontend/contract_metadata.rs | 9 ++ src/frontend/library_exports.rs | 3 + src/frontend/testing_markers.rs | 2 + src/frontend/typechecker/check_decl.rs | 5 + src/frontend/typechecker/check_expr/access.rs | 7 + src/frontend/typechecker/check_expr/calls.rs | 1 + .../typechecker/check_expr/calls/args.rs | 1 + .../typechecker/check_expr/calls/builtins.rs | 1 + .../check_expr/calls/constructors.rs | 2 + .../check_expr/calls/generic_bounds.rs | 1 + .../check_expr/calls/rust_boundary.rs | 4 + src/frontend/typechecker/check_expr/mod.rs | 2 + src/frontend/typechecker/check_stmt.rs | 3 + .../typechecker/collect/decl_helpers.rs | 3 + .../typechecker/collect/decorators.rs | 5 + .../typechecker/collect/stdlib_imports.rs | 2 + src/frontend/typechecker/mod.rs | 13 ++ .../typechecker/trait_bound_relations.rs | 2 + src/frontend/vocab_ast_bridge.rs | 1 + src/library_manifest/model.rs | 2 + src/lockfile.rs | 1 + src/lsp/backend.rs | 4 + src/lsp/call_site_type_args.rs | 3 + tests/codegen_snapshot_tests.rs | 7 + .../decorated_variadic_function.incn | 14 ++ tests/integration_tests.rs | 106 +++++++++++++++ ...ot_tests__decorated_variadic_function.snap | 117 +++++++++++++++++ .../docs/language/how-to/rust_interop.md | 3 +- .../docs/language/reference/stdlib/index.md | 1 + .../language/reference/stdlib/telemetry.md | 61 +++++++++ .../docs-site/docs/release_notes/0_3.md | 4 + .../docs-site/docs/release_notes/index.md | 2 +- .../tooling/tutorials/your_first_project.md | 3 +- workspaces/docs-site/mkdocs.yml | 1 + 93 files changed, 840 insertions(+), 91 deletions(-) create mode 100644 tests/codegen_snapshots/decorated_variadic_function.incn create mode 100644 tests/snapshots/codegen_snapshot_tests__decorated_variadic_function.snap create mode 100644 workspaces/docs-site/docs/language/reference/stdlib/telemetry.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e8da6aeb..319b0a66c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,27 @@ jobs: - name: Check formatting run: cargo +nightly fmt --all -- --check + rustdoc-gate: + name: Rustdoc Gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Check changed Rust docs + shell: bash + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch origin "${{ github.base_ref }}" --depth=1 + base_ref="origin/${{ github.base_ref }}" + elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + base_ref="${{ github.event.before }}" + else + base_ref="HEAD^" + fi + python3 scripts/check_changed_rustdocs.py --base "$base_ref" + clippy: name: Clippy runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index d7a1d0ea6..9fcdd73a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc28" +version = "0.3.0-rc29" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index e880dfc25..69a2ca4b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc28" +version = "0.3.0-rc29" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/bin/generate_lang_reference.rs b/crates/incan_core/src/bin/generate_lang_reference.rs index 1935a304c..1ca92d3ed 100644 --- a/crates/incan_core/src/bin/generate_lang_reference.rs +++ b/crates/incan_core/src/bin/generate_lang_reference.rs @@ -175,14 +175,17 @@ fn write_feature_inventory_reference(path: &Path) { } } +/// Escape generated reference text so it is safe inside a Markdown table cell. fn markdown_table_cell(value: &str) -> String { value.replace('|', "\\|").replace('\n', " ") } +/// Wrap generated reference text in Markdown code formatting. fn markdown_code(value: &str) -> String { format!("`{}`", value.replace('`', "\\`")) } +/// Render a comma-separated list of Markdown links for generated reference output. fn markdown_links(links: &[features::FeatureLink]) -> String { links .iter() @@ -191,6 +194,7 @@ fn markdown_links(links: &[features::FeatureLink]) -> String { .join(", ") } +/// Render the canonical source forms cell for a generated reference table row. fn canonical_forms_cell(forms: &[&str]) -> String { if forms.is_empty() { return "-".to_string(); @@ -202,6 +206,7 @@ fn canonical_forms_cell(forms: &[&str]) -> String { .join("
") } +/// Render the compact feature summary table for the generated language reference. fn render_features_summary_section(out: &mut String) { start_section(out, "## All features"); @@ -226,6 +231,7 @@ fn render_features_summary_section(out: &mut String) { out.push('\n'); } +/// Render detailed feature entries for the generated language reference. fn render_features_detail_section(out: &mut String) { start_section(out, "## Feature details"); diff --git a/crates/incan_core/src/lang/surface/methods.rs b/crates/incan_core/src/lang/surface/methods.rs index 4664c21fc..541761ae4 100644 --- a/crates/incan_core/src/lang/surface/methods.rs +++ b/crates/incan_core/src/lang/surface/methods.rs @@ -1353,6 +1353,7 @@ pub mod iterator_methods { super::info_for_impl(ITERATOR_METHODS, id, "iterator method info missing") } + /// Return static metadata for this language-surface method. const fn info(id: IteratorMethodId, canonical: &'static str, description: &'static str) -> IteratorMethodInfo { LangItemInfo { id, diff --git a/crates/incan_core/src/lang/testing.rs b/crates/incan_core/src/lang/testing.rs index 081bfd946..97d4c97c1 100644 --- a/crates/incan_core/src/lang/testing.rs +++ b/crates/incan_core/src/lang/testing.rs @@ -103,6 +103,7 @@ pub fn assert_comparison_failure_kind(id: TestingAssertHelperId) -> Option<&'sta } } +/// Build metadata for a standard-library assertion helper. const fn assert_helper(id: TestingAssertHelperId, canonical: &'static str) -> TestingAssertHelperInfo { LangItemInfo { id, diff --git a/crates/incan_stdlib/src/collections/ordinal_map.rs b/crates/incan_stdlib/src/collections/ordinal_map.rs index 6e916992c..4fd506326 100644 --- a/crates/incan_stdlib/src/collections/ordinal_map.rs +++ b/crates/incan_stdlib/src/collections/ordinal_map.rs @@ -14,12 +14,14 @@ macro_rules! __incan_ordinal_map_string_fast_impls { () => { impl OrdinalMap { + /// Return whether an ordinal-map key matching the provided string exists. #[doc(hidden)] #[inline] pub fn __incan_ordinal_contains_str(&self, key: &str) -> bool { self.__incan_ordinal_find_str(key, true) >= 0 } + /// Return an ordinal-map value for indexing syntax or raise a key error. #[doc(hidden)] #[inline] pub fn __incan_ordinal_getitem_str(&self, key: &str) -> i64 { @@ -29,6 +31,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Return an optional ordinal-map value for the provided string key. #[doc(hidden)] #[inline] pub fn __incan_ordinal_get_str(&self, key: &str) -> Option { @@ -40,6 +43,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Return an ordinal-map value for a required string key or raise a key error. #[doc(hidden)] #[inline] pub fn __incan_ordinal_require_str(&self, key: &str) -> Result { @@ -53,6 +57,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Return an ordinal-map value for a known-present string key without rechecking presence. #[doc(hidden)] #[inline] pub fn __incan_ordinal_get_unchecked_str(&self, key: &str) -> i64 { @@ -64,6 +69,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Return optional ordinal-map values for a sequence of string keys. #[doc(hidden)] #[inline] pub fn __incan_ordinal_get_many_str(&self, keys: &[String]) -> Vec> { @@ -74,6 +80,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { out } + /// Return required ordinal-map values for a sequence of string keys or raise on the first miss. #[doc(hidden)] #[inline] pub fn __incan_ordinal_require_many_str(&self, keys: &[String]) -> Result, OrdinalMapError> { @@ -93,6 +100,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { Ok(out) } + /// Return ordinal-map values for known-present string keys without rechecking presence. #[doc(hidden)] #[inline] pub fn __incan_ordinal_get_many_unchecked_str(&self, keys: &[String]) -> Vec { @@ -103,6 +111,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { out } + /// Find the ordinal-map slot for a string key using compact key metadata. #[inline] fn __incan_ordinal_find_str(&self, key: &str, verify_exact: bool) -> i64 { if self.slot_count_value == 0 { @@ -134,6 +143,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { -1i64 } + /// Return the ordinal-map value at a precomputed slot index. #[inline] fn __incan_ordinal_at_fast(&self, record_index: i64) -> i64 { if record_index < 0 { @@ -151,6 +161,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Return the compact hash stored for a precomputed ordinal-map slot. #[inline] fn __incan_ordinal_hash_at_fast(&self, record_index: i64) -> i64 { if record_index < 0 { @@ -162,6 +173,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { .unwrap_or(-1i64) } + /// Return the compact slot metadata stored at a precomputed ordinal-map offset. #[inline] fn __incan_ordinal_slot_at_fast(&self, slot_index: i64) -> i64 { if slot_index < 0 { @@ -179,6 +191,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Return whether the compact key bytes at an offset match the provided string key. #[inline] fn __incan_ordinal_key_bytes_equal_str(&self, record_index: i64, key_bytes: &[u8]) -> bool { if record_index < 0 { @@ -208,6 +221,7 @@ macro_rules! __incan_ordinal_map_string_fast_impls { } } + /// Read a compact little-endian integer from ordinal-map metadata. #[inline] fn __incan_ordinal_read_compact_int_fast(&self, data: &[u8], offset: i64, width: i64) -> i64 { if offset < 0 { diff --git a/crates/incan_stdlib/src/frozen.rs b/crates/incan_stdlib/src/frozen.rs index fef42c5f5..3ecfaf38c 100644 --- a/crates/incan_stdlib/src/frozen.rs +++ b/crates/incan_stdlib/src/frozen.rs @@ -199,6 +199,7 @@ impl FrozenList { } impl AsRef<[T]> for FrozenList { + /// Return a borrowed view of this value. fn as_ref(&self) -> &[T] { self.data } @@ -207,6 +208,7 @@ impl AsRef<[T]> for FrozenList { impl core::ops::Deref for FrozenList { type Target = [T]; + /// Return the underlying target for deref coercions. fn deref(&self) -> &Self::Target { self.data } diff --git a/crates/incan_stdlib/src/iter.rs b/crates/incan_stdlib/src/iter.rs index 0ee4165a0..8554c9515 100644 --- a/crates/incan_stdlib/src/iter.rs +++ b/crates/incan_stdlib/src/iter.rs @@ -150,6 +150,7 @@ impl GeneratorYield { impl Iterator for Generator { type Item = T; + /// Return the next item from this iterator bridge. #[inline] fn next(&mut self) -> Option { self.iter.next() diff --git a/crates/incan_stdlib/src/testing.rs b/crates/incan_stdlib/src/testing.rs index 4fbd07091..f042d8672 100644 --- a/crates/incan_stdlib/src/testing.rs +++ b/crates/incan_stdlib/src/testing.rs @@ -23,6 +23,7 @@ pub fn testing_marker_runtime_misuse_message(marker: &str) -> String { format!("std.testing.{marker} is marker metadata for `incan test` and is not executable runtime logic") } +/// Report misuse of compile-time testing markers at runtime. fn marker_runtime_misuse(marker: &str) -> ! { crate::errors::__private::raise_runtime_misuse(&testing_marker_runtime_misuse_message(marker)); } diff --git a/crates/incan_stdlib/src/validation.rs b/crates/incan_stdlib/src/validation.rs index 4ca4f3512..e346c38f1 100644 --- a/crates/incan_stdlib/src/validation.rs +++ b/crates/incan_stdlib/src/validation.rs @@ -10,6 +10,7 @@ pub struct ValidationError { } impl ValidationError { + /// Create a validation error without a machine-readable code. pub fn new(message: impl Into) -> Self { Self { message: message.into(), @@ -17,6 +18,7 @@ impl ValidationError { } } + /// Create a validation error with a machine-readable code. pub fn with_code(message: impl Into, code: impl Into) -> Self { Self { message: message.into(), @@ -43,6 +45,7 @@ pub struct ValidationFailure { } impl ValidationFailure { + /// Create a field/path validation failure from a displayable error. pub fn new(path: impl Into, error: impl Display) -> Self { Self { path: path.into(), @@ -81,6 +84,7 @@ pub struct ValidationErrorsBuilder { } impl ValidationErrorsBuilder { + /// Create a validation-error builder for a target type. pub fn new(target: impl Into) -> Self { Self { target: target.into(), @@ -88,14 +92,17 @@ impl ValidationErrorsBuilder { } } + /// Add a validation failure for one field or path. pub fn push_field_error(&mut self, field: impl Into, error: impl Display) { self.failures.push(ValidationFailure::new(field, error)); } + /// Return whether no validation failures have been collected. pub fn is_empty(&self) -> bool { self.failures.is_empty() } + /// Raise an aggregate validation error if any failures were collected. pub fn raise_if_any(self) { if !self.failures.is_empty() { crate::errors::raise(ValidationErrors { @@ -106,6 +113,7 @@ impl ValidationErrorsBuilder { } } +/// Raise a validation error for a failed validated-newtype hook. #[cold] #[track_caller] pub fn raise_validation_error(target: impl AsRef, hook: impl AsRef, error: impl Display) -> ! { @@ -116,6 +124,7 @@ pub fn raise_validation_error(target: impl AsRef, hook: impl AsRef, er )) } +/// Raise a validation error for a failed named constraint. #[cold] #[track_caller] pub fn raise_constraint_error(target: impl AsRef, constraint: impl AsRef) -> ! { diff --git a/crates/incan_syntax/src/ast/visitor.rs b/crates/incan_syntax/src/ast/visitor.rs index 55095cdb3..0addfb7cb 100644 --- a/crates/incan_syntax/src/ast/visitor.rs +++ b/crates/incan_syntax/src/ast/visitor.rs @@ -49,6 +49,7 @@ pub trait Visitor { fn visit_newtype(&mut self, _newtype: &NewtypeDecl) {} fn visit_enum(&mut self, _enum: &EnumDecl) {} fn visit_function(&mut self, _func: &FunctionDecl) {} + /// Visit an inline test module declaration in the AST. fn visit_test_module(&mut self, _test_module: &TestModuleDecl) {} fn visit_statement(&mut self, _stmt: &Spanned) {} fn visit_expr(&mut self, _expr: &Spanned) {} diff --git a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs index db9263b49..1195d7ffd 100644 --- a/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs +++ b/crates/incan_syntax/src/diagnostics/catalog/errors/types.rs @@ -106,11 +106,13 @@ pub fn regular_enum_variant_value_not_allowed(enum_name: &str, variant_name: &st .with_hint("Use `enum Name(str):` or `enum Name(int):` for value enums") } +/// Build the diagnostic for passing the same call argument more than once. pub fn duplicate_call_argument(callee: &str, name: &str, span: Span) -> CompileError { CompileError::type_error(format!("Duplicate argument '{name}' when calling '{callee}'"), span) .with_hint("Pass each fixed parameter at most once") } +/// Build the diagnostic for a keyword argument that the callee does not accept. pub fn unknown_keyword_argument(callee: &str, name: &str, span: Span) -> CompileError { CompileError::type_error( format!("Unexpected keyword argument '{name}' when calling '{callee}'"), @@ -119,6 +121,7 @@ pub fn unknown_keyword_argument(callee: &str, name: &str, span: Span) -> Compile .with_hint("Add a `**kwargs` rest parameter to capture arbitrary keyword arguments") } +/// Build the diagnostic for unpacking call arguments into a callee without a rest parameter. pub fn call_unpack_without_rest(callee: &str, unpack: &str, span: Span) -> CompileError { CompileError::type_error( format!( @@ -128,6 +131,7 @@ pub fn call_unpack_without_rest(callee: &str, unpack: &str, span: Span) -> Compi ) } +/// Build the diagnostic for omitting a required call argument. pub fn missing_required_argument(callee: &str, name: &str, span: Span) -> CompileError { CompileError::type_error( format!("Missing required argument '{name}' when calling '{callee}'"), @@ -144,6 +148,7 @@ pub fn unsafe_top_level_partial_preset(name: &str, span: Span) -> CompileError { .with_hint("Use literals, const paths, or declaration-safe collection/model literals as top-level partial presets") } +/// Build the diagnostic for declaring the same rest parameter twice. pub fn duplicate_rest_parameter(kind: &str, span: Span) -> CompileError { CompileError::type_error( format!("Only one `{kind}` rest parameter is allowed in a callable signature"), @@ -151,10 +156,12 @@ pub fn duplicate_rest_parameter(kind: &str, span: Span) -> CompileError { ) } +/// Build the diagnostic for placing a rest parameter after an invalid parameter kind. pub fn invalid_rest_parameter_order(message: &str, span: Span) -> CompileError { CompileError::type_error(message.to_string(), span) } +/// Build the diagnostic for putting a default value on a rest parameter. pub fn rest_parameter_default_not_allowed(name: &str, span: Span) -> CompileError { CompileError::type_error(format!("Rest parameter '{name}' cannot declare a default value"), span) } @@ -243,6 +250,7 @@ pub fn reserved_root_namespace(name: &str, span: Span) -> CompileError { .with_hint("Choose a different name (reserved: std, rust)") } +/// Build the diagnostic for a `@rust.allow` entry that is not a positional string. pub fn rust_allow_requires_positional_string(span: Span) -> CompileError { CompileError::type_error( "@rust.allow requires one or more positional string literal arguments".to_string(), @@ -251,21 +259,25 @@ pub fn rust_allow_requires_positional_string(span: Span) -> CompileError { .with_hint("Example: @rust.allow(\"dead_code\", \"clippy::too_many_arguments\")") } +/// Build the diagnostic for named arguments passed to `@rust.allow`. pub fn rust_allow_rejects_named_args(name: &str, span: Span) -> CompileError { CompileError::type_error(format!("@rust.allow does not accept named argument '{}'", name), span) .with_hint("Pass lint names as positional string literals") } +/// Build the diagnostic for an invalid Rust lint name in `@rust.allow`. pub fn rust_allow_invalid_lint_name(name: &str, span: Span) -> CompileError { CompileError::type_error(format!("Invalid Rust lint name '{}'", name), span) .with_hint("Use a Rust lint path like \"dead_code\" or \"clippy::too_many_arguments\"") } +/// Build the diagnostic for a duplicate lint in `@rust.allow`. pub fn rust_allow_duplicate_lint(name: &str, span: Span) -> CompileError { CompileError::type_error(format!("Duplicate Rust lint '{}' in @rust.allow", name), span) .with_hint("Each @rust.allow invocation may list a lint only once") } +/// Build the diagnostic for a broad lint group rejected by `@rust.allow`. pub fn rust_allow_broad_lint_group(name: &str, span: Span) -> CompileError { CompileError::type_error( format!("Broad Rust lint group '{}' is not allowed in @rust.allow", name), @@ -274,6 +286,7 @@ pub fn rust_allow_broad_lint_group(name: &str, span: Span) -> CompileError { .with_hint("Suppress only specific rustc or Clippy lints") } +/// Build the diagnostic for attaching `@rust.allow` to an unsupported declaration. pub fn rust_allow_unsupported_attachment(kind: &str, span: Span) -> CompileError { CompileError::type_error(format!("@rust.allow cannot be used on {kind} declarations"), span) .with_hint("@rust.allow is supported on functions, methods, models, classes, enums, and newtypes") @@ -441,6 +454,7 @@ pub fn incompatible_error_type(expected: &str, found: &str, span: Span) -> Compi .with_hint("Use map_err to convert the error type, or add a From implementation") } +/// Build the diagnostic for using `try` in a function that does not return `Result`. pub fn try_without_result_return(span: Span) -> CompileError { CompileError::type_error( "Cannot use '?' here: the enclosing function does not return Result[_, E]".to_string(), diff --git a/crates/incan_syntax/src/parser/expr.rs b/crates/incan_syntax/src/parser/expr.rs index 5bc0ea3c4..125dc8397 100644 --- a/crates/incan_syntax/src/parser/expr.rs +++ b/crates/incan_syntax/src/parser/expr.rs @@ -1525,6 +1525,7 @@ impl<'a> Parser<'a> { Expr::Ident(s.to_string()) } + /// Parse a `match` expression. fn match_expr(&mut self, start: usize) -> Result, CompileError> { let subject = self.expression()?; self.expect( @@ -1559,6 +1560,7 @@ impl<'a> Parser<'a> { )) } + /// Parse one arm of a `match` expression. fn match_arm(&mut self) -> Result, CompileError> { let start = self.current_span().start; @@ -1813,6 +1815,7 @@ impl<'a> Parser<'a> { )) } + /// Parse an `if` expression. fn if_expr(&mut self, start: usize) -> Result, CompileError> { self.expect(&TokenKind::Keyword(KeywordId::If), "Expected 'if'")?; let condition = self.expression()?; @@ -1847,6 +1850,7 @@ impl<'a> Parser<'a> { )) } + /// Parse a `loop` expression. fn loop_expr(&mut self, start: usize) -> Result, CompileError> { self.expect(&TokenKind::Keyword(KeywordId::Loop), "Expected 'loop'")?; self.expect( @@ -2220,6 +2224,7 @@ impl<'a> Parser<'a> { Ok(clauses) } + /// Parse a parenthesized expression or tuple literal. fn paren_or_tuple(&mut self, start: usize) -> Result, CompileError> { // Implicit line continuation: skip newlines after ( self.skip_newlines(); @@ -2358,6 +2363,7 @@ impl<'a> Parser<'a> { result } + /// Parse call arguments. fn call_args(&mut self) -> Result, CompileError> { // Implicit line continuation: skip newlines after ( self.skip_newlines(); diff --git a/crates/incan_syntax/src/parser/stmts.rs b/crates/incan_syntax/src/parser/stmts.rs index 3a2f29467..a2abb104f 100644 --- a/crates/incan_syntax/src/parser/stmts.rs +++ b/crates/incan_syntax/src/parser/stmts.rs @@ -10,6 +10,7 @@ impl<'a> Parser<'a> { // Statements // ======================================================================== + /// Parse a statement block. fn block(&mut self) -> Result>, CompileError> { let mut stmts = Vec::new(); let mut next_leading = self.consume_inter_statement_blank_prefix(); @@ -58,6 +59,7 @@ impl<'a> Parser<'a> { ) } + /// Parse one statement. fn statement(&mut self) -> Result, CompileError> { let start = self.current_span().start; @@ -505,6 +507,7 @@ impl<'a> Parser<'a> { Ok(vec![PatternArg::Positional(Spanned::new(pattern, value.span))]) } + /// Parse a `break` statement. fn break_stmt(&mut self) -> Result { self.expect(&TokenKind::Keyword(KeywordId::Break), "Expected 'break'")?; let value = if !self.check(&TokenKind::Newline) @@ -634,6 +637,7 @@ impl<'a> Parser<'a> { Ok(Statement::While(WhileStmt { condition, body })) } + /// Parse a `loop` statement. fn loop_stmt(&mut self) -> Result { self.expect(&TokenKind::Keyword(KeywordId::Loop), "Expected 'loop'")?; self.expect( @@ -648,6 +652,7 @@ impl<'a> Parser<'a> { Ok(Statement::Loop(LoopStmt { body })) } + /// Parse a `for` statement. fn for_stmt(&mut self) -> Result { self.expect(&TokenKind::Keyword(KeywordId::For), "Expected 'for'")?; let pattern = self.for_binding_pattern()?; diff --git a/crates/rust_inspect/src/extractor.rs b/crates/rust_inspect/src/extractor.rs index f3b0aeb02..de016ff09 100644 --- a/crates/rust_inspect/src/extractor.rs +++ b/crates/rust_inspect/src/extractor.rs @@ -117,10 +117,12 @@ fn canonical_adt_path(adt: Adt, db: &RootDatabase) -> Option { canonical_module_def_path(ModuleDef::Adt(adt), db) } +/// Normalize source type text from Rust inspection display output. fn normalize_source_type_text(text: &str) -> String { strip_rust_borrow_lifetimes(text).trim().replace(' ', "") } +/// Return the source spelling for a borrowed builtin Rust type. fn borrowed_builtin_source_display(text: &str) -> Option { let normalized = normalize_source_type_text(text); let (prefix, inner) = if let Some(inner) = normalized.strip_prefix("&mut") { @@ -144,6 +146,7 @@ fn borrowed_builtin_source_display(text: &str) -> Option { } } +/// Return whether a Rust display type is an exact numeric primitive. fn is_exact_numeric_display(text: &str) -> bool { matches!( text, @@ -230,6 +233,7 @@ fn resolve_source_path(text: &str, crate_name: &str, module: Module, db: &RootDa None } +/// Classify the source-level shape represented by a Rust display type. fn source_type_shape(text: &str, crate_name: &str, module: Module, db: &RootDatabase) -> RustTypeShape { let text = normalize_source_type_text(text); if text.is_empty() { @@ -419,6 +423,7 @@ fn rust_type_shape(ty: &Type<'_>, db: &RootDatabase, dt: DisplayTarget) -> RustT RustTypeShape::Unknown } +/// Render a Rust signature type in source-oriented form. fn function_sig_type_display(ty: &Type<'_>, db: &RootDatabase, dt: DisplayTarget) -> String { let raw = normalize_display_path(format_ty(ty, db, dt).as_str()); if let Some(display) = exact_numeric_boundary_display(raw.as_str()) { @@ -615,6 +620,7 @@ fn source_function_param_type_display(f: Function, param: &ra_ap_hir::Param<'_>, Some(rendered) } +/// Extract a Rust function signature from inspection metadata. fn extract_function_sig(f: Function, db: &RootDatabase, dt: DisplayTarget) -> RustFunctionSig { let params = f .assoc_fn_params(db) diff --git a/crates/rust_inspect/src/loader.rs b/crates/rust_inspect/src/loader.rs index 1703d48a9..bc773395f 100644 --- a/crates/rust_inspect/src/loader.rs +++ b/crates/rust_inspect/src/loader.rs @@ -48,6 +48,7 @@ impl RustWorkspace { index } + /// Build cargo configuration for the Rust metadata workspace. fn metadata_cargo_config() -> CargoConfig { CargoConfig::default() } diff --git a/scripts/check_changed_rustdocs.py b/scripts/check_changed_rustdocs.py index 55ea0afc0..6ea9bae7f 100644 --- a/scripts/check_changed_rustdocs.py +++ b/scripts/check_changed_rustdocs.py @@ -58,6 +58,7 @@ def changed_rust_files_from_diff_args(args: list[str]) -> dict[Path, set[int]]: or rel.endswith("/tests.rs") or "/examples/" in rel or rel.startswith("examples/") + or rel.startswith("crates/third_party/") ): current_path = None continue @@ -72,7 +73,7 @@ def changed_rust_files_from_diff_args(args: list[str]) -> dict[Path, set[int]]: count = int(match.group("count") or "1") if count == 0: continue - files[current_path].update(range(start, start + count)) + files[current_path].update(range(start, start + count)) return files @@ -173,6 +174,32 @@ def quote_macro_lines(lines: list[str]) -> set[int]: return quoted +def trait_impl_lines(lines: list[str]) -> set[int]: + """Return line numbers inside explicit trait implementation blocks.""" + trait_impls: set[int] = set() + brace_depth = 0 + active_impl_depth: int | None = None + + for index, line in enumerate(lines, start=1): + stripped = line.strip() + open_braces = line.count("{") + close_braces = line.count("}") + + if active_impl_depth is None and stripped.startswith("impl ") and " for " in stripped and "{" in stripped: + active_impl_depth = brace_depth + open_braces + + if active_impl_depth is not None: + trait_impls.add(index) + + brace_depth += open_braces + brace_depth -= close_braces + + if active_impl_depth is not None and brace_depth < active_impl_depth: + active_impl_depth = None + + return trait_impls + + def function_end_line(lines: list[str], fn_index: int) -> int: """Return the best-effort inclusive end line for a function starting at `fn_index`.""" depth = 0 @@ -195,6 +222,7 @@ def missing_docs(path: Path, changed_lines: set[int]) -> list[tuple[int, str]]: lines = path.read_text().splitlines() test_lines = test_module_lines(lines) quoted_lines = quote_macro_lines(lines) + trait_impls = trait_impl_lines(lines) misses: list[tuple[int, str]] = [] for index, line in enumerate(lines): match = FN_RE.match(line) @@ -205,6 +233,8 @@ def missing_docs(path: Path, changed_lines: set[int]) -> list[tuple[int, str]]: continue if line_no in quoted_lines: continue + if line_no in trait_impls: + continue end_line = function_end_line(lines, index) if not any(line_no <= changed <= end_line for changed in changed_lines): continue diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 1897053a7..914b2ee88 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -253,6 +253,7 @@ impl<'a> IrCodegen<'a> { registry } + /// Apply dependency symbol metadata to generated Rust codegen state. fn apply_dependency_symbol_metadata(emitter: &mut IrEmitter<'_>, metadata: &DependencySymbolMetadata) { emitter.set_type_module_paths(metadata.module_paths.clone(), metadata.ambiguous_type_names.clone()); emitter.set_value_module_paths( diff --git a/src/backend/ir/codegen/dependency_metadata.rs b/src/backend/ir/codegen/dependency_metadata.rs index f0519d146..0d912a9c0 100644 --- a/src/backend/ir/codegen/dependency_metadata.rs +++ b/src/backend/ir/codegen/dependency_metadata.rs @@ -9,6 +9,7 @@ use crate::frontend::typechecker::stdlib_loader::StdlibAstCache; use incan_core::lang::stdlib; use incan_core::lang::traits::{self as core_traits, TraitId}; +/// Collect field-alias metadata for exported models. pub(super) fn collect_model_field_aliases( main: &Program, deps: &[(&str, &Program)], @@ -109,6 +110,7 @@ pub(super) fn collect_externally_reachable_items_by_module( .map(|(name, _, path_segments)| path_segments.clone().unwrap_or_else(|| vec![(*name).to_string()])) .collect(); + /// Record dependency imports from checked module metadata. fn record_imports( reachable: &mut HashMap, HashSet>, program: &Program, diff --git a/src/backend/ir/codegen/serde_activation.rs b/src/backend/ir/codegen/serde_activation.rs index 04ae5c47f..acaae7dbb 100644 --- a/src/backend/ir/codegen/serde_activation.rs +++ b/src/backend/ir/codegen/serde_activation.rs @@ -93,6 +93,7 @@ pub(super) fn add_serde_to_newtypes( use crate::backend::ir::decl::IrDeclKind; use crate::backend::ir::types::IrType; + /// Return whether a newtype inner type is conservatively safe for serde derives. fn is_conservative_serde_safe_newtype_inner(ty: &IrType) -> bool { match ty { IrType::Unit diff --git a/src/backend/ir/emit/consts.rs b/src/backend/ir/emit/consts.rs index 5710b7fb3..325e5a7e9 100644 --- a/src/backend/ir/emit/consts.rs +++ b/src/backend/ir/emit/consts.rs @@ -56,6 +56,7 @@ impl<'a> IrEmitter<'a> { self.const_type_emittable_inner(ty, &mut seen_structs) } + /// Return whether a constant type can be emitted in generated Rust. fn const_type_emittable_inner(&self, ty: &IrType, seen_structs: &mut std::collections::HashSet) -> bool { match ty { IrType::Int @@ -92,6 +93,7 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether a struct constant can be emitted in generated Rust. fn const_struct_type_emittable(&self, name: &str, seen_structs: &mut std::collections::HashSet) -> bool { if !seen_structs.insert(name.to_string()) { return false; diff --git a/src/backend/ir/emit/decls/impls.rs b/src/backend/ir/emit/decls/impls.rs index be8ad9970..07d2c3d40 100644 --- a/src/backend/ir/emit/decls/impls.rs +++ b/src/backend/ir/emit/decls/impls.rs @@ -312,6 +312,7 @@ impl<'a> IrEmitter<'a> { })) } + /// Build reflection metadata entries for model fields. pub(in crate::backend::ir::emit) fn reflection_field_info_entries( &self, struct_name: &str, diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index e499cc056..99a7c119f 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -812,6 +812,7 @@ impl<'a> IrEmitter<'a> { } } + /// Emit call arguments while preserving rest-argument expansion semantics. pub(in super::super) fn emit_rest_aware_call_args( &self, func: &TypedExpr, @@ -891,6 +892,7 @@ impl<'a> IrEmitter<'a> { Ok(out) } + /// Emit one positional argument that may include rest expansion. fn emit_rest_positional_arg(&self, args: &[&IrCallArg], element_ty: &IrType) -> Result { let mut statements = Vec::with_capacity(args.len()); for arg in args { diff --git a/src/backend/ir/emit/expressions/calls/testing_asserts.rs b/src/backend/ir/emit/expressions/calls/testing_asserts.rs index 56ea8540e..a86c2e3d9 100644 --- a/src/backend/ir/emit/expressions/calls/testing_asserts.rs +++ b/src/backend/ir/emit/expressions/calls/testing_asserts.rs @@ -68,6 +68,7 @@ impl<'a> IrEmitter<'a> { } } + /// Evaluate an IR expression as a constant boolean when possible. fn constant_bool(expr: &TypedExpr) -> Option { match &expr.kind { IrExprKind::Bool(value) => Some(*value), @@ -76,6 +77,7 @@ impl<'a> IrEmitter<'a> { } } + /// Normalize an assert argument for generated failure messages. fn canonical_assert_arg( helper_id: TestingAssertHelperId, args: &[IrCallArg], @@ -90,6 +92,7 @@ impl<'a> IrEmitter<'a> { }) } + /// Build the generated failure message for an assertion. fn assert_failure_message(helper_id: TestingAssertHelperId) -> Result<&'static str, EmitError> { testing::assert_helper_default_failure_message(helper_id).ok_or_else(|| { EmitError::Unsupported(format!( @@ -99,6 +102,7 @@ impl<'a> IrEmitter<'a> { }) } + /// Extract the payload expression from a `Result` constructor call. fn result_constructor_payload(expr: &TypedExpr, constructor: ConstructorId) -> Option<&TypedExpr> { let expr = match &expr.kind { IrExprKind::InteropCoerce { expr, .. } => expr.as_ref(), @@ -121,6 +125,7 @@ impl<'a> IrEmitter<'a> { args.first().map(|arg| &arg.expr) } + /// Emit a generated assertion failure. fn emit_assert_failure( &self, default_message: &'static str, @@ -140,6 +145,7 @@ impl<'a> IrEmitter<'a> { Ok(quote! { panic!(#default_message); }) } + /// Emit a generated `assert_raises` failure. fn emit_assert_raises_failure( &self, default_message: TokenStream, @@ -159,6 +165,7 @@ impl<'a> IrEmitter<'a> { Ok(default_message) } + /// Emit a generated comparison assertion failure. fn emit_assert_comparison_failure( &self, failure_kind: &'static str, @@ -211,6 +218,7 @@ impl<'a> IrEmitter<'a> { } } + /// Emit an assertion that an option is `Some`. fn emit_assert_option_some(&self, args: &[IrCallArg]) -> Result { let option = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsSome, args, 0)?; let option_tokens = self.emit_expr(option)?; @@ -229,6 +237,7 @@ impl<'a> IrEmitter<'a> { }}) } + /// Emit an assertion that an option is `None`. fn emit_assert_option_none(&self, args: &[IrCallArg]) -> Result { let option = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsNone, args, 0)?; if matches!(option.kind, IrExprKind::None) { @@ -247,6 +256,7 @@ impl<'a> IrEmitter<'a> { }}) } + /// Emit an assertion that a result is `Ok`. fn emit_assert_result_ok(&self, args: &[IrCallArg]) -> Result { let result = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsOk, args, 0)?; if let Some(payload) = Self::result_constructor_payload(result, ConstructorId::Ok) { @@ -269,6 +279,7 @@ impl<'a> IrEmitter<'a> { }}) } + /// Emit an assertion that a result is `Err`. fn emit_assert_result_err(&self, args: &[IrCallArg]) -> Result { let result = Self::canonical_assert_arg(TestingAssertHelperId::AssertIsErr, args, 0)?; if let Some(payload) = Self::result_constructor_payload(result, ConstructorId::Err) { @@ -291,6 +302,7 @@ impl<'a> IrEmitter<'a> { }}) } + /// Emit an `assert_raises` call. fn emit_assert_raises(&self, args: &[IrCallArg]) -> Result { let call = Self::canonical_assert_arg(TestingAssertHelperId::AssertRaises, args, 0)?; let expected = Self::canonical_assert_arg(TestingAssertHelperId::AssertRaises, args, 1)?; diff --git a/src/backend/ir/emit/expressions/comprehensions.rs b/src/backend/ir/emit/expressions/comprehensions.rs index fe143d333..571e377b3 100644 --- a/src/backend/ir/emit/expressions/comprehensions.rs +++ b/src/backend/ir/emit/expressions/comprehensions.rs @@ -729,16 +729,19 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether a call argument contains a `try` expression. fn call_arg_contains_try(arg: &IrCallArg) -> bool { Self::expr_contains_try(&arg.expr) } + /// Return whether a list entry contains a `try` expression. fn list_entry_contains_try(entry: &IrListEntry) -> bool { match entry { IrListEntry::Element(expr) | IrListEntry::Spread(expr) => Self::expr_contains_try(expr), } } + /// Return whether a dict entry contains a `try` expression. fn dict_entry_contains_try(entry: &IrDictEntry) -> bool { match entry { IrDictEntry::Pair(key, value) => Self::expr_contains_try(key) || Self::expr_contains_try(value), @@ -746,6 +749,7 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether a generator clause contains a `try` expression. fn generator_clause_contains_try(clause: &IrGeneratorClause) -> bool { match clause { IrGeneratorClause::For { iterable, .. } => Self::expr_contains_try(iterable), @@ -753,6 +757,7 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether a statement contains a `try` expression. fn stmt_contains_try(stmt: &IrStmt) -> bool { match &stmt.kind { IrStmtKind::Expr(expr) | IrStmtKind::Let { value: expr, .. } | IrStmtKind::Yield(expr) => { @@ -795,6 +800,7 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether an assignment target contains a `try` expression. fn assign_target_contains_try(target: &AssignTarget) -> bool { match target { AssignTarget::Field { object, .. } => Self::expr_contains_try(object), diff --git a/src/backend/ir/emit/expressions/indexing.rs b/src/backend/ir/emit/expressions/indexing.rs index d7725d804..0797848b1 100644 --- a/src/backend/ir/emit/expressions/indexing.rs +++ b/src/backend/ir/emit/expressions/indexing.rs @@ -85,11 +85,13 @@ impl<'a> IrEmitter<'a> { }}) } + /// Emit a callable-name expression for a generic callable. fn emit_generic_callable_name_expr(&self, object: &TypedExpr) -> Result { let object = self.emit_expr(object)?; Ok(quote! { __IncanCallableName::__incan_callable_name(&#object) }) } + /// Emit the path to a callable-name helper function. pub(in crate::backend::ir::emit) fn emit_callable_name_helper_path( &self, module_path: &[String], diff --git a/src/backend/ir/emit/expressions/interop_coercions.rs b/src/backend/ir/emit/expressions/interop_coercions.rs index e89e3c5f9..7f1d23473 100644 --- a/src/backend/ir/emit/expressions/interop_coercions.rs +++ b/src/backend/ir/emit/expressions/interop_coercions.rs @@ -146,6 +146,7 @@ fn emit_structural_borrow_projection(source_tokens: TokenStream, target_ty: &IrT } } +/// Emit a structural borrow coercion at a Rust call boundary. fn emit_structural_borrow_coercion(inner_tokens: TokenStream, target_ty: &IrType) -> Option { match target_ty { IrType::List(_) | IrType::Set(_) | IrType::Dict(_, _) | IrType::Option(_) | IrType::Result(_, _) => { diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index 0156e9320..f96fcf16b 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -35,6 +35,7 @@ use fast_paths::emit_registered_method_fast_path; use iterator_methods::emit_iterator_method; use string_methods::emit_string_method; +/// Return the trait path used for type-level reflection. fn type_reflection_trait_path(method: &str) -> Option<&'static str> { match magic_methods::from_str(method) { Some(magic_methods::MagicMethodId::ClassName) => Some(tb::INCAN_TYPE_CLASS_NAME), @@ -69,6 +70,7 @@ impl ReceiverInfo { } } +/// Classify an IR type as a Rust collection family. fn rust_collection_family_for_ir_type(ty: &IrType) -> Option { match ty { IrType::Struct(name) | IrType::NamedGeneric(name, _) => { @@ -455,6 +457,7 @@ impl<'a> IrEmitter<'a> { }) } + /// Return whether a receiver type matches without relying on metadata. fn metadata_free_receiver_matches(receiver: &TypedExpr, class: MetadataFreeReceiverClass) -> bool { match class { MetadataFreeReceiverClass::IoValue => Self::receiver_allows_io_method_fallback(receiver), @@ -465,6 +468,7 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether an argument type matches without relying on metadata. fn metadata_free_arg_matches(arg_ty: &IrType, class: MetadataFreeArgClass) -> bool { match class { MetadataFreeArgClass::StringBuffer => Self::is_string_buffer_type(arg_ty), diff --git a/src/backend/ir/emit/expressions/methods/collection_methods.rs b/src/backend/ir/emit/expressions/methods/collection_methods.rs index 96134805e..284214d8e 100644 --- a/src/backend/ir/emit/expressions/methods/collection_methods.rs +++ b/src/backend/ir/emit/expressions/methods/collection_methods.rs @@ -16,6 +16,7 @@ pub(super) fn emit_dict_lookup_key(receiver: &TypedExpr, arg: &TypedExpr, emitte plan_dict_lookup_key(&receiver.ty, &arg.ty).apply(emitted) } +/// Return the element type for a collection IR type. fn collection_element_type(ty: &IrType) -> Option<&IrType> { match ty { IrType::List(elem) | IrType::Set(elem) => Some(elem.as_ref()), @@ -24,6 +25,7 @@ fn collection_element_type(ty: &IrType) -> Option<&IrType> { } } +/// Return whether a type stores owned string values. fn is_string_storage_type(ty: &IrType) -> bool { matches!( ty, diff --git a/src/backend/ir/emit/expressions/methods/fast_paths.rs b/src/backend/ir/emit/expressions/methods/fast_paths.rs index c2ffa2228..46fbf7e1a 100644 --- a/src/backend/ir/emit/expressions/methods/fast_paths.rs +++ b/src/backend/ir/emit/expressions/methods/fast_paths.rs @@ -46,6 +46,7 @@ pub(super) fn emit_registered_method_fast_path( Ok(None) } +/// Return whether a receiver can use a method fast path. fn receiver_matches_fast_path(emitter: &IrEmitter, receiver_ty: &IrType, fast_path: &MethodFastPath) -> bool { let Some((name, args)) = named_generic_receiver(receiver_ty) else { return false; @@ -57,6 +58,7 @@ fn receiver_matches_fast_path(emitter: &IrEmitter, receiver_ty: &IrType, fast_pa && type_module_matches(emitter, name, fast_path) } +/// Return the named generic receiver, if present. fn named_generic_receiver(ty: &IrType) -> Option<(&str, &[IrType])> { match peel_refs(ty) { IrType::NamedGeneric(name, args) => Some((name.as_str(), args.as_slice())), @@ -64,6 +66,7 @@ fn named_generic_receiver(ty: &IrType) -> Option<(&str, &[IrType])> { } } +/// Remove transparent reference wrappers from an IR type. fn peel_refs(ty: &IrType) -> &IrType { let mut ty = ty; while let IrType::Ref(inner) | IrType::RefMut(inner) = ty { @@ -72,10 +75,12 @@ fn peel_refs(ty: &IrType) -> &IrType { ty } +/// Return whether a type name matches an expected Rust path. fn type_name_matches(actual: &str, expected: &str) -> bool { actual == expected || actual.rsplit("::").next() == Some(expected) } +/// Return whether a concrete type argument matches an expected Rust path. fn concrete_type_arg_matches(actual: &IrType, expected: &str) -> bool { match expected { "str" => matches!( @@ -90,6 +95,7 @@ fn concrete_type_arg_matches(actual: &IrType, expected: &str) -> bool { } } +/// Return whether a type module path matches an expected Rust module. fn type_module_matches(emitter: &IrEmitter, type_name: &str, fast_path: &MethodFastPath) -> bool { let short_name = type_name.rsplit("::").next().unwrap_or(type_name); type_path_matches(type_name, fast_path.source_module, fast_path.receiver_type) @@ -101,15 +107,18 @@ fn type_module_matches(emitter: &IrEmitter, type_name: &str, fast_path: &MethodF }) } +/// Return whether a type path matches an expected Rust path. fn type_path_matches(type_name: &str, module: &str, receiver_type: &str) -> bool { let module_path = module.replace('.', "::"); type_name == format!("{module_path}::{receiver_type}") } +/// Return whether a module path matches an expected Rust module. fn module_matches(actual: &[String], expected: &str) -> bool { actual.iter().map(String::as_str).eq(expected.split('.')) } +/// Emit one argument for a method fast path. fn emit_fast_path_arg( emitter: &IrEmitter, shape: MethodFastPathArgShape, @@ -124,6 +133,7 @@ fn emit_fast_path_arg( } } +/// Emit an argument borrowed as `str` for a method fast path. fn emit_borrowed_str_arg(emitter: &IrEmitter, arg: &TypedExpr) -> Result { if let IrExprKind::Index { object, index } = &arg.kind && list_element_type(&object.ty).is_some_and(is_owned_string_type) @@ -138,6 +148,7 @@ fn emit_borrowed_str_arg(emitter: &IrEmitter, arg: &TypedExpr) -> Result Option<&IrType> { match peel_refs(ty) { IrType::List(elem) => Some(elem.as_ref()), @@ -145,10 +156,12 @@ fn list_element_type(ty: &IrType) -> Option<&IrType> { } } +/// Return whether an IR type is owned string storage. fn is_owned_string_type(ty: &IrType) -> bool { matches!(peel_refs(ty), IrType::String) } +/// Emit tokens that borrow an expression as `str`. fn borrowed_str_tokens(ty: &IrType, emitted: TokenStream) -> TokenStream { match ty { IrType::StaticStr | IrType::StrRef => emitted, @@ -162,6 +175,7 @@ fn borrowed_str_tokens(ty: &IrType, emitted: TokenStream) -> TokenStream { } } +/// Emit an expression borrowed for a Rust call boundary. fn borrow_expr_for_call(ty: &IrType, emitted: TokenStream) -> TokenStream { match ty { IrType::Ref(_) | IrType::RefMut(_) => emitted, diff --git a/src/backend/ir/emit/expressions/mod.rs b/src/backend/ir/emit/expressions/mod.rs index 9c42692b3..b449d48f1 100644 --- a/src/backend/ir/emit/expressions/mod.rs +++ b/src/backend/ir/emit/expressions/mod.rs @@ -122,6 +122,7 @@ impl<'a> IrEmitter<'a> { }}) } + /// Emit a cached wrapper for a generic decorated function. fn emit_cache_generic_decorated_function( &self, cache_name: &str, @@ -554,6 +555,7 @@ impl<'a> IrEmitter<'a> { } } + /// Emit the scrutinee expression for a match statement. pub(super) fn emit_match_scrutinee(&self, scrutinee: &TypedExpr) -> Result { if matches!(scrutinee.ty, IrType::Unknown) || Self::type_is_result_like(&scrutinee.ty) { return self.emit_expr(scrutinee); @@ -653,6 +655,7 @@ impl<'a> IrEmitter<'a> { rewritten } + /// Emit storage access while preserving a shared reference. pub(super) fn emit_storage_with_ref(&self, expr: &TypedExpr, body: TokenStream) -> Result { let local_name = format_ident!("__incan_static_value"); match Self::expr_storage_root(expr) { @@ -672,6 +675,7 @@ impl<'a> IrEmitter<'a> { } } + /// Emit storage access while preserving a mutable reference. pub(super) fn emit_storage_with_mut(&self, expr: &TypedExpr, body: TokenStream) -> Result { let local_name = format_ident!("__incan_static_value"); match Self::expr_storage_root(expr) { diff --git a/src/backend/ir/emit/mod.rs b/src/backend/ir/emit/mod.rs index 034999a45..76942f7ee 100644 --- a/src/backend/ir/emit/mod.rs +++ b/src/backend/ir/emit/mod.rs @@ -457,6 +457,7 @@ impl<'a> IrEmitter<'a> { self.canonical_function_registry = Some(registry); } + /// Return the canonical function registry used for callable-name lookups. pub(super) fn canonical_function_registry(&self) -> &FunctionRegistry { self.canonical_function_registry .as_ref() @@ -547,6 +548,7 @@ impl<'a> IrEmitter<'a> { Some(format!("fn({params}) -> {}", ret.rust_name())) } + /// Build a callable-name signature key from a function signature. fn callable_name_signature_key_from_signature(signature: &FunctionSignature) -> Option { let params = signature .params @@ -556,6 +558,7 @@ impl<'a> IrEmitter<'a> { Self::callable_name_signature_key(¶ms, &signature.return_type) } + /// Return whether a type can participate in callable-name helper signatures. fn callable_name_type_supported(ty: &IrType) -> bool { match ty { IrType::Unknown | IrType::Generic(_) | IrType::ImplTrait(_) | IrType::SelfType => false, @@ -590,6 +593,7 @@ impl<'a> IrEmitter<'a> { } } + /// Hash a callable-name signature key with a stable FNV-1a variant. fn stable_callable_name_hash(bytes: &[u8]) -> u64 { let mut hash = 0xcbf29ce484222325u64; for byte in bytes { @@ -599,6 +603,7 @@ impl<'a> IrEmitter<'a> { hash } + /// Return callable-name signature keys defined by the current module. pub(super) fn local_callable_name_signature_keys(&self) -> HashSet { self.callable_name_local_registry() .iter() @@ -606,6 +611,7 @@ impl<'a> IrEmitter<'a> { .collect() } + /// Return the local function registry used for callable-name helpers. pub(super) fn callable_name_local_registry(&self) -> &FunctionRegistry { self.callable_name_local_registry .as_ref() @@ -678,6 +684,7 @@ impl<'a> IrEmitter<'a> { } } + /// Emit the generated call that initializes local and imported module statics. pub(super) fn emit_module_static_init_call(&self) -> TokenStream { if *self.module_has_local_statics.borrow() || !self.imported_static_module_init_bindings.borrow().is_empty() { let init_fn = Self::rust_ident("__incan_init_module_statics"); @@ -687,14 +694,17 @@ impl<'a> IrEmitter<'a> { } } + /// Replace the imported static bindings that need per-static init calls. pub(super) fn set_imported_static_init_bindings(&self, bindings: HashSet) { *self.imported_static_init_bindings.borrow_mut() = bindings; } + /// Replace imported static modules that need module-level init calls. pub(super) fn set_imported_static_module_init_bindings(&self, bindings: Vec) { *self.imported_static_module_init_bindings.borrow_mut() = bindings; } + /// Build the generated Rust identifier for an imported static init shim. pub(super) fn imported_static_init_ident(name: &str) -> proc_macro2::Ident { let mut rendered = String::from("__incan_init_imported_static_"); for ch in name.chars() { @@ -707,10 +717,12 @@ impl<'a> IrEmitter<'a> { proc_macro2::Ident::new(&rendered, proc_macro2::Span::call_site()) } + /// Return whether a static binding needs its imported init shim called. pub(super) fn static_needs_imported_init_call(&self, name: &str) -> bool { self.imported_static_init_bindings.borrow().contains(name) } + /// Return whether a static binding needs any imported static init support. pub(super) fn static_needs_imported_init_import(&self, name: &str) -> bool { self.static_needs_imported_init_call(name) || self @@ -720,6 +732,7 @@ impl<'a> IrEmitter<'a> { .any(|binding| binding == name) } + /// Emit the generated init call required before touching a static binding. pub(super) fn emit_static_init_call_for_static(&self, name: &str) -> TokenStream { if self.static_needs_imported_init_call(name) { let init_fn = Self::imported_static_init_ident(name); @@ -980,6 +993,7 @@ impl<'a> IrEmitter<'a> { self.ambiguous_value_names = ambiguous; } + /// Emit a qualified path for an item imported from dependency metadata. pub(in crate::backend::ir::emit) fn emit_dependency_item_path( &self, module_path: &[String], @@ -998,6 +1012,7 @@ impl<'a> IrEmitter<'a> { Some(iter.fold(first, |acc, segment| quote! { #acc :: #segment })) } + /// Emit a dependency-qualified type path when a local type name is ambiguous. pub(in crate::backend::ir::emit) fn emit_dependency_type_path(&self, name: &str) -> Option { if name.contains("::") || self.ambiguous_type_names.contains(name) { return None; @@ -1006,6 +1021,7 @@ impl<'a> IrEmitter<'a> { self.emit_dependency_item_path(module_path, name) } + /// Emit a dependency-qualified value path when a local value name is ambiguous. pub(in crate::backend::ir::emit) fn emit_dependency_value_path(&self, name: &str) -> Option { if name.contains("::") || self.ambiguous_value_names.contains(name) { return None; diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index 24271f6f7..8f4ad11b8 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -811,6 +811,7 @@ impl<'program> GeneratedUseAnalyzer<'program> { } } + /// Collect callable-name signature keys required by function arguments. fn callable_name_function_arg_signature_keys(&self, expr: &TypedExpr) -> Vec { match &expr.kind { IrExprKind::Var { name, .. } => { @@ -1163,6 +1164,7 @@ impl<'program> GeneratedUseAnalyzer<'program> { } impl<'a> IrEmitter<'a> { + /// Collect imported static bindings that need generated init calls. fn collect_imported_static_init_bindings(&self, declarations: &[&IrDecl]) -> (HashSet, Vec) { let mut access_bindings = HashSet::new(); let mut module_init_bindings = HashSet::new(); @@ -2324,6 +2326,7 @@ impl<'a> IrEmitter<'a> { Ok(format!("{}{}", header, with_marker)) } + /// Collect callable-name use facts for a whole IR program. pub(crate) fn callable_name_use_facts_for_program( program: &IrProgram, externally_reachable_items: &HashSet, @@ -2343,6 +2346,7 @@ impl<'a> IrEmitter<'a> { } } + /// Return the callable-name signature metadata for a helper key. fn callable_name_signature_for_key(&self, key: &str) -> Option<(Vec, IrType)> { self.callable_name_local_registry() .iter() @@ -2362,6 +2366,7 @@ impl<'a> IrEmitter<'a> { }) } + /// Return helper keys needed for callable-name resolution. fn callable_name_helper_keys( &self, local_callable_name_signature_keys: &HashSet, @@ -2389,6 +2394,7 @@ impl<'a> IrEmitter<'a> { keys } + /// Build a callable-name resolution expression with a source-name fallback. fn callable_name_resolution_expr_with_fallback( &self, key: &str, @@ -2423,6 +2429,7 @@ impl<'a> IrEmitter<'a> { resolved } + /// Emit the trait used for generic callable-name reflection. fn emit_generic_callable_name_trait(&self, keys: &[String]) -> Option { if keys.is_empty() { return None; @@ -2479,6 +2486,7 @@ impl<'a> IrEmitter<'a> { }) } + /// Emit generated callable-name helper functions. fn emit_callable_name_helpers( &self, emitted_callable_names: &HashSet, diff --git a/src/backend/ir/emit/types.rs b/src/backend/ir/emit/types.rs index 66f9edb63..9abd6b6fc 100644 --- a/src/backend/ir/emit/types.rs +++ b/src/backend/ir/emit/types.rs @@ -181,6 +181,7 @@ impl<'a> IrEmitter<'a> { } } + /// Emit the Rust function type for a callable value. pub(in crate::backend::ir::emit) fn emit_callable_fn_type(&self, params: &[IrType], ret: &IrType) -> TokenStream { let previous = self.qualify_internal_canonical_paths.replace(true); let param_tokens = params.iter().map(|param| self.emit_type(param)).collect::>(); @@ -391,6 +392,7 @@ impl<'a> IrEmitter<'a> { } } + /// Collect string literal patterns from a match pattern tree. fn collect_string_literal_patterns<'p>(pattern: &'p Pattern, values: &mut Vec<&'p str>) -> bool { match pattern { Pattern::Literal(lit) => { diff --git a/src/backend/ir/lower/decl/functions.rs b/src/backend/ir/lower/decl/functions.rs index 81ebcd579..77b368124 100644 --- a/src/backend/ir/lower/decl/functions.rs +++ b/src/backend/ir/lower/decl/functions.rs @@ -33,6 +33,7 @@ fn body_contains_yield(body: &[ast::Spanned]) -> bool { }) } +/// Collect generic callable-name type parameters referenced by an expression. fn collect_generic_callable_name_type_params_from_expr(expr: &super::super::super::IrExpr, out: &mut Vec) { match &expr.kind { IrExprKind::Field { object, field } => { @@ -239,6 +240,7 @@ fn collect_generic_callable_name_type_params_from_expr(expr: &super::super::supe } } +/// Collect generic callable-name type parameters referenced by statements. fn collect_generic_callable_name_type_params_from_stmts(stmts: &[IrStmt], out: &mut Vec) { for stmt in stmts { match &stmt.kind { @@ -292,6 +294,7 @@ fn collect_generic_callable_name_type_params_from_stmts(stmts: &[IrStmt], out: & } } +/// Collect generic callable-name type parameters referenced by an assignment target. fn collect_generic_callable_name_type_params_from_assign_target(target: &AssignTarget, out: &mut Vec) { match target { AssignTarget::Field { object, .. } => collect_generic_callable_name_type_params_from_expr(object, out), diff --git a/src/backend/ir/lower/decl/methods.rs b/src/backend/ir/lower/decl/methods.rs index f65960751..8b9e784a0 100644 --- a/src/backend/ir/lower/decl/methods.rs +++ b/src/backend/ir/lower/decl/methods.rs @@ -6,7 +6,7 @@ use super::super::super::decl::{FunctionParam, IrAssociatedType, IrDecl, IrDeclK use super::super::super::expr::{IrCallArg, IrCallArgKind, IrExprKind, VarAccess, VarRefKind}; use super::super::super::stmt::{IrStmt, IrStmtKind}; use super::super::super::types::IrType; -use super::super::super::{IrSpan, Mutability, TypedExpr}; +use super::super::super::{FunctionSignature, IrSpan, Mutability, TypedExpr}; use super::super::AstLowering; use super::super::TraitImplLoweringInput; use super::super::errors::LoweringError; @@ -318,10 +318,7 @@ impl AstLowering { continue; }; let static_name = Self::decorator_method_static_binding_name(type_name, &method.node.name); - let decorated_ty = IrType::Function { - params: params.iter().map(|param| self.lower_resolved_type(¶m.ty)).collect(), - ret: Box::new(self.lower_resolved_type(&ret)), - }; + let decorated_ty = self.function_type_from_callable_surface(¶ms, &ret); let application = self.decorator_method_application_expr(type_name, &method.node)?; let mut value = self.lower_expr_spanned(&application)?; value.ty = decorated_ty.clone(); @@ -418,10 +415,11 @@ impl AstLowering { })); let return_type = self.lower_resolved_type(&ret); let static_name = Self::decorator_method_static_binding_name(owner, &method.name); + let callable_signature = self.function_signature_from_callable_surface(¶ms, &ret); let static_func = TypedExpr::new( IrExprKind::StaticRead { name: static_name }, IrType::Function { - params: params.iter().map(|param| self.lower_resolved_type(¶m.ty)).collect(), + params: callable_signature.params.iter().map(|param| param.ty.clone()).collect(), ret: Box::new(return_type.clone()), }, ); @@ -438,24 +436,13 @@ impl AstLowering { receiver_ty, ), }); - args.extend(wrapper_params.iter().skip(1).map(|param| IrCallArg { - name: None, - kind: IrCallArgKind::Positional, - expr: TypedExpr::new( - IrExprKind::Var { - name: param.name.clone(), - access: VarAccess::Read, - ref_kind: VarRefKind::Value, - }, - param.ty.clone(), - ), - })); + args.extend(Self::forwarding_args_from_params(&wrapper_params[1..])); let call = TypedExpr::new( IrExprKind::Call { func: Box::new(static_func), type_args: Vec::new(), args, - callable_signature: None, + callable_signature: Some(callable_signature), canonical_path: None, }, return_type.clone(), @@ -525,22 +512,7 @@ impl AstLowering { }, receiver_ty, ); - let args = adapter_params - .iter() - .skip(1) - .map(|param| IrCallArg { - name: None, - kind: IrCallArgKind::Positional, - expr: TypedExpr::new( - IrExprKind::Var { - name: param.name.clone(), - access: VarAccess::Read, - ref_kind: VarRefKind::Value, - }, - param.ty.clone(), - ), - }) - .collect(); + let args = Self::forwarding_args_from_params(&adapter_params[1..]); let call = TypedExpr::new( IrExprKind::MethodCall { receiver: Box::new(receiver), @@ -548,7 +520,10 @@ impl AstLowering { dispatch: None, type_args: Vec::new(), args, - callable_signature: None, + callable_signature: Some(FunctionSignature { + params: adapter_params.iter().skip(1).cloned().collect(), + return_type: return_type.clone(), + }), arg_policy: super::super::super::expr::MethodCallArgPolicy::Default, }, return_type.clone(), diff --git a/src/backend/ir/lower/expr/calls.rs b/src/backend/ir/lower/expr/calls.rs index 32d102605..e47f6f9f9 100644 --- a/src/backend/ir/lower/expr/calls.rs +++ b/src/backend/ir/lower/expr/calls.rs @@ -1197,6 +1197,7 @@ impl AstLowering { type_args.iter().map(|ty| self.lower_type(&ty.node)).collect() } + /// Return the expression carried by a call argument. fn call_arg_expr(arg: &ast::CallArg) -> &ast::Spanned { match arg { ast::CallArg::Positional(e) @@ -1279,6 +1280,7 @@ impl AstLowering { } } + /// Lower a rusttype interop adapter into IR. fn lower_rusttype_interop_adapter( &mut self, arg_ty: &IrType, diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index 520f8b303..b2403b10a 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -315,6 +315,7 @@ impl AstLowering { } } + /// Classify an IR type as a Rust collection family. fn rust_collection_family_for_ir_type(ty: &IrType) -> Option { match ty { IrType::Struct(name) | IrType::NamedGeneric(name, _) => { @@ -325,6 +326,7 @@ impl AstLowering { } } + /// Return the ordinary argument policy for a method call. fn regular_method_call_arg_policy( &self, receiver_span: crate::frontend::ast::Span, diff --git a/src/backend/ir/lower/mod.rs b/src/backend/ir/lower/mod.rs index a040ccfe7..d6cf63366 100644 --- a/src/backend/ir/lower/mod.rs +++ b/src/backend/ir/lower/mod.rs @@ -282,6 +282,73 @@ impl AstLowering { .collect() } + /// Lower typechecker callable metadata into an IR function signature while preserving the container shape required + /// for rest parameters. + fn function_signature_from_callable_surface( + &mut self, + callable_params: &[CallableParam], + callable_ret: &crate::frontend::symbols::ResolvedType, + ) -> FunctionSignature { + FunctionSignature { + params: callable_params + .iter() + .enumerate() + .map(|(idx, param)| { + let base_ty = self.lower_resolved_type(¶m.ty); + FunctionParam { + name: param.name.clone().unwrap_or_else(|| format!("__incan_arg_{idx}")), + ty: Self::lower_param_container_type(param.kind, base_ty), + mutability: Mutability::Immutable, + is_self: false, + kind: param.kind, + default: None, + } + }) + .collect(), + return_type: self.lower_resolved_type(callable_ret), + } + } + + /// Lower typechecker callable metadata into an IR function type. + fn function_type_from_callable_surface( + &mut self, + callable_params: &[CallableParam], + callable_ret: &crate::frontend::symbols::ResolvedType, + ) -> IrType { + let signature = self.function_signature_from_callable_surface(callable_params, callable_ret); + IrType::Function { + params: signature.params.into_iter().map(|param| param.ty).collect(), + ret: Box::new(signature.return_type), + } + } + + /// Build forwarding arguments for a wrapper whose IR parameters already encode rest-parameter containers. + fn forwarding_args_from_params(params: &[FunctionParam]) -> Vec { + params + .iter() + .map(|param| { + let kind = match param.kind { + ast::ParamKind::Normal => IrCallArgKind::Positional, + ast::ParamKind::RestPositional => IrCallArgKind::PositionalUnpack, + ast::ParamKind::RestKeyword => IrCallArgKind::KeywordUnpack, + }; + IrCallArg { + name: None, + kind, + expr: TypedExpr::new( + IrExprKind::Var { + name: param.name.clone(), + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + param.ty.clone(), + ), + } + }) + .collect() + } + + /// Build IR function parameters from source callable metadata. fn function_params_from_source_callable_surface( &mut self, callable_params: &[CallableParam], @@ -957,6 +1024,7 @@ impl AstLowering { } } + /// Collect callable re-exports from checked package metadata. fn collect_function_reexports(&self, program: &ast::Program) -> Vec { let mut reexports = Vec::new(); for decl in &program.declarations { @@ -983,6 +1051,7 @@ impl AstLowering { reexports } + /// Return canonical module segments for a source import. fn canonical_source_import_module_segments(&self, module: &ast::ImportPath) -> Vec { let segments = if module.parent_levels > 0 && !module.is_absolute { let mut base = self @@ -1735,13 +1804,7 @@ impl AstLowering { let original_name = Self::decorator_original_function_name(&f.name); let original = self.lower_function_named(f, original_name.clone(), super::decl::Visibility::Private)?; - let decorated_ty = IrType::Function { - params: callable_params - .iter() - .map(|param| self.lower_resolved_type(¶m.ty)) - .collect(), - ret: Box::new(self.lower_resolved_type(&callable_ret)), - }; + let decorated_ty = self.function_type_from_callable_surface(&callable_params, &callable_ret); if !original.type_params.is_empty() { let wrapper = self.generic_decorated_function_wrapper( @@ -1848,27 +1911,16 @@ impl AstLowering { decorated_ty, ); } - let args = params - .iter() - .map(|param| IrCallArg { - name: None, - kind: IrCallArgKind::Positional, - expr: TypedExpr::new( - IrExprKind::Var { - name: param.name.clone(), - access: VarAccess::Read, - ref_kind: VarRefKind::Value, - }, - param.ty.clone(), - ), - }) - .collect(); + let args = Self::forwarding_args_from_params(¶ms); let call = TypedExpr::new( IrExprKind::Call { func: Box::new(decorated_func), type_args: Vec::new(), args, - callable_signature: None, + callable_signature: Some(FunctionSignature { + params: params.clone(), + return_type: return_type.clone(), + }), canonical_path: None, }, return_type.clone(), @@ -1952,27 +2004,16 @@ impl AstLowering { ret: Box::new(return_type.clone()), }, ); - let args = params - .iter() - .map(|param| IrCallArg { - name: None, - kind: IrCallArgKind::Positional, - expr: TypedExpr::new( - IrExprKind::Var { - name: param.name.clone(), - access: VarAccess::Read, - ref_kind: VarRefKind::Value, - }, - param.ty.clone(), - ), - }) - .collect(); + let args = Self::forwarding_args_from_params(¶ms); let call = TypedExpr::new( IrExprKind::Call { func: Box::new(static_func), type_args: Vec::new(), args, - callable_signature: None, + callable_signature: Some(FunctionSignature { + params: params.clone(), + return_type: return_type.clone(), + }), canonical_path: None, }, return_type.clone(), @@ -2045,6 +2086,7 @@ impl AstLowering { .collect() } + /// Return whether decorated positional parameter shapes match. fn decorated_positional_param_shapes_match( surface_params: &[CallableParam], original_params: &[CallableParam], @@ -2058,6 +2100,7 @@ impl AstLowering { }) } + /// Return whether a decorated parameter shape matches the source parameter. fn decorated_param_shape_matches(surface_param: &CallableParam, original_param: &CallableParam) -> bool { surface_param.kind == original_param.kind && surface_param.ty == original_param.ty } diff --git a/src/backend/ir/lower/stmt.rs b/src/backend/ir/lower/stmt.rs index c12f5995a..d74201ded 100644 --- a/src/backend/ir/lower/stmt.rs +++ b/src/backend/ir/lower/stmt.rs @@ -1635,6 +1635,7 @@ impl AstLowering { self.lower_assert_condition_expr(condition, message) } + /// Lower an `assert` statement into IR. fn lower_assert_stmt(&mut self, assert_stmt: &ast::AssertStmt) -> Result { match &assert_stmt.kind { ast::AssertKind::Condition(condition) => { @@ -1661,6 +1662,7 @@ impl AstLowering { } } + /// Lower an assertion condition into IR. fn lower_assert_condition_expr( &mut self, condition: TypedExpr, @@ -1698,6 +1700,7 @@ impl AstLowering { Ok(IrStmtKind::Expr(call)) } + /// Lower an `assert_raises` statement into IR. fn lower_assert_raises_stmt( &mut self, call: &Spanned, @@ -1814,6 +1817,7 @@ impl AstLowering { Ok(IrStmtKind::Expr(call)) } + /// Build an assertion pattern from an expression. fn assert_is_pattern_from_expr(expr: &Spanned) -> Option> { let ast::Expr::Binary(scrutinee, ast::BinaryOp::Is, pattern_expr) = &expr.node else { return None; @@ -1857,6 +1861,7 @@ impl AstLowering { } } + /// Build an assertion pattern from a parsed pattern. fn assert_is_pattern_from_pattern<'a>( scrutinee: &'a Spanned, pattern: &Spanned, @@ -2024,6 +2029,7 @@ impl AstLowering { } } + /// Count reads of an identifier inside a condition expression. fn count_condition_ident_reads(&self, condition: &ast::Condition, counts: &mut HashMap) { match condition { ast::Condition::Expr(expr) => self.count_expr_ident_reads(&expr.node, counts), diff --git a/src/backend/ir/mod.rs b/src/backend/ir/mod.rs index fb5ce1131..fd2f25eb7 100644 --- a/src/backend/ir/mod.rs +++ b/src/backend/ir/mod.rs @@ -111,6 +111,7 @@ impl FunctionSignature { Some(merged) } + /// Return whether parameter lists are compatible for default inheritance. fn params_match_for_default_inheritance( left: &FunctionSignature, right: &FunctionSignature, @@ -124,6 +125,7 @@ impl FunctionSignature { .all(|(left, right)| Self::param_matches_for_default_inheritance(left, right, types_match)) } + /// Return whether one parameter is compatible for default inheritance. fn param_matches_for_default_inheritance( left: &FunctionParam, right: &FunctionParam, diff --git a/src/backend/ir/trait_bound_inference.rs b/src/backend/ir/trait_bound_inference.rs index 20144a3ca..8297bba93 100644 --- a/src/backend/ir/trait_bound_inference.rs +++ b/src/backend/ir/trait_bound_inference.rs @@ -218,6 +218,7 @@ fn infer_backend_clone_bounds(program: &mut IrProgram) { } } +/// Return type parameters visible to callable-bound inference. fn callable_inference_type_params(func: &IrFunction, owner_type_params: Option<&[IrTypeParam]>) -> Vec { let mut type_params = owner_type_params.map_or_else(Vec::new, |params| params.to_vec()); for type_param in &func.type_params { @@ -513,6 +514,7 @@ struct BackendCallCloneContext<'a> { } impl BackendCloneInferenceContext { + /// Build clone-bound inference context from an IR program. fn from_program(program: &IrProgram) -> Self { let mut incan_nominal_names = HashSet::new(); let mut rusttype_alias_names = HashSet::new(); @@ -544,6 +546,7 @@ impl BackendCloneInferenceContext { } } + /// Return whether a receiver is an Incan-owned nominal type. fn is_incan_owned_nominal_receiver(&self, receiver_ty: &IrType) -> bool { match receiver_type_for_method_dispatch(receiver_ty) { IrType::Struct(name) | IrType::NamedGeneric(name, _) | IrType::Enum(name) => { @@ -554,6 +557,7 @@ impl BackendCloneInferenceContext { } } + /// Return whether a receiver is a rusttype alias. fn is_rusttype_alias_receiver(&self, receiver_ty: &IrType) -> bool { match receiver_type_for_method_dispatch(receiver_ty) { IrType::Struct(name) | IrType::NamedGeneric(name, _) => self.name_matches(name, &self.rusttype_alias_names), @@ -561,12 +565,14 @@ impl BackendCloneInferenceContext { } } + /// Return whether a fully qualified or short name is in the provided name set. fn name_matches(&self, name: &str, names: &HashSet) -> bool { let short_name = name.rsplit("::").next().unwrap_or(name); names.contains(name) || names.contains(short_name) } } +/// Return the receiver type used for method-dispatch analysis. fn receiver_type_for_method_dispatch(receiver_ty: &IrType) -> &IrType { let mut receiver_ty = receiver_ty; while let IrType::Ref(inner) | IrType::RefMut(inner) = receiver_ty { @@ -575,6 +581,7 @@ fn receiver_type_for_method_dispatch(receiver_ty: &IrType) -> &IrType { receiver_ty } +/// Add backend clone bounds required by callable return values. fn augment_callable_type_params_for_backend_return_clones( type_params: &mut [IrTypeParam], body: &[IrStmt], @@ -1527,6 +1534,7 @@ fn collect_backend_clone_bounds_in_expr( } } +/// Return the reference kind used by a receiver expression. fn receiver_ref_kind(receiver: &IrExpr) -> Option { match &receiver.kind { IrExprKind::Var { ref_kind, .. } => Some(*ref_kind), @@ -1836,6 +1844,7 @@ fn scan_stmt_for_bounds( } } +/// Return the trait bound implied by a value-level reflection magic method. fn reflection_magic_trait_bound(method: &str) -> Option<&'static str> { match magic_methods::from_str(method) { Some(magic_methods::MagicMethodId::ClassName) => Some(tb::INCAN_CLASS_NAME), @@ -1844,6 +1853,7 @@ fn reflection_magic_trait_bound(method: &str) -> Option<&'static str> { } } +/// Return the trait bound implied by a type-level reflection magic method. fn type_reflection_magic_trait_bound(method: &str) -> Option<&'static str> { match magic_methods::from_str(method) { Some(magic_methods::MagicMethodId::ClassName) => Some(tb::INCAN_TYPE_CLASS_NAME), @@ -2239,6 +2249,7 @@ fn expr_type_param_name( None } +/// Return the type parameter named by a type-name expression. fn type_name_expr_type_param_name(expr: &IrExpr, type_params: &HashSet<&str>) -> Option { let IrExprKind::Var { name, @@ -2251,6 +2262,7 @@ fn type_name_expr_type_param_name(expr: &IrExpr, type_params: &HashSet<&str>) -> type_params.contains(name.as_str()).then(|| name.clone()) } +/// Extract a type parameter name from an IR type. fn type_param_name_from_ir_type(ty: &IrType, type_params: &HashSet<&str>) -> Option { match ty { IrType::Generic(name) if type_params.contains(name.as_str()) => Some(name.clone()), @@ -2259,6 +2271,7 @@ fn type_param_name_from_ir_type(ty: &IrType, type_params: &HashSet<&str>) -> Opt } } +/// Collect type-parameter mappings between callee and caller types. fn collect_type_param_mapping( callee_ty: &IrType, caller_ty: &IrType, @@ -2295,6 +2308,7 @@ fn collect_type_param_mapping( } } +/// Resolve the generic function key for a call target. fn resolve_called_generic_key( local_name: &str, canonical_path: Option<&[String]>, diff --git a/src/backend/project/generator.rs b/src/backend/project/generator.rs index 521a30766..827a9e94e 100644 --- a/src/backend/project/generator.rs +++ b/src/backend/project/generator.rs @@ -84,6 +84,7 @@ pub enum RunProfile { } impl ProjectGenerator { + /// Create a project generator for an Incan build target. pub fn new(output_dir: impl AsRef, name: &str, is_binary: bool) -> Self { Self { output_dir: output_dir.as_ref().to_path_buf(), @@ -167,6 +168,7 @@ impl ProjectGenerator { Some(Self::resolve_target_dir(raw)) } + /// Resolve the cargo target directory for a generated project. pub(super) fn resolve_target_dir(target_dir: PathBuf) -> PathBuf { if target_dir.is_absolute() { target_dir @@ -192,6 +194,7 @@ impl ProjectGenerator { } } + /// Return a filesystem-safe name for a shared cargo target directory. pub(super) fn shared_target_safe_name(name: &str, output_dir: &Path) -> String { let mut normalized = name .chars() diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs index 9e19dde2e..07d5c3748 100644 --- a/src/cli/commands/common.rs +++ b/src/cli/commands/common.rs @@ -367,6 +367,7 @@ pub(crate) fn collect_project_requirements( Ok(requirements) } +/// Build a dependency specification from a stdlib extra crate requirement. fn dependency_spec_from_stdlib_extra_crate(crate_name: &str) -> CliResult { let dep = stdlib::find_extra_crate_dep(crate_name).ok_or_else(|| { CliError::failure(format!( @@ -377,6 +378,7 @@ fn dependency_spec_from_stdlib_extra_crate(crate_name: &str) -> CliResult DependencySpec { match dep.source { StdlibExtraCrateSource::Version(version) => DependencySpec { @@ -467,6 +469,7 @@ pub(crate) fn merge_project_requirement_dependencies( Ok(()) } +/// Merge project-level dependency requirements into the resolved dependency set. pub(crate) fn merge_project_requirements( current: &ProjectRequirements, extra: &ProjectRequirements, @@ -501,6 +504,7 @@ pub(crate) fn merge_project_requirements( }) } +/// Merge resolved dependency requirements from multiple sources. pub(crate) fn merge_resolved_dependencies( current: &ResolvedDependencies, extra: &ResolvedDependencies, @@ -521,6 +525,7 @@ pub(crate) fn merge_resolved_dependencies( Ok(merged) } +/// Merge one resolved dependency requirement into the dependency map. fn merge_resolved_dependency( dependencies: &mut Vec, dev_dependencies: &mut Vec, @@ -746,6 +751,7 @@ fn rust_inspect_workspace_fingerprint( ) } +/// Return the workspace directory used for Rust inspection metadata. #[cfg(feature = "rust_inspect")] fn rust_inspect_workspace_dir(project_root: &Path, project_name: &str, fingerprint: &str) -> PathBuf { let mut safe_name = project_name @@ -921,6 +927,7 @@ fn parse_rust_inspect_prewarm_env(raw: Option<&str>) -> bool { !matches!(raw.trim(), "0" | "false" | "FALSE" | "off" | "OFF" | "no" | "NO") } +/// Return whether Rust inspection prewarming is enabled. #[cfg(feature = "rust_inspect")] fn rust_inspect_prewarm_enabled() -> bool { parse_rust_inspect_prewarm_env(std::env::var("INCAN_RUST_INSPECT_PREWARM").ok().as_deref()) diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 1716829bd..de2266ec9 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -64,6 +64,7 @@ fn test_preheat_enabled() -> bool { parse_test_preheat_env(std::env::var("INCAN_TEST_PREHEAT").ok().as_deref()) } +/// Collect inline imports required by dependencies of a test source file. fn collect_test_dependency_inline_imports( test_module: &ParsedModule, source_modules: &[ParsedModule], @@ -236,6 +237,7 @@ struct TopLevelNameSummary { /// Collect top-level Rust item names that would collide if multiple Incan files were concatenated. fn collect_top_level_decl_names(program: &Program) -> TopLevelNames { + /// Record a top-level import binding as both a type and value name. fn add_import_binding(name: &str, names: &mut TopLevelNames) { names.imported_types.insert(name.to_string()); names.imported_values.insert(name.to_string()); @@ -326,6 +328,7 @@ fn collect_top_level_decl_names(program: &Program) -> TopLevelNames { names } +/// Collect top-level name information for one test source file. fn collect_top_level_name_summary( path: &Path, source: &str, @@ -341,6 +344,7 @@ fn collect_top_level_name_summary( }) } +/// Collect top-level name summaries for all files in a test batch. fn collect_top_level_name_summaries( sources_by_file: &[(PathBuf, String)], library_imported_vocab: Option<&parser::ImportedLibraryVocab>, @@ -351,6 +355,7 @@ fn collect_top_level_name_summaries( .collect() } +/// Return whether top-level names collide across test-batch files. fn top_level_summaries_have_collision<'a>(summaries: impl IntoIterator) -> bool { let mut type_owner: HashMap = HashMap::new(); let mut value_owner: HashMap = HashMap::new(); @@ -454,6 +459,7 @@ fn partition_collision_free_file_groups( .collect() } +/// Shift token spans after concatenating test source files. fn rebase_token_spans(tokens: &mut [lexer::Token], source_offset: usize) { if source_offset == 0 { return; @@ -527,6 +533,7 @@ struct InlineSourceModuleBatch { harnesses: Vec, } +/// Create an empty synthetic program for a test batch. fn empty_test_batch_root(first_path: &Path) -> Program { Program { declarations: Vec::new(), @@ -536,6 +543,7 @@ fn empty_test_batch_root(first_path: &Path) -> Program { } } +/// Return whether a program contains inline test modules. fn program_has_inline_test_module(program: &Program) -> bool { program .declarations @@ -543,6 +551,7 @@ fn program_has_inline_test_module(program: &Program) -> bool { .any(|decl| matches!(decl.node, Declaration::TestModule(_))) } +/// Prepare the runner AST and fixture metadata for a test module. fn prepare_runner_program(ast: &Program) -> Result<(Program, HashMap), String> { let mut runner_ast = ast_with_inline_test_declarations(ast); normalize_runner_assert_statements(&mut runner_ast); @@ -554,6 +563,7 @@ fn prepare_runner_program(ast: &Program) -> Result<(Program, HashMap String { let mut hasher = Sha256::new(); for segment in segments { @@ -592,6 +603,7 @@ fn module_name_for_segments(segments: &[String]) -> String { format!("{stem}_{}", &digest[..8]) } +/// Read conftest source files for a test batch. fn read_conftest_sources(paths: &[PathBuf]) -> Result, String> { let mut sources = Vec::new(); for path in paths { @@ -602,6 +614,7 @@ fn read_conftest_sources(paths: &[PathBuf]) -> Result, St Ok(sources) } +/// Prepare a collision-aware batch of inline source modules. fn prepare_inline_source_module_batch( sources_by_file: &[(PathBuf, String)], conftest_files_by_file: &HashMap>, @@ -821,6 +834,7 @@ fn shared_cargo_target_dir(project_root: &Path) -> PathBuf { } } +/// Return the package entry path used for lockfile validation. fn lock_validation_entry_path(project_root: &Path, manifest: Option<&ProjectManifest>) -> Option { if let Some(main) = manifest .and_then(|m| m.project.as_ref()) @@ -1696,6 +1710,7 @@ fn test_runner_stdlib_features( features.into_iter().collect() } +/// Collect stdlib feature flags needed by a test batch. fn test_runner_stdlib_features_for_batch( base: &[String], tests: &[TestInfo], @@ -2054,6 +2069,7 @@ fn inject_file_test_harness( inject_file_test_harness_with_indices(rust_code, tests, &test_indices, project_root, fixtures) } +/// Inject generated Rust test harness entries using stable test indices. fn inject_file_test_harness_with_indices( rust_code: &str, tests: &[TestInfo], diff --git a/src/cli/test_runner/module_graph.rs b/src/cli/test_runner/module_graph.rs index 9378911fe..cc996679f 100644 --- a/src/cli/test_runner/module_graph.rs +++ b/src/cli/test_runner/module_graph.rs @@ -111,6 +111,7 @@ fn queue_implicit_stdlib_helpers( Ok(queued) } +/// Return the stable key used for dependency graph edges. fn dependency_edge_key(path: &Path) -> String { path.to_string_lossy().to_string() } diff --git a/src/dependency_resolver.rs b/src/dependency_resolver.rs index 2c44b6e04..fc80aaba8 100644 --- a/src/dependency_resolver.rs +++ b/src/dependency_resolver.rs @@ -66,6 +66,7 @@ fn with_rust_import_context(error: CompileError, import: &InlineRustImport) -> C .with_hint("Verify the Rust crate/module/item path in the import statement") } +/// Resolve dependency specifications for a package graph. pub fn resolve_dependencies( manifest: Option<&ProjectManifest>, inline_imports: &[InlineRustImport], @@ -81,6 +82,7 @@ pub fn resolve_dependencies( ) } +/// Resolve dependencies reachable from entrypoint modules. pub fn resolve_reachable_dependencies( manifest: Option<&ProjectManifest>, inline_imports: &[InlineRustImport], @@ -96,6 +98,7 @@ pub fn resolve_reachable_dependencies( ) } +/// Resolve dependencies for an explicit dependency scope. fn resolve_dependencies_with_scope( manifest: Option<&ProjectManifest>, inline_imports: &[InlineRustImport], @@ -210,6 +213,7 @@ fn matching_dep_spec<'a>( .or_else(|| deps.get_key_value(&crate_name.replace('-', "_"))) } +/// Merge inline import dependency requirements. fn merge_inline_imports( inline_imports: &[InlineRustImport], manifest_deps: &HashMap, @@ -405,6 +409,7 @@ fn merge_inline_imports( } } +/// Select manifest dependencies that are relevant to the active build scope. fn select_manifest_dependencies( deps: &HashMap, selected_keys: &HashSet, @@ -559,6 +564,7 @@ fn validate_optional_imports( // Known-good defaults (RFC 013) // ============================================================================ +/// Return a conservative dependency specification for a known-good crate. fn known_good_spec(crate_name: &str) -> Option { if let Some(spec) = known_good_spec_from_stdlib(crate_name) { return Some(spec); diff --git a/src/format/comments/buffer.rs b/src/format/comments/buffer.rs index 06bc5bd83..5c3be488b 100644 --- a/src/format/comments/buffer.rs +++ b/src/format/comments/buffer.rs @@ -19,6 +19,7 @@ pub(in crate::format) struct NormalizedLineBuffer { } impl NormalizedLineBuffer { + /// Create an empty line buffer with no active string state. pub(in crate::format) fn new() -> Self { Self { lines: Vec::new(), @@ -63,6 +64,7 @@ impl NormalizedLineBuffer { } } + /// Return whether the comment buffer ends with a nonblank line. pub(in crate::format) fn ends_with_nonblank_line(&self) -> bool { self.lines.last().is_some_and(|line| !line.is_empty()) } diff --git a/src/format/comments/mod.rs b/src/format/comments/mod.rs index c68d11aed..ac2e4252b 100644 --- a/src/format/comments/mod.rs +++ b/src/format/comments/mod.rs @@ -5,10 +5,12 @@ mod model; mod reattach; mod scanner; +/// Reattach scanned comments to the formatted syntax tree. pub(super) fn reattach_comments(source: &str, formatted: &str) -> String { reattach::reattach_comments(source, formatted) } +/// Count line comments in formatted source text. pub(super) fn count_line_comments(source: &str) -> usize { scanner::count_line_comments(source) } diff --git a/src/format/comments/model.rs b/src/format/comments/model.rs index 88d840b39..03b6b4922 100644 --- a/src/format/comments/model.rs +++ b/src/format/comments/model.rs @@ -37,6 +37,7 @@ struct PendingStandaloneBlock { saw_blank_before: bool, } +/// Normalize source text before matching comments back to code. pub(super) fn normalize_code_for_match(code: &str) -> String { code.chars().filter(|c| !c.is_whitespace()).collect() } @@ -207,6 +208,7 @@ fn finalize_pending_standalone_block( } } +/// Trim blank comment lines from the end of a comment block. fn trim_trailing_blank_comment_lines(lines: &[String]) -> Vec { let mut out = lines.to_vec(); while out.last().is_some_and(|l| l.trim().is_empty()) { diff --git a/src/format/comments/reattach.rs b/src/format/comments/reattach.rs index fb39168ad..ce216e8ed 100644 --- a/src/format/comments/reattach.rs +++ b/src/format/comments/reattach.rs @@ -220,10 +220,12 @@ fn expand_inline_match_arm_with_leading_block( true } +/// Return whether an inline comment still belongs to a formatted node. fn inline_comment_matches(inline_comment: &InlineComment, normalized: &str, occurrence: Option) -> bool { inline_comment.anchor == normalized && occurrence.is_some_and(|occ| occ == inline_comment.occurrence) } +/// Queue trailing comment blocks for reattachment. fn queue_trailing_blocks( trailing_standalone: &[AnchoredStandaloneBlock], trailing_idx: &mut usize, diff --git a/src/format/comments/scanner.rs b/src/format/comments/scanner.rs index 18e96ff88..5785593ec 100644 --- a/src/format/comments/scanner.rs +++ b/src/format/comments/scanner.rs @@ -30,6 +30,7 @@ pub(super) fn count_line_comments(source: &str) -> usize { count } +/// Return the byte index where a line comment starts outside strings. pub(super) fn comment_start_index(line: &str, state: &mut StringState) -> Option { let mut i = 0usize; while i < line.len() { diff --git a/src/format/formatter/declarations.rs b/src/format/formatter/declarations.rs index f0f05b2ba..c4ec40e43 100644 --- a/src/format/formatter/declarations.rs +++ b/src/format/formatter/declarations.rs @@ -6,6 +6,7 @@ use crate::frontend::ast::*; use super::{Formatter, RFC053_METHOD_BLANK_LINES}; impl Formatter { + /// Return whether a method declaration owns a formatted body. fn method_is_body_bearing(method: &MethodDecl) -> bool { method.body.is_some() } @@ -15,6 +16,7 @@ impl Formatter { property.body.is_some() } + /// Format methods with declaration spacing preserved. fn format_methods_with_spacing(&mut self, methods: &[Spanned], seen_member_before_methods: bool) { let mut seen_member = seen_member_before_methods; for method in methods { @@ -112,6 +114,7 @@ impl Formatter { self.writer.newline(); } + /// Format an inline test module declaration. fn format_test_module(&mut self, test_module: &TestModuleDecl) { self.writer.write("module "); self.writer.write(&test_module.name); @@ -126,6 +129,7 @@ impl Formatter { self.writer.dedent(); } + /// Format a docstring while preserving source prose. pub(super) fn format_docstring(&mut self, doc: &str) { // Trim leading and trailing whitespace from the docstring content to ensure idempotent formatting let trimmed = doc.trim(); @@ -1150,6 +1154,7 @@ impl Formatter { } } + /// Format one function parameter. fn format_param(&mut self, param: &Param) { if param.is_mut { self.writer.write("mut "); @@ -1279,6 +1284,7 @@ fn strip_common_indent(line: &str, indent: usize) -> &str { &line[start..] } +/// Return normalized docstring body lines. fn normalized_docstring_lines(doc: &str) -> Vec { let lines: Vec<&str> = doc.lines().collect(); let first = lines.first().map(|line| line.trim()).unwrap_or_default(); diff --git a/src/format/formatter/expressions.rs b/src/format/formatter/expressions.rs index 23ebc7bb1..5e0a9ae90 100644 --- a/src/format/formatter/expressions.rs +++ b/src/format/formatter/expressions.rs @@ -17,6 +17,7 @@ impl Formatter { matches!(expr, Expr::Binary(_, op, _) if Self::is_logical_binary_op(op)) } + /// Write one formatted call argument. fn write_call_arg(&mut self, arg: &CallArg) { match arg { CallArg::Positional(expr) => self.format_expr(&expr.node), @@ -53,6 +54,7 @@ impl Formatter { } } + /// Format call arguments with line wrapping when needed. fn format_call_args_with_wrapping(&mut self, args: &[CallArg]) { if args.is_empty() { return; @@ -543,6 +545,7 @@ impl Formatter { // ---- Call args ---- + /// Format closure parameters. fn format_closure_params(&mut self, params: &[Spanned]) { for (i, param) in params.iter().enumerate() { if i > 0 { @@ -552,6 +555,7 @@ impl Formatter { } } + /// Format call arguments. fn format_call_args(&mut self, args: &[CallArg]) { for (i, arg) in args.iter().enumerate() { if i > 0 { @@ -575,6 +579,7 @@ impl Formatter { } } + /// Format one match arm. fn format_match_arm(&mut self, arm: &Spanned) { self.writer.blank_lines(arm.leading_blank_lines as usize); let arm = &arm.node; @@ -607,6 +612,7 @@ impl Formatter { } } + /// Try to format a match arm body as an inline statement. fn try_format_inline_match_statement(&mut self, stmts: &[Spanned]) -> bool { let [stmt] = stmts else { return false; @@ -628,6 +634,7 @@ impl Formatter { true } + /// Format a statement for inline expression contexts. fn format_statement_inline(&mut self, stmt: &Statement) -> bool { match stmt { Statement::Expr(expr) => self.format_expr(&expr.node), diff --git a/src/format/formatter/mod.rs b/src/format/formatter/mod.rs index 70c45e6bb..ecc62c15b 100644 --- a/src/format/formatter/mod.rs +++ b/src/format/formatter/mod.rs @@ -187,6 +187,7 @@ impl Formatter { } } + /// Return whether a declaration needs wider top-level spacing. fn decl_needs_wide_top_level_spacing(decl: &Declaration) -> bool { matches!( decl, diff --git a/src/format/formatter/statements.rs b/src/format/formatter/statements.rs index 50e0bff5d..cdd1aef46 100644 --- a/src/format/formatter/statements.rs +++ b/src/format/formatter/statements.rs @@ -181,6 +181,7 @@ impl Formatter { self.writer.newline(); } + /// Format an assert statement. fn format_assert(&mut self, assert_stmt: &AssertStmt) { self.writer.write("assert "); match &assert_stmt.kind { @@ -203,6 +204,7 @@ impl Formatter { self.writer.newline(); } + /// Format an if statement. fn format_if(&mut self, if_stmt: &IfStmt) { self.writer.write("if "); self.format_condition(&if_stmt.condition); @@ -243,6 +245,7 @@ impl Formatter { } } + /// Format a loop statement. fn format_loop(&mut self, loop_stmt: &LoopStmt) { self.writer.writeln("loop:"); self.writer.indent(); @@ -255,6 +258,7 @@ impl Formatter { self.writer.dedent(); } + /// Format a while statement. fn format_while(&mut self, while_stmt: &WhileStmt) { self.writer.write("while "); self.format_condition(&while_stmt.condition); @@ -299,6 +303,7 @@ impl Formatter { } } + /// Format a conditional expression. fn format_condition(&mut self, condition: &Condition) { match condition { Condition::Expr(expr) => self.format_expr(&expr.node), diff --git a/src/frontend/api_metadata.rs b/src/frontend/api_metadata.rs index 608c53fb2..6ac080f15 100644 --- a/src/frontend/api_metadata.rs +++ b/src/frontend/api_metadata.rs @@ -589,12 +589,14 @@ struct ApiAliasProjectionRequest { anchor: SourceAnchor, } +/// Build the API declaration path for a module-local name. fn declaration_path(module_path: &[String], name: &str) -> Vec { let mut path = module_path.to_vec(); path.push(name.to_string()); path } +/// Normalize an API target path by removing a leading `crate` segment. fn normalized_api_target_path(path: &[String]) -> Vec { if path.first().is_some_and(|segment| segment == "crate") { return path[1..].to_vec(); @@ -602,6 +604,7 @@ fn normalized_api_target_path(path: &[String]) -> Vec { path.to_vec() } +/// Build callable metadata from a checked API function export. fn callable_from_function(function: &ApiFunction) -> ApiCallableMetadata { ApiCallableMetadata { name: function.name.clone(), @@ -614,6 +617,7 @@ fn callable_from_function(function: &ApiFunction) -> ApiCallableMetadata { } } +/// Build projected callable metadata for an alias re-export. fn projected_function_for_alias( alias: &ApiAliasProjectionRequest, target: &ApiProjectedFunction, @@ -624,10 +628,12 @@ fn projected_function_for_alias( projected } +/// Look up the checked export kind for a public name. fn checked_kind<'a>(exports: &'a HashMap, name: &str) -> Option<&'a CheckedExportKind> { exports.get(name).map(|export| &export.kind) } +/// Return whether a declaration visibility is public. fn public(visibility: Visibility) -> bool { matches!(visibility, Visibility::Public) } @@ -701,6 +707,7 @@ fn api_preset_value(value: &CheckedPresetValue) -> PresetValueExport { } } +/// Convert a source function declaration into API metadata. fn api_function( function: &FunctionDecl, span: Span, @@ -723,6 +730,7 @@ fn api_function( } } +/// Convert a source function declaration into callable API metadata. fn api_callable_for_function( function: &FunctionDecl, span: Span, @@ -766,6 +774,7 @@ fn source_function_params(function: &FunctionDecl, checker: &TypeChecker) -> Vec .collect() } +/// Resolve the source return type used by function API metadata. fn source_function_return_type(function: &FunctionDecl, checker: &TypeChecker) -> TypeRef { type_ref_from_resolved(&crate::frontend::symbols::resolve_type( &function.return_type.node, @@ -773,6 +782,7 @@ fn source_function_return_type(function: &FunctionDecl, checker: &TypeChecker) - )) } +/// Convert a source model declaration into API metadata. fn api_model( model: &ModelDecl, span: Span, @@ -795,6 +805,7 @@ fn api_model( } } +/// Convert a source class declaration into API metadata. fn api_class( class: &ClassDecl, span: Span, @@ -894,6 +905,7 @@ fn api_enum( } } +/// Convert a source newtype declaration into API metadata. fn api_newtype( newtype: &NewtypeDecl, span: Span, @@ -915,6 +927,7 @@ fn api_newtype( } } +/// Convert a source type alias declaration into API metadata. fn api_type_alias( alias: &TypeAliasDecl, span: Span, @@ -932,6 +945,7 @@ fn api_type_alias( } } +/// Convert a checked constant declaration into API metadata. fn api_const( name: &str, span: Span, @@ -947,6 +961,7 @@ fn api_const( } } +/// Convert an import declaration into API alias metadata. fn api_aliases(import: &ImportDecl, span: Span, module_path: &[String]) -> Vec { match &import.kind { ImportKind::From { module, items } => { @@ -972,6 +987,7 @@ fn api_aliases(import: &ImportDecl, span: Span, module_path: &[String]) -> Vec, @@ -1115,6 +1131,7 @@ fn checked_method_shape_matches(ast_method: &MethodDecl, checked: &CheckedMethod )) } +/// Convert checked type parameters into API metadata exports. fn type_params(type_params: &[CheckedTypeParam]) -> Vec { type_params .iter() @@ -1135,6 +1152,7 @@ fn type_bound(bound: &CheckedTypeBound) -> TypeBoundExport { } } +/// Convert checked callable parameters into API metadata exports. fn params(params: &[crate::frontend::symbols::CallableParam]) -> Vec { params .iter() @@ -1153,6 +1171,7 @@ fn params(params: &[crate::frontend::symbols::CallableParam]) -> Vec FieldExport { FieldExport { name: field.name.clone(), @@ -1163,6 +1182,7 @@ fn field(field: &crate::frontend::library_exports::CheckedField) -> FieldExport } } +/// Return checked fields ordered to match the source declaration. fn fields_in_source_order(ast_fields: &[Spanned], checked_fields: &[CheckedField]) -> Vec { let checked_by_name: HashMap<&str, &CheckedField> = checked_fields .iter() @@ -1187,6 +1207,7 @@ fn fields_in_source_order(ast_fields: &[Spanned], checked_fields: &[C out } +/// Convert source decorators into checked API metadata entries. fn decorators_metadata( decorators: &[Spanned], checker: &TypeChecker, @@ -1223,6 +1244,7 @@ fn decorators_metadata( .collect() } +/// Convert a decorator argument into API metadata. fn decorator_arg_metadata(arg: &DecoratorArg, checker: &TypeChecker) -> DecoratorArgMetadata { match arg { DecoratorArg::Positional(expr) => DecoratorArgMetadata::Positional { @@ -1241,6 +1263,7 @@ fn decorator_arg_metadata(arg: &DecoratorArg, checker: &TypeChecker) -> Decorato } } +/// Convert a decorator expression into a safe metadata value. fn decorator_expr_value(expr: &Spanned, checker: &TypeChecker) -> DecoratorValue { match &expr.node { Expr::Literal(literal) => DecoratorValue::Literal { @@ -1333,6 +1356,7 @@ fn decorator_expr_value(expr: &Spanned, checker: &TypeChecker) -> Decorato } } +/// Convert a decorator call argument into API metadata. fn decorator_call_arg_metadata(arg: &CallArg, checker: &TypeChecker) -> DecoratorCallArgMetadata { match arg { CallArg::Positional(value) => DecoratorCallArgMetadata::Positional { @@ -1351,6 +1375,7 @@ fn decorator_call_arg_metadata(arg: &CallArg, checker: &TypeChecker) -> Decorato } } +/// Return the source path represented by a decorator expression. fn decorator_expr_path(expr: &Expr) -> Vec { match expr { Expr::Ident(name) => vec![name.clone()], @@ -1366,6 +1391,7 @@ fn decorator_expr_path(expr: &Expr) -> Vec { } } +/// Return a stable label for a decorator expression shape. fn decorator_expr_label(expr: &Expr) -> &'static str { match expr { Expr::Ident(_) => "identifier", @@ -1391,6 +1417,7 @@ fn safe_value_from_literal(literal: &crate::frontend::ast::Literal) -> SafeMetad } } +/// Convert a constant value into a safe metadata value. fn safe_value_from_const(value: &ConstValue) -> SafeMetadataValue { match value { ConstValue::Int(value) => SafeMetadataValue::Int(*value), @@ -1401,6 +1428,7 @@ fn safe_value_from_const(value: &ConstValue) -> SafeMetadataValue { } } +/// Extract the leading function docstring expression, when present. fn function_docstring(body: &[Spanned]) -> Option { let first = body.first()?; let Statement::Expr(expr) = &first.node else { @@ -1422,6 +1450,7 @@ pub fn validate_checked_api_docstrings(package: &[CheckedApiMetadata]) -> Vec) -> Option { let docstring = docstring?; let lines = normalized_docstring_lines(docstring); @@ -1441,6 +1470,7 @@ fn parse_docstring(docstring: Option<&str>) -> Option { Some(parsed.finish()) } +/// Return normalized docstring body lines. fn normalized_docstring_lines(docstring: &str) -> Vec { docstring .lines() @@ -1468,6 +1498,7 @@ enum DocstringSection { } impl DocstringSection { + /// Map a docstring section heading to its parser state. fn from_heading(line: &str) -> Option { match line { "Args:" | "Parameters:" => Some(Self::Params), @@ -1491,6 +1522,7 @@ struct DocstringBuilder { } impl DocstringBuilder { + /// Add a normalized docstring line to the active section. fn push_line(&mut self, section: DocstringSection, line: &str) { match section { DocstringSection::Summary => push_prose_line(&mut self.summary_lines, line), @@ -1502,6 +1534,7 @@ impl DocstringBuilder { } } + /// Build the completed structured docstring from accumulated lines. fn finish(self) -> ApiDocstring { ApiDocstring { summary: joined_non_empty(self.summary_lines), @@ -1514,6 +1547,7 @@ impl DocstringBuilder { } } +/// Append a normalized prose line to a docstring section. fn push_prose_line(lines: &mut Vec, line: &str) { if line.is_empty() { if !lines.last().is_some_and(String::is_empty) { @@ -1524,6 +1558,7 @@ fn push_prose_line(lines: &mut Vec, line: &str) { lines.push(line.to_string()); } +/// Append a normalized entry line to a docstring section. fn push_entry_line(entries: &mut Vec, line: &str) { if line.is_empty() { return; @@ -1546,6 +1581,7 @@ fn push_entry_line(entries: &mut Vec, line: &str) { } } +/// Parse a docstring return section into structured API documentation. fn parse_return_section(lines: Vec) -> Option { let description = joined_non_empty(lines)?; if let Some((ty, rest)) = description.split_once(':') { @@ -1560,11 +1596,13 @@ fn parse_return_section(lines: Vec) -> Option { Some(ApiDocstringReturn { ty: None, description }) } +/// Join non-empty docstring lines into a single paragraph. fn joined_non_empty(lines: Vec) -> Option { let joined = lines.join("\n").trim().to_string(); if joined.is_empty() { None } else { Some(joined) } } +/// Return whether a docstring fragment looks like a type spelling. fn looks_like_type_spelling(text: &str) -> bool { !text.is_empty() && text @@ -1572,6 +1610,7 @@ fn looks_like_type_spelling(text: &str) -> bool { .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ':' | '[' | ']' | ',' | ' ' | '&')) } +/// Validate docstring coverage for declarations in one API metadata module. fn validate_module_docstrings( module: &CheckedApiMetadata, aliases: &[ApiAlias], @@ -1689,6 +1728,7 @@ struct DeclarationDocFacts<'a> { aliases: Vec<&'a str>, } +/// Validate a callable docstring against its exported API shape. fn validate_callable_docstring( module_path: &[String], declaration_name: &str, @@ -1735,6 +1775,7 @@ fn validate_callable_docstring( ); } +/// Validate a type docstring against its exported API shape. fn validate_type_docstring( module_path: &[String], declaration_name: &str, @@ -1773,6 +1814,7 @@ fn validate_type_docstring( ); } +/// Validate one exported declaration docstring. fn validate_declaration_docstring( module_path: &[String], declaration_name: &str, @@ -1802,6 +1844,7 @@ fn validate_declaration_docstring( ); } +/// Validate the return section for a callable docstring. fn validate_return_docstring( module_path: &[String], anchor: &SourceAnchor, @@ -1830,6 +1873,7 @@ fn validate_return_docstring( } } +/// Validate decorator documentation entries for an exported callable. fn validate_decorator_entries( module_path: &[String], anchor: &SourceAnchor, @@ -1858,6 +1902,7 @@ fn validate_decorator_entries( ); } +/// Validate alias documentation entries for exported declarations. fn validate_alias_entries( module_path: &[String], anchor: &SourceAnchor, @@ -1949,6 +1994,7 @@ fn validate_named_entries( } } +/// Return the expected docstring section name for a documented noun. fn section_name_for_noun(noun: &str) -> &'static str { match noun { "parameter" => "Args:", @@ -1959,6 +2005,7 @@ fn section_name_for_noun(noun: &str) -> &'static str { } } +/// Record an API docstring diagnostic anchored to a source span. fn push_docstring_diagnostic( diagnostics: &mut Vec, module_path: &[String], @@ -1972,6 +2019,7 @@ fn push_docstring_diagnostic( }); } +/// Return method metadata attached to a class-like declaration. fn declaration_methods(declaration: &ApiDeclaration) -> &[ApiMethod] { match declaration { ApiDeclaration::Model(model) => &model.methods, @@ -1982,6 +2030,7 @@ fn declaration_methods(declaration: &ApiDeclaration) -> &[ApiMethod] { } } +/// Return alias metadata exported by a checked package. fn package_aliases(package: &[CheckedApiMetadata]) -> Vec { package .iter() @@ -1993,6 +2042,7 @@ fn package_aliases(package: &[CheckedApiMetadata]) -> Vec { .collect() } +/// Return aliases that target a specific exported declaration. fn aliases_for_declaration<'a>(aliases: &'a [ApiAlias], module_path: &[String], name: &str) -> Vec<&'a str> { aliases .iter() @@ -2001,6 +2051,7 @@ fn aliases_for_declaration<'a>(aliases: &'a [ApiAlias], module_path: &[String], .collect() } +/// Return whether an alias path names a specific exported declaration. fn alias_targets_declaration(alias: &ApiAlias, module_path: &[String], name: &str) -> bool { let mut declaration_path = module_path.to_vec(); declaration_path.push(name.to_string()); @@ -2014,6 +2065,7 @@ fn alias_targets_declaration(alias: &ApiAlias, module_path: &[String], name: &st false } +/// Render a type reference as a docstring-facing type name. fn type_ref_doc_name(ty: &TypeRef) -> String { match ty { TypeRef::Named { name } => name.clone(), @@ -2037,6 +2089,7 @@ fn type_ref_doc_name(ty: &TypeRef) -> String { } } +/// Build a source anchor for an API metadata span. fn anchor(module_path: &[String], name: &str, span: Span) -> SourceAnchor { let mut parts = module_path.to_vec(); parts.push(name.to_string()); @@ -2046,6 +2099,7 @@ fn anchor(module_path: &[String], name: &str, span: Span) -> SourceAnchor { } } +/// Convert a concrete span into an API metadata source span. fn source_span(span: Span) -> SourceSpan { SourceSpan { start: span.start, diff --git a/src/frontend/ast_walk.rs b/src/frontend/ast_walk.rs index a591facea..c6bb74de2 100644 --- a/src/frontend/ast_walk.rs +++ b/src/frontend/ast_walk.rs @@ -225,6 +225,7 @@ where } } +/// Return whether an assert pattern contains an expression. fn assert_has_expr(assert_stmt: &crate::frontend::ast::AssertStmt, pred: &mut F) -> bool where F: FnMut(&Expr) -> bool, @@ -241,6 +242,7 @@ where .is_some_and(|message| expr_has(&message.node, pred)) } +/// Return whether a condition pattern contains an expression. fn condition_has_expr(condition: &Condition, pred: &mut F) -> bool where F: FnMut(&Expr) -> bool, diff --git a/src/frontend/contract_metadata.rs b/src/frontend/contract_metadata.rs index 152898a8d..77f98c33e 100644 --- a/src/frontend/contract_metadata.rs +++ b/src/frontend/contract_metadata.rs @@ -40,6 +40,7 @@ impl Default for ContractMetadataPackage { } impl ContractMetadataPackage { + /// Create a contract metadata package for canonical model bundles. pub fn new(model_bundles: Vec) -> Self { Self { schema_version: CONTRACT_METADATA_SCHEMA_VERSION, @@ -99,6 +100,7 @@ pub struct CanonicalModelField { pub metadata: BTreeMap, } +/// Return the default publishable contract metadata for a package. fn default_publishable() -> bool { true } @@ -229,6 +231,7 @@ impl CanonicalModelBundle { }) } + /// Return the package bundle name used in contract metadata. fn bundle_name(&self) -> String { if self.logical_type_name.trim().is_empty() { "".to_string() @@ -238,6 +241,7 @@ impl CanonicalModelBundle { } } +/// Validate an identifier used in contract metadata. fn validate_identifier(value: &str, label: &str, bundle: &str) -> Result<(), ContractMetadataError> { let mut chars = value.chars(); let valid_start = chars.next().is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic()); @@ -252,6 +256,7 @@ fn validate_identifier(value: &str, label: &str, bundle: &str) -> Result<(), Con } } +/// Validate a type spelling used in contract metadata. fn validate_type_spelling(ty: &str, bundle: &str, field: &str) -> Result<(), ContractMetadataError> { let trimmed = ty.trim(); if trimmed.is_empty() { @@ -269,6 +274,7 @@ fn validate_type_spelling(ty: &str, bundle: &str, field: &str) -> Result<(), Con Ok(()) } +/// Return the source text used for exported field metadata. fn field_metadata_source(field: &CanonicalModelField) -> String { let mut pairs = Vec::new(); if let Some(alias) = field.alias.as_deref() { @@ -284,6 +290,7 @@ fn field_metadata_source(field: &CanonicalModelField) -> String { } } +/// Return the source text used for exported field type metadata. fn field_type_source(field: &CanonicalModelField) -> String { let ty = field.ty.trim(); if field.nullable && !ty.starts_with("Option[") { @@ -293,6 +300,7 @@ fn field_type_source(field: &CanonicalModelField) -> String { } } +/// Escape text for use in an Incan string literal. fn escape_incan_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } @@ -389,6 +397,7 @@ pub fn materialize_contract_models( Ok(()) } +/// Validate that generated contract metadata has no source-name collisions. fn validate_no_source_collisions( program: &Program, bundles: &[CanonicalModelBundle], diff --git a/src/frontend/library_exports.rs b/src/frontend/library_exports.rs index b7cafeb64..fc85d2732 100644 --- a/src/frontend/library_exports.rs +++ b/src/frontend/library_exports.rs @@ -343,6 +343,7 @@ fn checked_alias_export(alias: &AliasDecl, checker: &TypeChecker) -> Option Vec { match &import.kind { ImportKind::From { module, items } => { @@ -368,6 +369,7 @@ fn checked_import_exports(import: &ImportDecl, checker: &TypeChecker) -> Vec, @@ -405,6 +407,7 @@ fn checked_alias_function_export(name: &str, info: &FunctionInfo) -> CheckedFunc } } +/// Return the checked export for a projected callable alias. fn checked_projected_function_export(name: &str, kind: &SymbolKind) -> Option { match kind { SymbolKind::Function(info) => Some(checked_alias_function_export(name, info)), diff --git a/src/frontend/testing_markers.rs b/src/frontend/testing_markers.rs index 626345ba4..c33327711 100644 --- a/src/frontend/testing_markers.rs +++ b/src/frontend/testing_markers.rs @@ -306,6 +306,7 @@ fn find_stdlib_file(relative: &str) -> Option { None } +/// Extract compile-time semantics from a testing marker expression. fn extract_testing_marker_semantics(program: &ast::Program) -> Result { let mut semantics = TestingMarkerSemantics::default(); let mut saw_markers = false; @@ -348,6 +349,7 @@ fn extract_testing_marker_semantics(program: &ast::Program) -> Result Result<(), TestingMarkerLoadError> { let expected_names = incan_core::lang::testing::RUNNER_ONLY_MARKER_NAMES; let mut missing = Vec::new(); diff --git a/src/frontend/typechecker/check_decl.rs b/src/frontend/typechecker/check_decl.rs index e06a4fbda..02cd138bd 100644 --- a/src/frontend/typechecker/check_decl.rs +++ b/src/frontend/typechecker/check_decl.rs @@ -39,6 +39,7 @@ fn property_infos_identical(a: &PropertyInfo, b: &PropertyInfo) -> bool { a.has_body == b.has_body && a.return_type == b.return_type } +/// Resolve the local type used for a checked function parameter. fn local_type_for_param(kind: ParamKind, ty: ResolvedType) -> ResolvedType { match kind { ParamKind::Normal => ty, @@ -774,6 +775,7 @@ impl TypeChecker { } } } + /// Render a named method signature for compatibility diagnostics. fn method_sig_string_named(&self, method_name: &str, m: &MethodInfo) -> String { let recv = match m.receiver { Some(Receiver::Mutable) => "mut self", @@ -798,6 +800,7 @@ impl TypeChecker { ) } + /// Return whether two method signatures are compatible. pub(in crate::frontend::typechecker) fn method_sigs_compatible( &self, expected: &MethodInfo, @@ -2253,6 +2256,7 @@ impl TypeChecker { } } + /// Typecheck an inline test module declaration. fn check_test_module(&mut self, test_module: &TestModuleDecl) { self.symbols.enter_scope(ScopeKind::Block); for decl in &test_module.body { @@ -2618,6 +2622,7 @@ impl TypeChecker { result } + /// Validate a model declaration that derives validation support. fn check_validate_derive_model(&mut self, model: &ModelDecl) { // Validate that validate() exists and has the expected signature. let Some(TypeInfo::Model(info)) = self.lookup_type_info(&model.name) else { diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 4593919ab..51e9164e9 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -130,6 +130,7 @@ impl TypeChecker { Some(RustCallableAliasSignature { params, return_ty }) } + /// Build the Rust trait-object type for a callable alias. fn rust_callable_trait_object(ty: &SynType) -> Option<&syn::TypeTraitObject> { match ty { SynType::TraitObject(trait_object) => Some(trait_object), @@ -149,6 +150,7 @@ impl TypeChecker { } } + /// Check a closure expression against a Rust callable alias. fn check_closure_with_rust_callable_alias( &mut self, expr: &Spanned, @@ -219,6 +221,7 @@ impl TypeChecker { closure_ty } + /// Check a method argument against a Rust callable alias. fn check_method_arg_with_rust_callable_alias( &mut self, arg: &CallArg, @@ -1271,6 +1274,7 @@ impl TypeChecker { } } + /// Validate a reflection magic-method call. fn validate_reflection_magic_call( &mut self, method: &str, @@ -1327,6 +1331,7 @@ impl TypeChecker { } } + /// Return the canonical Rust path for a receiver type. fn rust_canonical_path_for_receiver_type(&self, ty: &ResolvedType) -> Option { match ty { ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) => self.rust_canonical_path_for_receiver_type(inner), @@ -1339,6 +1344,7 @@ impl TypeChecker { } } + /// Return the canonical Rust path for a nominal receiver. fn rust_canonical_path_for_nominal_receiver( &self, name: &str, @@ -1862,6 +1868,7 @@ impl TypeChecker { } } + /// Return whether an active type parameter has a builtin bound. fn active_type_param_has_builtin_bound(&self, type_param: &str, trait_id: TraitId) -> bool { let expected = core_traits::as_str(trait_id); self.current_type_param_bound_details.iter().rev().any(|frame| { diff --git a/src/frontend/typechecker/check_expr/calls.rs b/src/frontend/typechecker/check_expr/calls.rs index 3a472f127..0f3f5a059 100644 --- a/src/frontend/typechecker/check_expr/calls.rs +++ b/src/frontend/typechecker/check_expr/calls.rs @@ -23,6 +23,7 @@ mod constructors; mod generic_bounds; mod rust_boundary; +/// Return whether the last Rust path segment looks like a type name. fn rust_path_last_segment_looks_like_type(path: &str) -> bool { path.rsplit("::") .next() diff --git a/src/frontend/typechecker/check_expr/calls/args.rs b/src/frontend/typechecker/check_expr/calls/args.rs index e33121e55..4d86f64b5 100644 --- a/src/frontend/typechecker/check_expr/calls/args.rs +++ b/src/frontend/typechecker/check_expr/calls/args.rs @@ -9,6 +9,7 @@ use crate::frontend::typechecker::helpers::{collection_type_id, dict_ty, list_ty use incan_core::lang::types::collections::CollectionTypeId; impl TypeChecker { + /// Return the expression carried by a call argument. pub(in crate::frontend::typechecker::check_expr::calls) fn call_arg_expr(arg: &CallArg) -> &Spanned { match arg { CallArg::Positional(e) diff --git a/src/frontend/typechecker/check_expr/calls/builtins.rs b/src/frontend/typechecker/check_expr/calls/builtins.rs index f1870b73c..b09e0bee5 100644 --- a/src/frontend/typechecker/check_expr/calls/builtins.rs +++ b/src/frontend/typechecker/check_expr/calls/builtins.rs @@ -55,6 +55,7 @@ impl TypeChecker { .unwrap_or(ResolvedType::Unknown) } + /// Validate call arity for a stdlib module helper. fn validate_stdlib_module_call_arity( &mut self, callable: &str, diff --git a/src/frontend/typechecker/check_expr/calls/constructors.rs b/src/frontend/typechecker/check_expr/calls/constructors.rs index c71c5a158..70a8930ce 100644 --- a/src/frontend/typechecker/check_expr/calls/constructors.rs +++ b/src/frontend/typechecker/check_expr/calls/constructors.rs @@ -142,6 +142,7 @@ impl TypeChecker { ResolvedType::Generic(surface_types::as_str(tid).to_string(), vec![inner]) } + /// Return whether a call target names an enum type. pub(in crate::frontend::typechecker::check_expr::calls) fn is_enum_type_name_expr_for_call( &self, expr: &Spanned, @@ -217,6 +218,7 @@ impl TypeChecker { } value_enum.value_type.resolved_type() } + /// Return the result type produced by a constructor call. pub(in crate::frontend::typechecker::check_expr::calls) fn constructor_result_type( &self, name: &str, diff --git a/src/frontend/typechecker/check_expr/calls/generic_bounds.rs b/src/frontend/typechecker/check_expr/calls/generic_bounds.rs index eca2eaa11..cf96f9c71 100644 --- a/src/frontend/typechecker/check_expr/calls/generic_bounds.rs +++ b/src/frontend/typechecker/check_expr/calls/generic_bounds.rs @@ -85,6 +85,7 @@ impl TypeChecker { substitute_resolved_type(&info.return_type, &type_bindings) } + /// Assert that call-site type parameters have been inferred. fn assert_call_site_type_params_inferred( &mut self, callee: &str, diff --git a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs index 6b674052b..dfa21b809 100644 --- a/src/frontend/typechecker/check_expr/calls/rust_boundary.rs +++ b/src/frontend/typechecker/check_expr/calls/rust_boundary.rs @@ -132,6 +132,7 @@ impl TypeChecker { ); } + /// Return how a rusttype boundary matches an argument type. fn rusttype_boundary_match(&self, arg_ty: &ResolvedType, target_ty: &ResolvedType) -> Option { if let ResolvedType::Named(type_name) = arg_ty && let Some(TypeInfo::Newtype(newtype)) = self.lookup_type_info(type_name) @@ -159,6 +160,7 @@ impl TypeChecker { None } + /// Return whether a Rust type display names a generic type parameter. fn is_rust_generic_type_param_display(rust_ty: &str) -> bool { let normalized = rust_ty.trim().replace(' ', ""); let mut chars = normalized.chars(); @@ -409,6 +411,7 @@ impl TypeChecker { } } + /// Bind Incan call arguments to a Rust function signature. fn bind_rust_call_args<'a>( &mut self, callable_display: &str, @@ -544,6 +547,7 @@ impl TypeChecker { } } + /// Return whether an argument can cross a Rust boundary. #[cfg(test)] pub(in crate::frontend::typechecker) fn rust_arg_matches_boundary( &self, diff --git a/src/frontend/typechecker/check_expr/mod.rs b/src/frontend/typechecker/check_expr/mod.rs index 9473dac5c..ffe454417 100644 --- a/src/frontend/typechecker/check_expr/mod.rs +++ b/src/frontend/typechecker/check_expr/mod.rs @@ -141,6 +141,7 @@ impl TypeChecker { None } + /// Convert function symbol information into a resolved function type. fn function_info_to_resolved_function_type(info: &FunctionInfo) -> ResolvedType { ResolvedType::Function(info.params.clone(), Box::new(info.return_type.clone())) } @@ -244,6 +245,7 @@ impl TypeChecker { ty } + /// Return whether a span identifies a type receiver. pub(crate) fn is_type_receiver_span(&self, span: Span) -> bool { self.type_receiver_spans .iter() diff --git a/src/frontend/typechecker/check_stmt.rs b/src/frontend/typechecker/check_stmt.rs index 02f68d622..73f396ac0 100644 --- a/src/frontend/typechecker/check_stmt.rs +++ b/src/frontend/typechecker/check_stmt.rs @@ -731,6 +731,7 @@ impl TypeChecker { self.consumed_iterator_bindings.remove(&assign.name); } + /// Check a return statement against the active function context. fn check_return(&mut self, expr: Option<&Spanned>, span: Span) { if matches!(self.current_yield_context, super::YieldContext::Generator { .. }) { if let Some(expr) = expr { @@ -1202,6 +1203,7 @@ impl TypeChecker { } } + /// Check an assert statement. fn check_assert_stmt(&mut self, assert_stmt: &AssertStmt) { match &assert_stmt.kind { AssertKind::Condition(condition) => { @@ -1291,6 +1293,7 @@ impl TypeChecker { } } + /// Build an assertion pattern from a parsed pattern. fn assert_is_pattern_from_pattern(pattern: &Spanned) -> Option { match &pattern.node { Pattern::Constructor(name, args) diff --git a/src/frontend/typechecker/collect/decl_helpers.rs b/src/frontend/typechecker/collect/decl_helpers.rs index 2165307f4..134d14e1e 100644 --- a/src/frontend/typechecker/collect/decl_helpers.rs +++ b/src/frontend/typechecker/collect/decl_helpers.rs @@ -106,6 +106,7 @@ fn resolve_owner_self_reference( } } +/// Return declared type parameter names as a set. pub(super) fn type_param_name_set( owner_type_params: &[TypeParam], method_type_params: &[TypeParam], @@ -117,6 +118,7 @@ pub(super) fn type_param_name_set( .collect() } +/// Remove type parameters shadowed by inner declarations. fn shadow_declared_type_params(ty: ResolvedType, type_param_names: &HashSet) -> ResolvedType { match ty { ResolvedType::Named(name) | ResolvedType::TypeVar(name) if type_param_names.contains(&name) => { @@ -164,6 +166,7 @@ fn shadow_declared_type_params(ty: ResolvedType, type_param_names: &HashSet, diff --git a/src/frontend/typechecker/collect/decorators.rs b/src/frontend/typechecker/collect/decorators.rs index 1d8f00038..7d7a9c1e7 100644 --- a/src/frontend/typechecker/collect/decorators.rs +++ b/src/frontend/typechecker/collect/decorators.rs @@ -398,6 +398,7 @@ impl TypeChecker { !is_stdlib_decorator_function } + /// Resolve a decorator identifier through import aliases. fn decorator_id_with_import_aliases(&self, dec: &Decorator) -> Option { let resolved = resolve_decorator_path(dec, &self.symbols); if let Some(id) = decorators::from_segments(&resolved) { @@ -408,6 +409,7 @@ impl TypeChecker { decorators::from_segments(&alias_resolved) } + /// Validate one lint name passed to `@rust.allow`. fn validate_single_rust_allow_lint(&mut self, name: &str, span: Span, seen: &mut HashSet) { if name.is_empty() || name.trim() != name || !Self::is_valid_rust_lint_path(name) { self.errors.push(errors::rust_allow_invalid_lint_name(name, span)); @@ -424,10 +426,12 @@ impl TypeChecker { } } + /// Return whether a Rust lint path has valid syntax. fn is_valid_rust_lint_path(name: &str) -> bool { name.split("::").all(Self::is_valid_rust_lint_segment) } + /// Return whether one Rust lint path segment is valid. fn is_valid_rust_lint_segment(segment: &str) -> bool { let mut chars = segment.chars(); let Some(first) = chars.next() else { @@ -436,6 +440,7 @@ impl TypeChecker { (first == '_' || first.is_ascii_alphabetic()) && chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) } + /// Return whether a Rust lint group is too broad for `@rust.allow`. fn is_broad_rust_lint_group(name: &str) -> bool { matches!( name, diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index f5927defc..17655df9b 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -1517,6 +1517,7 @@ impl TypeChecker { } } + /// Convert manifest parameters into checked callable parameters. fn params_from_manifest(&self, params: &[ParamExport]) -> Vec { params .iter() @@ -1723,6 +1724,7 @@ impl TypeChecker { } } +/// Convert a manifest parameter kind into a checked parameter kind. fn param_kind_from_manifest(kind: ParamKindExport) -> ParamKind { match kind { ParamKindExport::Normal => ParamKind::Normal, diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index d06b4ddae..5c85f13ad 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -510,6 +510,7 @@ impl TypeChecker { self.rust_item_metadata_for_path(canonical_path) } + /// Split top-level generic arguments from a Rust display type. fn split_top_level_generic_args(args: &str) -> Vec<&str> { split_top_level_rust_args(args) } @@ -588,6 +589,7 @@ impl TypeChecker { rendered } + /// Return the Rust definition metadata for a canonical path. fn rust_definition_for_path(&self, canonical_path: &str) -> Option { let canonical_path = Self::normalize_rust_namespace_path(canonical_path); if let Some(definition) = self.symbols.all_symbols().iter().find_map(|sym| { @@ -663,6 +665,7 @@ impl TypeChecker { Some(format!("{base}<{rendered_args}>")) } + /// Return whether two Rust type identities describe the same boundary type. fn rust_type_identities_compatible(&self, actual: &ResolvedType, expected: &ResolvedType) -> Option { if let ResolvedType::Ref(inner) | ResolvedType::RefMut(inner) = expected && let Some(matches) = self.rust_type_identities_compatible(actual, inner) @@ -687,6 +690,7 @@ impl TypeChecker { Some(self.rust_type_args_compatible(actual_args.as_slice(), expected_args.as_slice())) } + /// Return whether Rust generic type arguments are compatible. fn rust_type_args_compatible(&self, actual_args: &[ResolvedType], expected_args: &[ResolvedType]) -> bool { if actual_args.len() != expected_args.len() { return (actual_args.is_empty() && expected_args.iter().all(Self::rust_type_arg_is_unknown_placeholder)) @@ -699,6 +703,7 @@ impl TypeChecker { }) } + /// Return whether a Rust type argument is an unknown placeholder. fn rust_type_arg_is_unknown_placeholder(arg: &ResolvedType) -> bool { match arg { ResolvedType::Unknown => true, @@ -839,6 +844,7 @@ impl TypeChecker { strip_rust_borrow_lifetimes(rust_ty) } + /// Return Rust display text with lifetime parameters removed. fn rust_display_without_lifetimes(rust_ty: &str) -> String { Self::strip_borrow_lifetimes(rust_ty) .replace("'static ", "") @@ -847,10 +853,12 @@ impl TypeChecker { .to_string() } + /// Return compact Rust display text for comparison. fn compact_rust_display(rust_ty: &str) -> String { Self::rust_display_without_lifetimes(rust_ty).replace(' ', "") } + /// Split a Rust generic display type into base and arguments. fn rust_generic_base_and_args(normalized: &str) -> Option<(&str, Vec<&str>)> { let start = normalized.find('<')?; if !normalized.ends_with('>') { @@ -861,10 +869,12 @@ impl TypeChecker { Some((base, Self::split_top_level_generic_args(inner))) } + /// Build a Rust collection identity from a display-type base. fn rust_collection_id_from_display_base(base: &str) -> Option { incan_core::lang::types::collections::from_rust_display_base(base) } + /// Return the resolved Rust display type for a structural parameter. fn resolved_structural_rust_param_display(&self, normalized: &str, mut resolve_arg: F) -> Option where F: FnMut(&Self, &str) -> ResolvedType, @@ -2527,6 +2537,7 @@ impl TypeChecker { } } + /// Collect static writes performed inside initializer conditions. fn collect_static_initializer_static_writes_from_condition( &mut self, condition: &Condition, @@ -2650,6 +2661,7 @@ impl TypeChecker { } } + /// Collect static dependencies referenced by a condition expression. fn collect_static_dependencies_from_condition( &self, condition: &Condition, @@ -3675,6 +3687,7 @@ impl TypeChecker { } } + /// Return whether a module path names generated stdlib dependency code. fn is_generated_stdlib_dependency_module(module_name: &str) -> bool { module_name == incan_core::lang::stdlib::INCAN_STD_NAMESPACE || module_name diff --git a/src/frontend/typechecker/trait_bound_relations.rs b/src/frontend/typechecker/trait_bound_relations.rs index 8e36d46b4..338f063f1 100644 --- a/src/frontend/typechecker/trait_bound_relations.rs +++ b/src/frontend/typechecker/trait_bound_relations.rs @@ -575,6 +575,7 @@ impl TypeChecker { ) } + /// Return whether a tuple type satisfies a trait bound. fn tuple_type_satisfies_bound(&self, items: &[ResolvedType], bound: &str) -> bool { match builtin_traits::from_str(bound) { Some( @@ -591,6 +592,7 @@ impl TypeChecker { } } + /// Return whether a collection type satisfies a trait bound. fn collection_type_satisfies_bound(&self, kind: CollectionTypeId, args: &[ResolvedType], bound: &str) -> bool { let all_args_satisfy = || args.iter().all(|arg| self.type_satisfies_explicit_bound(arg, bound)); match builtin_traits::from_str(bound) { diff --git a/src/frontend/vocab_ast_bridge.rs b/src/frontend/vocab_ast_bridge.rs index e1664b4c1..59014019e 100644 --- a/src/frontend/vocab_ast_bridge.rs +++ b/src/frontend/vocab_ast_bridge.rs @@ -414,6 +414,7 @@ pub fn public_expression_to_internal(expr: &incan_vocab::IncanExpr) -> Result Result { diff --git a/src/library_manifest/model.rs b/src/library_manifest/model.rs index f68e1d8ce..c5df2b7c5 100644 --- a/src/library_manifest/model.rs +++ b/src/library_manifest/model.rs @@ -775,6 +775,7 @@ fn type_bound_from_checked(bound: &CheckedTypeBound) -> TypeBoundExport { } } +/// Convert checked callable parameters into library-manifest parameter records. fn params_from_checked(params: &[CallableParam]) -> Vec { params .iter() @@ -789,6 +790,7 @@ fn params_from_checked(params: &[CallableParam]) -> Vec { .collect() } +/// Convert an AST parameter kind into a library-manifest parameter kind. fn param_kind_from_ast(kind: crate::frontend::ast::ParamKind) -> ParamKindExport { match kind { crate::frontend::ast::ParamKind::Normal => ParamKindExport::Normal, diff --git a/src/lockfile.rs b/src/lockfile.rs index 83161a337..cee351796 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -276,6 +276,7 @@ fn source_fingerprint(source: &DependencySource, project_root: Option<&Path>) -> } } +/// Normalize a relative path before adding it to a lockfile fingerprint. fn normalize_relative_path_for_fingerprint(path: &Path) -> PathBuf { if path.is_absolute() { return path.to_path_buf(); diff --git a/src/lsp/backend.rs b/src/lsp/backend.rs index dbb7f7092..af8b4eeb7 100644 --- a/src/lsp/backend.rs +++ b/src/lsp/backend.rs @@ -2776,10 +2776,12 @@ fn inline_code(value: &str) -> String { format!("{fence} {value} {fence}") } +/// Collect import aliases visible to LSP decorator resolution. fn collect_import_aliases(ast: &Program) -> HashMap> { crate::frontend::decorator_resolution::collect_import_aliases(ast) } +/// Resolve a decorator path through visible import aliases. fn resolve_decorator_path( dec: &crate::frontend::ast::Decorator, aliases: &HashMap>, @@ -3152,6 +3154,7 @@ fn unchecked_lookup_hover(source: &str, value_types: &[ValueTypeFact], ident: &s )) } +/// Return the LSP source location for a stdlib import path. fn stdlib_location_for_path(path: &[String]) -> Option { let stub_rel = stdlib::stdlib_stub_path(path)?; let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -3166,6 +3169,7 @@ fn stdlib_location_for_path(path: &[String]) -> Option { }) } +/// Find the import path that exposes a stdlib source location. fn find_stdlib_import_path(ast: &Program, offset: usize) -> Option> { for decl in &ast.declarations { let Declaration::Import(import) = &decl.node else { diff --git a/src/lsp/call_site_type_args.rs b/src/lsp/call_site_type_args.rs index 64331748e..476864ef4 100644 --- a/src/lsp/call_site_type_args.rs +++ b/src/lsp/call_site_type_args.rs @@ -129,6 +129,7 @@ fn scan_types_in_call(type_args: &[Spanned], offset: usize) -> Option<&Spa None } +/// Scan call arguments for LSP call-site type argument hints. fn scan_call_args(args: &[CallArg], offset: usize) -> Option<&Spanned> { for a in args { match a { @@ -304,6 +305,7 @@ fn call_site_types_in_stmts(stmts: &[Spanned], offset: usize) -> Opti None } +/// Find call-site type argument opportunities inside a statement. fn call_site_type_in_stmt(stmt: &Statement, offset: usize) -> Option<&Spanned> { match stmt { Statement::Assignment(a) => call_site_type_in_expr(&a.value, offset), @@ -351,6 +353,7 @@ fn call_site_type_in_stmt(stmt: &Statement, offset: usize) -> Option<&Spanned ((F) -> F): + return (func) => func + +@preserve() +pub def decorated_total(first: int, second: int, *rest: int, **labels: str) -> int: + mut total: int = first + second + for value in rest: + total = total + value + if labels["mode"] == "sum": + return total + return -1 + +def main() -> int: + return decorated_total(1, 2, 3, 4, mode="sum") diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 87dd9cddf..201b129b9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2906,6 +2906,112 @@ def main() -> None: Ok(()) } + #[test] + fn test_decorated_variadic_callables_compile_and_run() -> Result<(), Box> { + let output = incan_command() + .args([ + "run", + "-c", + r#" +def preserve[F]() -> ((F) -> F): + return (func) => func + +@preserve() +pub def decorated_total(first: int, second: int, *rest: int, **labels: str) -> int: + mut total: int = first + second + for value in rest: + total = total + value + if labels["mode"] == "sum": + return total + return -1 + +class Box: + base: int + + @preserve() + def total(self, first: int, *rest: int, **labels: str) -> int: + mut total: int = self.base + first + for value in rest: + total = total + value + if labels["mode"] == "sum": + return total + return -1 + +def main() -> None: + box = Box(base=5) + println(decorated_total(1, 2, 3, 4, mode="sum") + box.total(6, 7, 8, mode="sum")) +"#, + ]) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "decorated variadic callable regression failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = strip_ansi_escapes(&String::from_utf8_lossy(&output.stdout)); + let lines: Vec<&str> = stdout.lines().map(str::trim).filter(|line| !line.is_empty()).collect(); + assert_eq!(lines, vec!["36"], "unexpected decorated variadic output:\n{stdout}"); + Ok(()) + } + + #[test] + fn test_decorated_variadic_library_builds() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let root = tmp.path(); + fs::create_dir_all(root.join("src"))?; + fs::write( + root.join("incan.toml"), + "[project]\nname = \"decorated_rest_lib\"\nversion = \"0.1.0\"\n", + )?; + fs::write( + root.join("src/lib.incn"), + r#" +def preserve[F]() -> ((F) -> F): + return (func) => func + +@preserve() +pub def decorated_total(first: int, second: int, *rest: int, **labels: str) -> int: + mut total: int = first + second + for value in rest: + total = total + value + if labels["mode"] == "sum": + return total + return -1 + +pub class Box: + base: int + + @preserve() + def total(self, first: int, *rest: int, **labels: str) -> int: + mut total: int = self.base + first + for value in rest: + total = total + value + if labels["mode"] == "sum": + return total + return -1 +"#, + )?; + + let mut command = incan_command(); + let output = command + .args(["build", "--lib"]) + .current_dir(root) + .env("CARGO_NET_OFFLINE", "true") + .output()?; + assert!( + output.status.success(), + "decorated variadic library build failed: status={:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn test_string_and_bytes_iteration_compile_and_run() -> Result<(), Box> { let output = run_incan_source( diff --git a/tests/snapshots/codegen_snapshot_tests__decorated_variadic_function.snap b/tests/snapshots/codegen_snapshot_tests__decorated_variadic_function.snap new file mode 100644 index 000000000..98b3310a3 --- /dev/null +++ b/tests/snapshots/codegen_snapshot_tests__decorated_variadic_function.snap @@ -0,0 +1,117 @@ +--- +source: tests/codegen_snapshot_tests.rs +expression: rust_code +--- +// Generated by the Incan compiler v + +// __INCAN_INSERT_MODS__ + +incan_stdlib::__incan_stdlib_version_check!(""); +#[inline(always)] +pub(crate) fn __incan_init_module_statics() { + static __INCAN_STATIC_INIT_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new( + false, + ); + if __INCAN_STATIC_INIT_RUNNING.load(std::sync::atomic::Ordering::Acquire) { + return; + } + static __INCAN_STATIC_INIT_ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + __INCAN_STATIC_INIT_ONCE + .get_or_init(|| { + struct __IncanStaticInitGuard<'a>(&'a std::sync::atomic::AtomicBool); + impl Drop for __IncanStaticInitGuard<'_> { + fn drop(&mut self) { + self.0.store(false, std::sync::atomic::Ordering::Release); + } + } + __INCAN_STATIC_INIT_RUNNING + .store(true, std::sync::atomic::Ordering::Release); + let _guard = __IncanStaticInitGuard(&__INCAN_STATIC_INIT_RUNNING); + std::sync::LazyLock::force(&__INCAN_DECORATED_DECORATED_TOTAL); + }); +} +fn preserve() -> fn(F) -> F { + __incan_init_module_statics(); + return |func| func; +} +fn __incan_original_decorated_total( + first: i64, + second: i64, + rest: Vec, + labels: std::collections::HashMap, +) -> i64 { + __incan_init_module_statics(); + let mut total: i64 = first + second; + for value in rest.iter().copied() { + total = total + value; + } + if incan_stdlib::strings::str_eq( + &incan_stdlib::collections::dict_get(&labels, <_ as AsRef>::as_ref(&"mode")) + .clone(), + &"sum", + ) { + return total; + } + return -1; +} +static __INCAN_DECORATED_DECORATED_TOTAL: std::sync::LazyLock< + incan_stdlib::storage::StaticCell< + fn(i64, i64, Vec, std::collections::HashMap) -> i64, + >, +> = std::sync::LazyLock::new(|| incan_stdlib::storage::StaticCell::new( + preserve()(__incan_original_decorated_total), +)); +pub fn decorated_total( + first: i64, + second: i64, + rest: Vec, + labels: std::collections::HashMap, +) -> i64 { + __incan_init_module_statics(); + return { + __incan_init_module_statics(); + __INCAN_DECORATED_DECORATED_TOTAL.get() + }( + first, + second, + { + let mut __incan_rest_args = Vec::new(); + __incan_rest_args.extend(rest); + __incan_rest_args + }, + { + let mut __incan_rest_kwargs = std::collections::HashMap::new(); + __incan_rest_kwargs.extend(labels); + __incan_rest_kwargs + }, + ); +} +fn main() { + __incan_init_module_statics(); + std::panic::set_hook( + std::boxed::Box::new(|panic_info| { + if let Some(message) = panic_info.payload().downcast_ref::<&str>() { + eprintln!("{message}"); + } else if let Some(message) = panic_info.payload().downcast_ref::() { + eprintln!("{message}"); + } else { + eprintln!("generated program panicked"); + } + }), + ); + return decorated_total( + 1, + 2, + { + let mut __incan_rest_args = Vec::new(); + __incan_rest_args.push(3); + __incan_rest_args.push(4); + __incan_rest_args + }, + { + let mut __incan_rest_kwargs = std::collections::HashMap::new(); + __incan_rest_kwargs.insert("mode".to_string(), "sum".to_string()); + __incan_rest_kwargs + }, + ); +} diff --git a/workspaces/docs-site/docs/language/how-to/rust_interop.md b/workspaces/docs-site/docs/language/how-to/rust_interop.md index ad96bad9b..9f719c2cf 100644 --- a/workspaces/docs-site/docs/language/how-to/rust_interop.md +++ b/workspaces/docs-site/docs/language/how-to/rust_interop.md @@ -513,8 +513,7 @@ Direct `list[T]` arguments lower to Rust `Vec`. At external Rust call boundar ## Understanding Rust types (optional) ??? tip "Coming from Python?" - If you're new to Rust types like `Vec`, `HashMap`, `String`, `Option`, and `Result`, see - [Understanding Rust types (coming from Python)](rust_types_for_python_devs.md). + If you're new to Rust types like `Vec`, `HashMap`, `String`, `Option`, and `Result`, see [Understanding Rust types (coming from Python)](rust_types_for_python_devs.md). ### Matching on Rust-backed enums and oneofs diff --git a/workspaces/docs-site/docs/language/reference/stdlib/index.md b/workspaces/docs-site/docs/language/reference/stdlib/index.md index 9c7ee5025..2bf3dfb78 100644 --- a/workspaces/docs-site/docs/language/reference/stdlib/index.md +++ b/workspaces/docs-site/docs/language/reference/stdlib/index.md @@ -18,6 +18,7 @@ Pages in this section are curated and checked into the repository. - [`std.io`](io.md) (curated) - [`std.json`](json.md) (curated) - [`std.logging`](logging.md) (curated; see also [how-to](../../how-to/logging.md)) +- [`std.telemetry`](telemetry.md) (curated) - [`std.regex`](regex.md) (curated; see also [how-to](../../how-to/regular_expressions.md)) - [`std.uuid`](uuid.md) (curated; see also [how-to](../../how-to/working_with_uuids.md)) - [`std.tempfile`](tempfile.md) (curated; see also [file I/O how-to](../../how-to/file_io.md)) diff --git a/workspaces/docs-site/docs/language/reference/stdlib/telemetry.md b/workspaces/docs-site/docs/language/reference/stdlib/telemetry.md new file mode 100644 index 000000000..e2273d894 --- /dev/null +++ b/workspaces/docs-site/docs/language/reference/stdlib/telemetry.md @@ -0,0 +1,61 @@ +# `std.telemetry` reference + +`std.telemetry` provides pure data-model types for observability-facing stdlib modules. Importing it does not configure exporters, install providers, start background tasks, or capture runtime context. + +## Imports + +```incan +from std.telemetry import Attributes, InstrumentationScope, Resource, SpanContext, TelemetryValue +from std.telemetry.core import TraceFlags, TraceId, SpanId, Timestamp +``` + +Use the top-level `std.telemetry` prelude for ordinary data-model imports. Use `std.telemetry.core` when code should make the internal layering explicit or avoid prelude-style imports. + +## `TelemetryValue` + +`TelemetryValue` carries structured values across logging and future telemetry boundaries without forcing callers to stringify nested data. + +| API | Returns | Description | +| --- | --- | --- | +| `TelemetryValue.none()` | `TelemetryValue` | Null telemetry value. | +| `TelemetryValue.string(value: str)` | `TelemetryValue` | String value. | +| `TelemetryValue.bool(value: bool)` | `TelemetryValue` | Boolean value. | +| `TelemetryValue.int(value: int)` | `TelemetryValue` | Integer value. | +| `TelemetryValue.float(value: float)` | `TelemetryValue` | Floating-point value. | +| `TelemetryValue.bytes(value: str)` | `TelemetryValue` | Encoded byte value; the caller owns the encoding convention. | +| `TelemetryValue.array(values: list[TelemetryValue])` | `TelemetryValue` | Nested telemetry array. | +| `TelemetryValue.map(values: Dict[str, TelemetryValue])` | `TelemetryValue` | Nested telemetry map. | +| `value.display_text()` | `str` | Human-oriented text; strings render directly and structured values render as JSON. | + +The `TelemetryValueKind` enum uses stable string values: `NONE`, `STRING`, `BOOL`, `INT`, `FLOAT`, `BYTES`, `ARRAY`, and `MAP`. + +## Attributes + +| API | Returns | Description | +| --- | --- | --- | +| `Attributes(fields)` | `Attributes` | Newtype wrapper around `Dict[str, TelemetryValue]`. | +| `Attributes.from_string_fields(fields: Dict[str, str])` | `Attributes` | Convert ordinary string fields into structured telemetry attributes. | + +Attribute keys may use OpenTelemetry semantic-convention names such as `service.name`, `http.request.method`, or `gen_ai.request.model`. Values remain structured through the data-model boundary so logging and future telemetry exporters can decide how to render them. + +## Resource, Scope, And Context + +| Type | Description | +| --- | --- | +| `Timestamp` | RFC 3339-style timestamp string newtype used by records that already have a time value. | +| `Resource` | Entity that produced telemetry, currently represented as structured attributes. | +| `InstrumentationScope` | Logical scope name, optional version, and optional schema URL for the code that emitted telemetry. | +| `TraceId` | W3C/OpenTelemetry trace-id string newtype. | +| `SpanId` | W3C/OpenTelemetry span-id string newtype. | +| `TraceFlags` | W3C trace-flags string newtype. | +| `SpanContext` | Serializable grouping of trace id, span id, and optional flags. | + +These types are inert data holders in 0.3. They preserve identifiers and attributes when another stdlib module, such as `std.logging`, already has structured observability data to carry. + +## Boundaries + +`std.telemetry` is not a provider API in 0.3. It does not sample spans, manage active context, export metrics, configure OpenTelemetry SDKs, or read process resource attributes. Those behaviors belong to a future telemetry provider surface; this module only provides the shared typed payload shape. + +## See also + +- [`std.logging`](logging.md) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index c5b250194..17c7f7610 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -33,6 +33,7 @@ Use this section as the map. The release note names each larger feature, says wh ### Language features - **Numeric types and fixed-scale decimals**: Use exact widths and schema-shaped names when a boundary needs them, while keeping `int` and `float` ergonomic for ordinary code. Start with [Choosing numeric types](../language/how-to/choosing_numeric_types.md), then [Numeric semantics](../language/reference/numeric_semantics.md) ([RFC 009], #325). +- **Validated newtypes and checked coercion**: Move primitive invariants into named source types with checked construction, optional implicit coercion, and explicit opt-out when an API should require construction at the boundary. Read [Newtypes](../language/reference/newtypes.md) and [Book: newtypes](../language/tutorials/book/12_newtypes.md) ([RFC 017]). - **Loop expressions**: Use `loop:` plus `break ` for search, retry, and accumulator-free loops that produce a value. Read [Control Flow](../language/explanation/control_flow.md) ([RFC 016], #327, #387). - **Pattern control flow**: Use `if let` and `while let` when one successful pattern should run and the miss case should do nothing. Read [Control Flow](../language/explanation/control_flow.md) ([RFC 049], #333). - **Union narrowing and pattern alternation**: Model inputs that can take several shapes, then narrow them with checked patterns instead of hand-written tag logic. Read [Union types](../language/reference/union_types.md) ([RFC 071], [RFC 029]). @@ -45,11 +46,13 @@ Use this section as the map. The release note names each larger feature, says wh - **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). - **Variadics and call unpacking**: Describe call shapes that accept or forward flexible argument lists without losing static checks. Read [Functions](../language/reference/functions.md) ([RFC 038], #83). - **Generators and lazy iterators**: Build pipelines with generator values, lazy adapters, and explicit terminal consumers such as `collect`, `count`, `find`, and `fold`. Read [Generators](../language/how-to/generators.md) and [Generator semantics](../language/explanation/generators.md) ([RFC 006], [RFC 088], #127, #324, #386). +- **Scoped DSL surfaces**: Let vocab crates activate scoped block, clause, glyph, leading-dot, and symbol syntax for their own DSL contexts instead of turning library-specific syntax into global parser behavior. Read [Author library DSLs with incan_vocab](../contributing/how-to/authoring_vocab_crates.md) ([RFC 040], [RFC 045]). ### Rust interop and API metadata - **Rust trait adoption from Incan source**: Newtypes and rusttypes can adopt Rust traits with `with Trait`, method-level `for Trait`, and associated type declarations. Read [Rust interop](../language/how-to/rust_interop.md), [Rust types for Python developers](../language/how-to/rust_types_for_python_devs.md), and [`std.traits`](../language/reference/stdlib/traits.md) ([RFC 043], #200). - **Derived and inspected Rust metadata**: Supported `@rust.derive(...)`, associated types, inspected Rust signatures, and metadata-backed call boundaries now survive further through generated calls. Read [Derives and traits](../language/explanation/derives_and_traits.md) and [Rust-shaped confidence](../language/explanation/rust_shaped_confidence.md) ([RFC 041], #175). +- **Targeted generated-Rust lint suppression**: Use `@rust.allow(...)` when source semantics intentionally require a narrow generated-Rust lint allowance, without turning off warnings for whole projects or generated modules. Read [Rust interop](../language/how-to/rust_interop.md#targeted-generated-rust-lint-suppression) ([RFC 057]). - **Rust imported calls follow Incan argument binding**: Imported Rust free functions can use keyword arguments when inspected or shipped Rust metadata provides parameter names; codegen lowers those calls to the positional Rust call shape (#718). - **Checked public API metadata**: Public declarations, aliases, partials, models, enum variants, decorator-backed callable context, and facade alias projections can be emitted for tools and downstream consumers. Read [Checked API metadata](../tooling/reference/checked_api_metadata.md) and [LSP protocol support](../tooling/reference/lsp_protocol_support.md) ([RFC 048], #205, #438, #694, #695). @@ -67,6 +70,7 @@ Use this section as the map. The release note names each larger feature, says wh - **[`std.io`](../language/reference/stdlib/io.md)**: In-memory byte streams and buffered readers cover byte-oriented I/O without direct Rust interop ([RFC 056]). - **[`std.json`](../language/reference/stdlib/json.md)**: `JsonValue` supports dynamic payload construction, inspection, conversion, and extraction at API boundaries; see [Dynamic JSON](../language/how-to/dynamic_json.md) ([RFC 051]). - **[`std.logging`](../language/reference/stdlib/logging.md)**: Structured logging gives modules stable logger names, levels, fields, and runtime-friendly generated Rust output; see [Logging](../language/how-to/logging.md) ([RFC 072]). +- **[`std.telemetry`](../language/reference/stdlib/telemetry.md)**: Pure telemetry data types carry structured attributes, resources, scopes, and trace context identifiers without configuring exporters or background providers ([RFC 072]). - **[`std.regex`](../language/reference/stdlib/regex.md)**: Safe-default regular expressions cover matching, captures, iteration, splitting, and replacement without backtracking-only features; see [Regular expressions](../language/how-to/regular_expressions.md) ([RFC 059]). - **[`std.result`](../language/reference/stdlib/result.md)**: `Result[T, E]` gained Rust-shaped `map`, `map_err`, `and_then`, `or_else`, `inspect`, and `inspect_err` helpers for fallible pipelines ([RFC 070], #386). - **[`std.tempfile`](../language/reference/stdlib/tempfile.md)**: Scoped temporary files and directories are first-party test and application resources ([RFC 010]). diff --git a/workspaces/docs-site/docs/release_notes/index.md b/workspaces/docs-site/docs/release_notes/index.md index 68d17a345..3128d4c22 100644 --- a/workspaces/docs-site/docs/release_notes/index.md +++ b/workspaces/docs-site/docs/release_notes/index.md @@ -9,7 +9,7 @@ This section tracks user-facing changes in Incan across releases. ## Releases -- [Release 0.3 (dev)](0_3.md) +- [Release 0.3](0_3.md) - [Release 0.2](0_2.md) - [Release 0.1](0_1.md) diff --git a/workspaces/docs-site/docs/tooling/tutorials/your_first_project.md b/workspaces/docs-site/docs/tooling/tutorials/your_first_project.md index 023cd749a..9537dd933 100644 --- a/workspaces/docs-site/docs/tooling/tutorials/your_first_project.md +++ b/workspaces/docs-site/docs/tooling/tutorials/your_first_project.md @@ -212,8 +212,7 @@ test_main.incn::test_farewell PASSED ``` !!! tip "Test discovery" - Test files are found by name (`test_*.incn`) and test functions by name (`def test_*()`). - See: [Testing](../how-to/testing.md). + Test files are found by name (`test_*.incn`) and test functions by name (`def test_*()`). See: [Testing](../how-to/testing.md). ## Your final project layout diff --git a/workspaces/docs-site/mkdocs.yml b/workspaces/docs-site/mkdocs.yml index 6526e50e7..618c8c325 100644 --- a/workspaces/docs-site/mkdocs.yml +++ b/workspaces/docs-site/mkdocs.yml @@ -179,6 +179,7 @@ nav: - std.io: language/reference/stdlib/io.md - std.json: language/reference/stdlib/json.md - std.logging: language/reference/stdlib/logging.md + - std.telemetry: language/reference/stdlib/telemetry.md - std.regex: language/reference/stdlib/regex.md - std.uuid: language/reference/stdlib/uuid.md - std.tempfile: language/reference/stdlib/tempfile.md From da97c0061e7695e3375c11f67cb9fe66f90209b4 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 31 May 2026 07:27:22 +0200 Subject: [PATCH 51/58] bugfix - preserve chained Rust callback alias context (#708) (#722) --- Cargo.lock | 18 ++--- Cargo.toml | 2 +- src/cli/commands/build.rs | 17 ++++- src/frontend/typechecker/check_expr/access.rs | 74 ++++++++++++++----- tests/cli_integration.rs | 46 +++++++++++- .../docs-site/docs/release_notes/0_3.md | 2 +- 6 files changed, 125 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fcdd73a2..d729da3aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc29" +version = "0.3.0-rc30" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 69a2ca4b5..208ee2b9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc29" +version = "0.3.0-rc30" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/cli/commands/build.rs b/src/cli/commands/build.rs index a91cdcc34..a51b8e8e0 100644 --- a/src/cli/commands/build.rs +++ b/src/cli/commands/build.rs @@ -1122,10 +1122,11 @@ mod tests { let tmp = tempfile::tempdir()?; let project_root = tmp.path(); let scripts_dir = project_root.join("scripts"); + let declared_unused_rust_dependencies = ["itoa", "ryu"]; std::fs::create_dir_all(&scripts_dir)?; std::fs::write( project_root.join("incan.toml"), - "[project]\nname = \"unused_rust_dep_run_repro\"\nversion = \"0.1.0\"\n\n[rust-dependencies]\ndatafusion = \"53\"\n", + "[project]\nname = \"unused_rust_dep_run_repro\"\nversion = \"0.1.0\"\n\n[rust-dependencies]\nitoa = \"1\"\nryu = \"1\"\n", )?; std::fs::write( scripts_dir.join("check.incn"), @@ -1156,9 +1157,19 @@ mod tests { )?; let generated_manifest = std::fs::read_to_string(output_dir.join("Cargo.toml"))?; + let manifest = toml::from_str::(&generated_manifest)?; + let dependency_table = manifest + .get("dependencies") + .and_then(toml::Value::as_table) + .ok_or("generated manifest should contain a dependencies table")?; + let emitted_unused_dependencies = declared_unused_rust_dependencies + .iter() + .filter(|dependency| dependency_table.contains_key(**dependency)) + .copied() + .collect::>(); assert!( - !generated_manifest.contains("datafusion"), - "unused package-level rust dependencies should not be emitted for a script run:\n{generated_manifest}" + emitted_unused_dependencies.is_empty(), + "unused package-level rust dependencies should not be emitted for a script run; emitted {emitted_unused_dependencies:?}:\n{generated_manifest}" ); Ok(()) } diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 51e9164e9..2772d24c0 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -80,34 +80,48 @@ impl TypeChecker { let ResolvedType::RustPath(path) = expected_ty else { return None; }; + self.rust_callable_alias_target_display_for_path(path, &mut std::collections::HashSet::new()) + } + + /// Follow Rust type-alias chains until they expose a callable trait object target. + /// + /// This is intentionally metadata-driven rather than crate-specific. DataFusion's + /// `ScalarFunctionImplementation -> Arc` chain is one motivating surface, but the compiler must not + /// special-case DataFusion or require regression tests to compile that heavyweight crate. + fn rust_callable_alias_target_display_for_path( + &self, + path: &str, + seen: &mut std::collections::HashSet, + ) -> Option { + let canonical_path = Self::normalize_rust_namespace_path(path).to_string(); + if !seen.insert(canonical_path.clone()) { + return None; + } if let Some(metadata) = self.rust_item_metadata_for_path(path) && let RustItemKind::Type(type_info) = &metadata.kind && let Some(target) = type_info.alias_target.as_ref() { - return Some(self.rust_display_for_owner_path(target, path)); + let display = self.rust_display_for_owner_path(target, canonical_path.as_str()); + if Self::rust_display_has_callable_fn_bound(display.as_str()) { + return Some(display); + } + let (target_base, _) = self.rust_path_base_and_args(display.as_str()); + if target_base != canonical_path + && let Some(expanded) = self.rust_callable_alias_target_display_for_path(target_base.as_str(), seen) + { + return Some(expanded); + } + return None; } Some(self.rust_display_for_owner_path(path, path)) - .filter(|display| display.contains("dyn") && display.contains("Fn")) + .filter(|display| Self::rust_display_has_callable_fn_bound(display.as_str())) } /// Parse a Rust callable alias target such as `Arc Result + Send + Sync>`. fn rust_callable_alias_signature(&self, expected_ty: &ResolvedType) -> Option { let target_display = self.rust_callable_alias_target_display(expected_ty)?; let ty = syn::parse_str::(&target_display).ok()?; - let trait_object = Self::rust_callable_trait_object(&ty)?; - let fn_bound = trait_object.bounds.iter().find_map(|bound| { - let TypeParamBound::Trait(trait_bound) = bound else { - return None; - }; - let segment = trait_bound.path.segments.last()?; - if !matches!(segment.ident.to_string().as_str(), "Fn" | "FnMut" | "FnOnce") { - return None; - } - let PathArguments::Parenthesized(args) = &segment.arguments else { - return None; - }; - Some(args) - })?; + let fn_bound = Self::rust_callable_fn_bound(&ty)?; let params = fn_bound .inputs @@ -130,7 +144,33 @@ impl TypeChecker { Some(RustCallableAliasSignature { params, return_ty }) } - /// Build the Rust trait-object type for a callable alias. + /// Return whether a Rust display type contains a callable trait-object target. + fn rust_display_has_callable_fn_bound(display: &str) -> bool { + let Ok(ty) = syn::parse_str::(display) else { + return false; + }; + Self::rust_callable_fn_bound(&ty).is_some() + } + + /// Return the `Fn(...) -> ...` bound carried by a Rust callable trait-object target. + fn rust_callable_fn_bound(ty: &SynType) -> Option<&syn::ParenthesizedGenericArguments> { + let trait_object = Self::rust_callable_trait_object(ty)?; + trait_object.bounds.iter().find_map(|bound| { + let TypeParamBound::Trait(trait_bound) = bound else { + return None; + }; + let segment = trait_bound.path.segments.last()?; + if !matches!(segment.ident.to_string().as_str(), "Fn" | "FnMut" | "FnOnce") { + return None; + } + let PathArguments::Parenthesized(args) = &segment.arguments else { + return None; + }; + Some(args) + }) + } + + /// Find the Rust trait-object type wrapped by a callable alias target. fn rust_callable_trait_object(ty: &SynType) -> Option<&syn::TypeTraitObject> { match ty { SynType::TraitObject(trait_object) => Some(trait_object), diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 38ce9deab..a40e27244 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -656,7 +656,7 @@ def main() -> None: )?; fs::write( tmp.path().join("src").join("arc_callback.incn"), - r#"from rust::arc_callback import CallbackError, ColumnarValue, SliceCallback, create_udf + r#"from rust::arc_callback import CallbackError, ColumnarValue, DataType, ScalarFunctionImplementation, SliceCallback, Volatility, create_udf, create_udf_full from rust::std::sync import Arc def callback(args: list[ColumnarValue]) -> Result[ColumnarValue, CallbackError]: @@ -667,10 +667,21 @@ def inline_arc_callback_value() -> int: Ok(value) => return value.value() Err(_) => return -1 +def inline_datafusion_shaped_callback_value() -> int: + match create_udf_full( + name="sha1", + input_types=[DataType.Utf8], + return_type=DataType.Utf8, + volatility=Volatility.Immutable, + fun=Arc.from((args) => callback(args.to_vec())), + ): + Ok(value) => return value.value() + Err(_) => return -1 + pub def arc_callback_case() -> str: implementation: SliceCallback = Arc.from((args) => callback(args.to_vec())) match create_udf(callback=implementation, name="assigned"): - Ok(value) => return f"arc_callback:{value.value()}:{inline_arc_callback_value()}" + Ok(value) => return f"arc_callback:{value.value()}:{inline_arc_callback_value()}:{inline_datafusion_shaped_callback_value()}" Err(_) => return "arc_callback:err" "#, )?; @@ -735,6 +746,9 @@ pub def reexport_identity_case() -> str: "#, )?; + // Keep this fixture DataFusion-shaped but crate-light. The real DataFusion crate is far too expensive for a + // compiler regression test; the behavior under test is the Rust metadata shape: + // `ScalarFunctionImplementation -> SliceCallback -> Arc`. let helper_src = tmp.path().join("rust").join("arc_callback").join("src"); fs::create_dir_all(&helper_src)?; fs::write( @@ -770,6 +784,17 @@ impl ColumnarValue { pub struct CallbackError; pub type SliceCallback = Arc Result + Send + Sync>; +pub type ScalarFunctionImplementation = crate::SliceCallback; + +#[derive(Clone)] +pub enum DataType { + Utf8, +} + +#[derive(Clone)] +pub enum Volatility { + Immutable, +} pub fn invoke(callback: SliceCallback) -> Result { let args = vec![ColumnarValue::new(7)]; @@ -781,6 +806,21 @@ pub fn create_udf(name: &str, callback: crate::SliceCallback) -> Result, + return_type: DataType, + volatility: Volatility, + fun: crate::ScalarFunctionImplementation, +) -> Result { + let _ = name; + let _ = input_types; + let _ = return_type; + let _ = volatility; + let args = vec![ColumnarValue::new(13)]; + fun(&args) +} "#, )?; let helper_src = tmp.path().join("rust").join("borrow_helper").join("src"); @@ -988,7 +1028,7 @@ impl Expr { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), - "arc_callback:11:11\nborrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok\nreexport_identity:ok", + "arc_callback:11:11:13\nborrowed:1\nby_value:ok\ntrait_by_value:ok\ncross_crate:ok\nreexport_identity:ok", "expected batched generic Rust param output, got:\n{stdout}" ); Ok(()) diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 17c7f7610..8a77a3c9a 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -103,7 +103,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Borrowing decisions are metadata-backed**: Borrowed `str`/bytes calls, method fallback borrowing, and retained generated imports now follow the same decisions across typechecking, lowering, and emission. - **Rust bridge identity is preserved**: Inspected methods with unknown generic or lifetime placeholders and re-exported Rust argument displays keep stable bridge identity, including nested generic wrappers such as `Arc` (#645, #630, #705). -- **Rust callable aliases can accept Incan closures**: Rust `type` aliases such as `Arc Result + Send + Sync>` now preserve their alias target metadata, contextually type Incan closure parameters, and emit the required Rust closure parameter annotations without pulling heavyweight downstream crates into compiler regression tests (#708). +- **Rust callable aliases can accept Incan closures**: Rust `type` aliases such as `Arc Result + Send + Sync>` now preserve their alias target metadata, contextually type Incan closure parameters, follow alias chains such as `ScalarFunctionImplementation -> SliceCallback -> Arc`, and emit the required Rust closure parameter annotations without pulling heavyweight downstream crates into compiler regression tests (#708). - **Collection adaptation is less manual**: Owned Incan values can flow to shared borrowed generic Rust parameters, and `list[T]` can adapt to `Vec` where metadata proves the boundary (#506, #128). - **Protobuf-style APIs need fewer workarounds**: Prost-style inherent and trait-provided `decode(buf: T)` calls lower correctly (#609, #612). - **Generated Rust pruning is safer**: Enum-pattern imports and metadata-derived extension-trait imports survive pruning, while unused generated Rust is pruned without broad `allow` attributes (#459, #447, #214). From 2c1f1ac24334e1504ffbc7b3718ab1dba12d7059 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Sun, 31 May 2026 19:19:04 +0200 Subject: [PATCH 52/58] bugfix - preserve vocab item modifiers and raw Rust keyword fields (#724, #725) (#726) --- Cargo.lock | 18 +-- Cargo.toml | 2 +- crates/incan_core/src/interop/metadata.rs | 5 +- crates/incan_syntax/src/ast/stmts.rs | 17 +++ crates/incan_syntax/src/parser/core.rs | 44 ++++++ crates/incan_syntax/src/parser/stmts.rs | 94 +++++++++++++ crates/incan_syntax/src/parser/tests.rs | 63 +++++++++ crates/incan_vocab/README.md | 9 +- crates/incan_vocab/src/ast.rs | 31 ++++- crates/incan_vocab/src/keywords.rs | 86 ++++++++++++ crates/incan_vocab/src/lib.rs | 17 +-- crates/rust_inspect/src/extractor.rs | 55 +++++++- src/backend/ir/codegen.rs | 85 ++++++++++++ src/backend/ir/emit/expressions/indexing.rs | 6 +- src/backend/ir/emit/expressions/lvalue.rs | 6 +- src/backend/ir/lower/expr/mod.rs | 13 ++ src/backend/ir/lower/stmt.rs | 19 ++- src/cli/test_runner/execution.rs | 7 + src/format/formatter/statements.rs | 14 ++ src/frontend/ast_walk.rs | 7 + src/frontend/typechecker/check_expr/access.rs | 23 +++- src/frontend/typechecker/check_expr/calls.rs | 9 +- src/frontend/typechecker/check_stmt.rs | 6 + src/frontend/typechecker/mod.rs | 16 +++ src/frontend/typechecker/type_info.rs | 19 +++ src/frontend/vocab_ast_bridge.rs | 129 ++++++++++++++++-- src/lsp/backend.rs | 19 +++ src/lsp/call_site_type_args.rs | 5 + tests/integration_tests.rs | 85 ++++++++++++ .../how-to/authoring_vocab_crates.md | 1 + .../docs/language/how-to/rust_interop.md | 2 + .../docs-site/docs/release_notes/0_3.md | 4 +- 32 files changed, 864 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d729da3aa..6be83b220 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc30" +version = "0.3.0-rc33" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 208ee2b9f..9fcd93fd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc30" +version = "0.3.0-rc33" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_core/src/interop/metadata.rs b/crates/incan_core/src/interop/metadata.rs index 78682e5b5..12c384ff5 100644 --- a/crates/incan_core/src/interop/metadata.rs +++ b/crates/incan_core/src/interop/metadata.rs @@ -405,7 +405,10 @@ pub fn split_top_level_rust_args(text: &str) -> Vec<&str> { /// A public field surfaced on a Rust struct/union-like type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RustFieldInfo { - /// Field name as it appears in Rust. + /// Source-facing Rust field name accepted by Incan, with raw identifier prefixes removed. + /// + /// A Rust field declared as `r#type` is surfaced as `type`; an ordinary Rust field declared as `type_` remains + /// `type_`. Codegen rawifies keyword names when emitting Rust. pub name: String, /// Pretty-printed type for diagnostics and debug output. pub type_display: String, diff --git a/crates/incan_syntax/src/ast/stmts.rs b/crates/incan_syntax/src/ast/stmts.rs index 87356c120..c64b54b84 100644 --- a/crates/incan_syntax/src/ast/stmts.rs +++ b/crates/incan_syntax/src/ast/stmts.rs @@ -28,6 +28,8 @@ pub enum Statement { For(ForStmt), /// Expression statement Expr(Spanned), + /// DSL-owned expression-list item with declared trailing keyword metadata. + VocabExpressionItem(VocabExpressionItemStmt), /// `assert expr`, `assert expr, msg`, `assert call() raises Error`, or `assert value is Pattern`. Assert(AssertStmt), /// `pass` or `...` @@ -228,6 +230,21 @@ pub struct VocabKeywordBinding { pub activation_namespace: String, pub surface_kind: incan_vocab::KeywordSurfaceKind, pub placement: incan_vocab::KeywordPlacement, + pub clause_body_kind: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VocabExpressionItemStmt { + pub expr: Spanned, + pub alias: Option, + pub modifiers: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VocabExpressionItemModifierStmt { + pub keyword: String, + pub value: Spanned, + pub span: Span, } /// Raw vocab block statement captured before desugaring. diff --git a/crates/incan_syntax/src/parser/core.rs b/crates/incan_syntax/src/parser/core.rs index 32271c8f3..33a837846 100644 --- a/crates/incan_syntax/src/parser/core.rs +++ b/crates/incan_syntax/src/parser/core.rs @@ -28,6 +28,8 @@ struct ActiveImportedKeywordSpec { valid_decorators: Vec, surface_kind: incan_vocab::KeywordSurfaceKind, placement: incan_vocab::KeywordPlacement, + clause_body_kind: Option, + expression_item_modifiers: Vec, } #[derive(Debug, Clone)] @@ -62,6 +64,8 @@ pub struct Parser<'a> { active_soft_keywords: std::collections::HashSet, active_imported_keyword_specs: std::collections::HashMap>, vocab_block_stack: Vec, + vocab_body_kind_stack: Vec>, + vocab_expression_item_modifier_stack: Vec>, module_path: Option, library_imported_vocab: ImportedLibraryVocab, library_imported_dsl_surfaces: ImportedLibraryDslSurfaces, @@ -120,6 +124,8 @@ impl<'a> Parser<'a> { active_soft_keywords: std::collections::HashSet::new(), active_imported_keyword_specs: std::collections::HashMap::new(), vocab_block_stack: Vec::new(), + vocab_body_kind_stack: Vec::new(), + vocab_expression_item_modifier_stack: Vec::new(), module_path, library_imported_vocab: library_imported_vocab.cloned().unwrap_or_default(), library_imported_dsl_surfaces: library_imported_dsl_surfaces.cloned().unwrap_or_default(), @@ -346,6 +352,10 @@ impl<'a> Parser<'a> { } for keyword in ®istration.keywords { + let (clause_body_kind, expression_item_modifiers) = self + .active_clause_surface_for_keyword(library, keyword) + .map(|clause| (Some(clause.body_kind), clause.expression_item_modifiers.clone())) + .unwrap_or((None, Vec::new())); let specs = self .active_imported_keyword_specs .entry(keyword.name.clone()) @@ -360,6 +370,8 @@ impl<'a> Parser<'a> { valid_decorators: registration.valid_decorators.clone(), surface_kind: keyword.surface_kind, placement: keyword.placement.clone(), + clause_body_kind, + expression_item_modifiers, }); if let Some(id) = incan_core::lang::keywords::from_str(&keyword.name) && incan_core::lang::keywords::is_soft(id) @@ -369,6 +381,38 @@ impl<'a> Parser<'a> { } } } + + /// Return the clause surface declared by a rich DSL surface for one imported keyword. + /// + /// Low-level keyword registrations do not carry clause-body structure. When the same library also provides the + /// author-facing `DslSurface`, parser-only forms such as expression-list item modifiers can be gated by the richer + /// public contract instead of guessed later by the AST bridge. + fn active_clause_surface_for_keyword( + &self, + library: &str, + keyword: &incan_vocab::KeywordSpec, + ) -> Option<&incan_vocab::ClauseSurface> { + let surfaces = self.library_imported_dsl_surfaces.get(library)?; + for surface in surfaces { + if !dsl_surface_applies_to_pub_import(surface, library) { + continue; + } + for declaration in &surface.declarations { + let incan_vocab::KeywordPlacement::InBlock(parents) = &keyword.placement else { + continue; + }; + if !parents.iter().any(|parent| parent == &declaration.keyword) { + continue; + } + for clause in &declaration.clauses { + if clause.keyword == keyword.name && clause.compound_tokens == keyword.compound_tokens { + return Some(clause); + } + } + } + } + None + } } /// Return `true` when a DSL surface should activate for a `pub::library` import. diff --git a/crates/incan_syntax/src/parser/stmts.rs b/crates/incan_syntax/src/parser/stmts.rs index a2abb104f..c8468ec21 100644 --- a/crates/incan_syntax/src/parser/stmts.rs +++ b/crates/incan_syntax/src/parser/stmts.rs @@ -180,6 +180,8 @@ impl<'a> Parser<'a> { let spec_surface_kind = spec.surface_kind; let spec_placement = spec.placement.clone(); let spec_valid_decorators = spec.valid_decorators.clone(); + let spec_clause_body_kind = spec.clause_body_kind; + let spec_expression_item_modifiers = spec.expression_item_modifiers.clone(); // Avoid committing to vocab-block parsing unless a top-level header-delimiting `:` is visible ahead. This // preserves `assignment_or_expr_stmt` fallback for statements like `route = "/health"`, `route(args)`, and @@ -223,8 +225,13 @@ impl<'a> Parser<'a> { } self.vocab_block_stack.push(keyword_name.clone()); + self.vocab_body_kind_stack.push(spec_clause_body_kind); + self.vocab_expression_item_modifier_stack + .push(spec_expression_item_modifiers); let body = self.block(); self.vocab_block_stack.pop(); + self.vocab_body_kind_stack.pop(); + self.vocab_expression_item_modifier_stack.pop(); let body = body?; self.expect(&TokenKind::Dedent, "Expected dedent after vocab block body")?; @@ -235,6 +242,7 @@ impl<'a> Parser<'a> { activation_namespace: spec_activation_namespace, surface_kind: spec_surface_kind, placement: spec_placement, + clause_body_kind: spec_clause_body_kind, }, decorators, header_args, @@ -803,6 +811,10 @@ impl<'a> Parser<'a> { // Parse the expression (could be field access like self.field or index like arr[i]) let expr = self.expression()?; + if let Some(item) = self.vocab_expression_list_item_tail(expr.clone())? { + return Ok(Statement::VocabExpressionItem(item)); + } + // Check for tuple assignment: expr, expr, ... = value // This handles patterns like: arr[i], arr[j] = arr[j], arr[i] if self.match_token(&TokenKind::Punctuation(PunctuationId::Comma)) { @@ -892,6 +904,88 @@ impl<'a> Parser<'a> { Ok(Statement::Expr(expr)) } + /// Return whether the current vocab body is contractually an expression list. + fn vocab_expression_list_items_enabled(&self) -> bool { + matches!( + self.vocab_body_kind_stack.last(), + Some(Some(incan_vocab::ClauseBodyKind::ExpressionList)) + ) + } + + /// Parse declared trailing keyword payloads for one expression-list item. + fn vocab_expression_list_item_tail( + &mut self, + expr: Spanned, + ) -> Result, CompileError> { + if !self.vocab_expression_list_items_enabled() { + return Ok(None); + } + + let mut alias = None; + let mut modifiers = Vec::new(); + let mut saw_tail = false; + + while let Some(surface) = self.current_expression_item_modifier_surface() { + let keyword_span = self.current_span(); + self.advance(); + saw_tail = true; + match surface.kind { + incan_vocab::ExpressionItemModifierKind::Alias => { + if alias.is_some() { + return Err(CompileError::syntax( + format!("Duplicate expression-list alias modifier `{}`", surface.keyword), + keyword_span, + )); + } + alias = Some(self.identifier()?); + } + incan_vocab::ExpressionItemModifierKind::Expression => { + let value = self.expression()?; + let span = Span::new(keyword_span.start, value.span.end); + modifiers.push(VocabExpressionItemModifierStmt { + keyword: surface.keyword, + value, + span, + }); + } + _ => { + return Err(CompileError::syntax( + format!("Unsupported expression-list modifier kind for `{}`", surface.keyword), + keyword_span, + )); + } + } + } + + if saw_tail { + Ok(Some(VocabExpressionItemStmt { + expr, + alias, + modifiers, + })) + } else { + Ok(None) + } + } + + /// Return the declared expression-list item modifier matching the current token. + fn current_expression_item_modifier_surface(&self) -> Option { + let keyword = self.current_vocab_word_token()?; + self.vocab_expression_item_modifier_stack + .last() + .and_then(|modifiers| modifiers.iter().find(|modifier| modifier.keyword == keyword)) + .cloned() + } + + /// Return the current identifier/keyword spelling when it can start a DSL-owned item modifier. + fn current_vocab_word_token(&self) -> Option<&str> { + match &self.peek().kind { + TokenKind::Ident(name) => Some(name.as_str()), + TokenKind::Keyword(id) => Some(incan_core::lang::keywords::as_str(*id)), + _ => None, + } + } + /// Convert an assignment operator token such as `+=` or `<<=` into its AST compound operator. fn compound_op_from_token_kind(kind: &TokenKind) -> Option { match kind { diff --git a/crates/incan_syntax/src/parser/tests.rs b/crates/incan_syntax/src/parser/tests.rs index 235c6397e..7e0b27eeb 100644 --- a/crates/incan_syntax/src/parser/tests.rs +++ b/crates/incan_syntax/src/parser/tests.rs @@ -4497,6 +4497,69 @@ def has_name(name: str | None) -> bool: Ok(()) } + #[test] + fn test_expression_list_clause_accepts_declared_item_modifiers() -> Result<(), Box> { + let source = "import pub::analytics\n\ndef configure() -> None:\n query:\n SELECT:\n sum(amount) as total for customer with context\n amount\n"; + let tokens = crate::lexer::lex(source).map_err(|errs| format!("lex errors: {errs:?}"))?; + + let metadata = incan_vocab::VocabRegistration::new() + .with_surface( + incan_vocab::DslSurface::on_import("analytics.query").with_declaration( + incan_vocab::DeclarationSurface::named("query") + .with_clause_body() + .with_clause( + incan_vocab::ClauseSurface::expr_list("SELECT") + .with_expression_item_modifiers([ + incan_vocab::ExpressionItemModifierSurface::expr("for"), + incan_vocab::ExpressionItemModifierSurface::expr("with"), + ]) + .required(), + ), + ), + ) + .metadata(); + let mut keyword_map = std::collections::HashMap::new(); + keyword_map.insert("analytics".to_string(), metadata.keyword_registrations); + let mut surface_map = std::collections::HashMap::new(); + surface_map.insert("analytics".to_string(), metadata.dsl_surfaces); + + let program = + crate::parser::parse_with_context_and_surfaces(&tokens, None, Some(&keyword_map), Some(&surface_map)) + .map_err(|errs| format!("parse errors: {errs:?}"))?; + let function = match &program.declarations[1].node { + crate::ast::Declaration::Function(function) => function, + other => return Err(format!("expected function declaration, got {other:?}").into()), + }; + let crate::ast::Statement::VocabBlock(query_block) = &function.body[0].node else { + return Err(format!("expected query vocab block, got {:?}", function.body[0].node).into()); + }; + let crate::ast::Statement::VocabBlock(select_block) = &query_block.body[0].node else { + return Err(format!("expected SELECT clause block, got {:?}", query_block.body[0].node).into()); + }; + assert_eq!( + select_block.keyword_binding.clause_body_kind, + Some(incan_vocab::ClauseBodyKind::ExpressionList) + ); + assert!(matches!( + &select_block.body[0].node, + crate::ast::Statement::VocabExpressionItem(item) + if item.alias.as_deref() == Some("total") + && item.modifiers.len() == 2 + && item.modifiers[0].keyword == "for" + && matches!(&item.modifiers[0].value.node, crate::ast::Expr::Ident(name) if name == "customer") + && item.modifiers[1].keyword == "with" + && matches!(&item.modifiers[1].value.node, crate::ast::Expr::Ident(name) if name == "context") + && matches!(&item.expr.node, crate::ast::Expr::Call(callee, _, _) + if matches!(&callee.node, crate::ast::Expr::Ident(name) if name == "sum")) + )); + assert!(matches!( + &select_block.body[1].node, + crate::ast::Statement::Expr(expr) + if matches!(&expr.node, crate::ast::Expr::Ident(name) if name == "amount") + )); + Ok(()) + } + #[test] fn test_scoped_symbol_descriptor_does_not_change_call_outside_vocab_block() -> Result<(), Box> { diff --git a/crates/incan_vocab/README.md b/crates/incan_vocab/README.md index 65e406fd9..283bba245 100644 --- a/crates/incan_vocab/README.md +++ b/crates/incan_vocab/README.md @@ -74,7 +74,9 @@ Version 0.2.0 is the RFC 040 release. It adds the stable library-author contract Companion crates should export one obvious Rust function: ```rust -use incan_vocab::{ClauseSurface, DeclarationSurface, DslSurface, VocabRegistration}; +use incan_vocab::{ + ClauseSurface, DeclarationSurface, DslSurface, ExpressionItemModifierSurface, VocabRegistration, +}; pub fn library_vocab() -> VocabRegistration { VocabRegistration::new().with_surface( @@ -84,7 +86,10 @@ pub fn library_vocab() -> VocabRegistration { .desugars_to_expression() .with_clauses([ ClauseSurface::expr("FROM").required(), - ClauseSurface::expr_list("SELECT").required().after("FROM"), + ClauseSurface::expr_list("SELECT") + .with_expression_item_modifier(ExpressionItemModifierSurface::expr("for")) + .required() + .after("FROM"), ]), ), ) diff --git a/crates/incan_vocab/src/ast.rs b/crates/incan_vocab/src/ast.rs index 397560af4..32bf5ccfa 100644 --- a/crates/incan_vocab/src/ast.rs +++ b/crates/incan_vocab/src/ast.rs @@ -124,6 +124,33 @@ pub struct VocabFieldSpec { pub span: Span, } +/// One trailing keyword payload parsed after an expression-list item. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct VocabExpressionItemModifier { + /// Keyword that introduced this payload. + pub keyword: String, + /// Expression payload captured after the keyword. + pub value: IncanExpr, + /// Source span for this modifier. + pub span: Span, +} + +/// One expression-list entry parsed inside a DSL-owned clause body. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct VocabExpressionItem { + /// Expression payload. + pub expr: IncanExpr, + /// Optional SQL-style alias from `expr as alias`. + pub alias: Option, + /// Additional declared trailing keyword payloads, such as `expr for target with context`. + #[cfg_attr(feature = "serde", serde(default))] + pub modifiers: Vec, + /// Source span for this expression-list item. + pub span: Span, +} + /// A DSL-owned clause such as `FROM`, `SELECT`, `config`, or `input`. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -152,8 +179,8 @@ pub enum VocabClauseBody { Empty, /// A single expression payload. Expression(IncanExpr), - /// A list of expressions, typically separated by commas or lines. - ExpressionList(Vec), + /// A list of expression entries, typically separated by commas or lines. + ExpressionList(Vec), /// An opaque host-language type payload. Type(VocabTypeExpr), /// A field/config-style body. diff --git a/crates/incan_vocab/src/keywords.rs b/crates/incan_vocab/src/keywords.rs index 559768372..6931eff26 100644 --- a/crates/incan_vocab/src/keywords.rs +++ b/crates/incan_vocab/src/keywords.rs @@ -270,6 +270,48 @@ pub enum ClauseBodyKind { NestedItems, } +/// Payload parser for a trailing keyword on one expression-list item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub enum ExpressionItemModifierKind { + /// The trailing keyword captures an alias identifier, such as `expr as name`. + #[default] + Alias, + /// The trailing keyword captures another expression, such as `expr for target`. + Expression, +} + +/// One trailing keyword accepted after an expression-list item. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ExpressionItemModifierSurface { + /// Keyword spelling consumed after the leading item expression. + pub keyword: String, + /// Payload shape consumed after the keyword. + pub kind: ExpressionItemModifierKind, +} + +impl ExpressionItemModifierSurface { + /// Create an alias modifier such as `expr as alias`. + #[must_use] + pub fn alias(keyword: &str) -> Self { + Self { + keyword: keyword.to_string(), + kind: ExpressionItemModifierKind::Alias, + } + } + + /// Create an expression modifier such as `expr for target`. + #[must_use] + pub fn expr(keyword: &str) -> Self { + Self { + keyword: keyword.to_string(), + kind: ExpressionItemModifierKind::Expression, + } + } +} + /// Relative placement of one clause within a declaration's clause grammar. #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -309,6 +351,9 @@ pub struct ClauseSurface { pub compound_tokens: Vec, /// Structured body payload kind for this clause. pub body_kind: ClauseBodyKind, + /// Trailing keyword payloads accepted after expression-list items. + #[cfg_attr(feature = "serde", serde(default))] + pub expression_item_modifiers: Vec, /// Whether the clause is required, optional, or repeatable. pub cardinality: ClauseCardinality, /// Relative ordering guidance within the owning declaration. @@ -323,6 +368,7 @@ impl ClauseSurface { keyword: keyword.to_string(), compound_tokens: Vec::new(), body_kind, + expression_item_modifiers: default_expression_item_modifiers(body_kind), cardinality: ClauseCardinality::Optional, placement: ClausePlacement::Anywhere, } @@ -335,6 +381,10 @@ impl ClauseSurface { } /// Create an expression-list clause from its full spelling. + /// + /// Expression-list clauses preserve each item as [`crate::VocabExpressionItem`]. They accept SQL-style `expr as + /// alias` by default and can declare more trailing keyword payloads with + /// [`Self::with_expression_item_modifier`]. #[must_use] pub fn expr_list(spelling: &str) -> Self { Self::from_spelling(spelling, ClauseBodyKind::ExpressionList) @@ -358,12 +408,14 @@ impl ClauseSurface { Self::from_spelling(spelling, ClauseBodyKind::NestedItems) } + /// Create a clause from a full spelling and attach any defaults implied by its body kind. fn from_spelling(spelling: &str, body_kind: ClauseBodyKind) -> Self { let (keyword, compound_tokens) = split_spelling(spelling); Self { keyword, compound_tokens, body_kind, + expression_item_modifiers: default_expression_item_modifiers(body_kind), cardinality: ClauseCardinality::Optional, placement: ClausePlacement::Anywhere, } @@ -380,6 +432,31 @@ impl ClauseSurface { self } + /// Add one trailing keyword parser for expression-list items. + #[must_use] + pub fn with_expression_item_modifier(mut self, modifier: ExpressionItemModifierSurface) -> Self { + if !self + .expression_item_modifiers + .iter() + .any(|existing| existing.keyword == modifier.keyword) + { + self.expression_item_modifiers.push(modifier); + } + self + } + + /// Add multiple trailing keyword parsers for expression-list items. + #[must_use] + pub fn with_expression_item_modifiers(mut self, modifiers: I) -> Self + where + I: IntoIterator, + { + for modifier in modifiers { + self = self.with_expression_item_modifier(modifier); + } + self + } + /// Mark this clause as required. #[must_use] pub fn required(mut self) -> Self { @@ -416,6 +493,15 @@ impl ClauseSurface { } } +/// Return expression-list item modifiers that are part of the built-in high-level clause contract. +fn default_expression_item_modifiers(body_kind: ClauseBodyKind) -> Vec { + if matches!(body_kind, ClauseBodyKind::ExpressionList) { + vec![ExpressionItemModifierSurface::alias("as")] + } else { + Vec::new() + } +} + /// One DSL-owned declaration surface such as a query block, stage, or workflow. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/crates/incan_vocab/src/lib.rs b/crates/incan_vocab/src/lib.rs index 2e9b5db3a..be9d8bcdd 100644 --- a/crates/incan_vocab/src/lib.rs +++ b/crates/incan_vocab/src/lib.rs @@ -45,8 +45,8 @@ pub use ast::{ Decorator, DecoratorArg, DecoratorArgValue, IncanBinaryOp, IncanExpr, IncanRaceForArm, IncanRaceForBody, IncanRaceForExpr, IncanScopedSurfaceExpr, IncanScopedSurfaceOwner, IncanScopedSurfacePayload, IncanScopedSymbolCall, IncanStatement, IncanUnaryOp, Span, VocabBodyItem, VocabClause, VocabClauseBody, - VocabDeclaration, VocabDeclarationHead, VocabFieldSpec, VocabKeywordMetadata, VocabParameter, VocabSyntaxNode, - VocabTypeExpr, + VocabDeclaration, VocabDeclarationHead, VocabExpressionItem, VocabExpressionItemModifier, VocabFieldSpec, + VocabKeywordMetadata, VocabParameter, VocabSyntaxNode, VocabTypeExpr, }; #[cfg(feature = "serde")] pub use desugar::execute_desugar_request; @@ -56,12 +56,13 @@ pub use desugar::{ }; pub use keywords::{ ClauseBodyKind, ClauseCardinality, ClausePlacement, ClauseSurface, DeclarationBodyKind, DeclarationHeadKind, - DeclarationSurface, DesugarTarget, DslSurface, KeywordActivation, KeywordPlacement, KeywordRegistration, - KeywordSpec, KeywordSurfaceKind, ScopedSurfaceChainMode, ScopedSurfaceDescriptor, ScopedSurfaceDiagnosticKind, - ScopedSurfaceDiagnosticTemplate, ScopedSurfaceEligibility, ScopedSurfaceFamily, ScopedSurfaceFormatHint, - ScopedSurfaceMisuseScope, ScopedSurfacePosition, ScopedSurfaceReceiver, ScopedSurfaceSyntax, - ScopedSymbolDescriptor, ScopedSymbolDiagnosticKind, ScopedSymbolDiagnosticTemplate, ScopedSymbolEligibility, - ScopedSymbolFamily, ScopedSymbolMisuseScope, ScopedSymbolPosition, ScopedSymbolRoleMetadata, + DeclarationSurface, DesugarTarget, DslSurface, ExpressionItemModifierKind, ExpressionItemModifierSurface, + KeywordActivation, KeywordPlacement, KeywordRegistration, KeywordSpec, KeywordSurfaceKind, ScopedSurfaceChainMode, + ScopedSurfaceDescriptor, ScopedSurfaceDiagnosticKind, ScopedSurfaceDiagnosticTemplate, ScopedSurfaceEligibility, + ScopedSurfaceFamily, ScopedSurfaceFormatHint, ScopedSurfaceMisuseScope, ScopedSurfacePosition, + ScopedSurfaceReceiver, ScopedSurfaceSyntax, ScopedSymbolDescriptor, ScopedSymbolDiagnosticKind, + ScopedSymbolDiagnosticTemplate, ScopedSymbolEligibility, ScopedSymbolFamily, ScopedSymbolMisuseScope, + ScopedSymbolPosition, ScopedSymbolRoleMetadata, }; pub use manifest::{ CargoDependency, CargoDependencySource, FieldExport, FunctionExport, HelperBinding, LibraryManifest, diff --git a/crates/rust_inspect/src/extractor.rs b/crates/rust_inspect/src/extractor.rs index de016ff09..c5d79a04a 100644 --- a/crates/rust_inspect/src/extractor.rs +++ b/crates/rust_inspect/src/extractor.rs @@ -324,6 +324,20 @@ fn source_field_type_shape(field: &ra_ap_hir::Field, db: &RootDatabase, crate_na Some(source_type_shape(text.as_str(), crate_name, module, db)) } +/// Return the Rust source spelling for a named field, removing only Rust's raw-identifier prefix. +/// +/// rust-analyzer may expose a raw field such as `r#type` through a safe internal name. Incan needs the source spelling +/// instead: `type` should be accepted in Incan and later emitted as `r#type`, while an ordinary Rust field named +/// `type_` must remain `type_`. +fn source_field_name(field: &ra_ap_hir::Field, db: &RootDatabase) -> Option { + let source = field.source(db)?; + let FieldSource::Named(field) = source.value else { + return None; + }; + let raw = field.name()?.syntax().text().to_string(); + Some(raw.strip_prefix("r#").unwrap_or(raw.as_str()).to_string()) +} + fn normalize_variant_payload_shape(shape: RustTypeShape) -> RustTypeShape { match shape { RustTypeShape::RustPath { path, args } @@ -694,6 +708,10 @@ fn collect_implemented_traits(ty: Type<'_>, db: &RootDatabase) -> Vec, db: &RootDatabase, dt: DisplayTarget, crate_name: &str) -> Vec { if let Some(adt) = ty.as_adt() { let type_args: Vec> = ty.type_arguments().collect(); @@ -713,7 +731,7 @@ fn collect_public_fields(ty: Type<'_>, db: &RootDatabase, dt: DisplayTarget, cra type_shape = source_field_type_shape(&field, db, crate_name).unwrap_or(type_shape); } collected.push(RustFieldInfo { - name: field.name(db).as_str().to_owned(), + name: source_field_name(&field, db).unwrap_or_else(|| field.name(db).as_str().to_owned()), type_display: format_ty(&field_ty, db, dt), type_shape, }); @@ -731,7 +749,7 @@ fn collect_public_fields(ty: Type<'_>, db: &RootDatabase, dt: DisplayTarget, cra type_shape = source_field_type_shape(&field, db, crate_name).unwrap_or(type_shape); } fields.push(RustFieldInfo { - name: field.name(db).as_str().to_owned(), + name: source_field_name(&field, db).unwrap_or_else(|| field.name(db).as_str().to_owned()), type_display: format_ty(&field_ty, db, dt), type_shape, }); @@ -1079,6 +1097,39 @@ edition = "2021" Ok(()) } + #[test] + fn type_metadata_unescapes_raw_keyword_fields_without_rewriting_plain_names() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + fs::create_dir_all(tmp.path().join("src"))?; + fs::write( + tmp.path().join("Cargo.toml"), + r#"[package] +name = "demo_raw_field_probe" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + tmp.path().join("src/lib.rs"), + r#"pub struct JoinRel { + pub r#type: i64, + pub type_: i64, + pub r#match: i64, +} +"#, + )?; + + let workspace = RustWorkspace::load(tmp.path(), &|_| ())?; + let metadata = extract_rust_item(&workspace, "demo_raw_field_probe::JoinRel")?; + let RustItemKind::Type(info) = metadata.kind else { + return Err(std::io::Error::other("expected type metadata").into()); + }; + let fields = info.fields.iter().map(|field| field.name.as_str()).collect::>(); + assert_eq!(fields, ["type", "type_", "match"]); + Ok(()) + } + #[test] fn type_alias_metadata_preserves_source_target_shape() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 914b2ee88..099c3a6f5 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -3170,6 +3170,91 @@ pub def make_pair() -> Pair: Ok(()) } + #[cfg(feature = "rust_inspect")] + #[test] + fn test_codegen_emits_raw_rust_field_names_for_keyword_fields_issue725() -> Result<(), Box> { + use crate::frontend::typechecker::TypeChecker; + use incan_core::interop::{ + RustFieldInfo, RustItemKind, RustItemMetadata, RustTypeInfo, RustTypeShape, RustVisibility, + }; + + let source = r#" +from rust::demo import JoinRel + +pub def get_type(join: JoinRel) -> int: + return join.type + join.match + join.type_ + +pub def rebuild(join: JoinRel) -> JoinRel: + return JoinRel(type=join.type, match=join.match, type_=join.type_) +"#; + let tokens = must_ok(lexer::lex(source)); + let ast = must_ok(parser::parse(&tokens)); + + let tmp = seeded_rust_inspect_workspace()?; + let manifest_dir = tmp.path().to_path_buf(); + let mut tc = TypeChecker::new(); + tc.set_rust_inspect_manifest_dir(manifest_dir.clone()); + tc.rust_inspect_cache + .insert_test_item( + &manifest_dir, + RustItemMetadata { + canonical_path: "demo::JoinRel".to_string(), + definition_path: Some("demo::JoinRel".to_string()), + visibility: RustVisibility::Public, + kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, + methods: Vec::new(), + implemented_traits: Vec::new(), + fields: vec![ + RustFieldInfo { + name: "type".to_string(), + type_display: "i64".to_string(), + type_shape: RustTypeShape::Int, + }, + RustFieldInfo { + name: "match".to_string(), + type_display: "i64".to_string(), + type_shape: RustTypeShape::Int, + }, + RustFieldInfo { + name: "type_".to_string(), + type_display: "i64".to_string(), + type_shape: RustTypeShape::Int, + }, + ], + variants: Vec::new(), + }), + }, + ) + .map_err(|e| std::io::Error::other(format!("seed rust-inspect type: {e}")))?; + tc.check_program(&ast) + .map_err(|errs| std::io::Error::other(format!("typecheck failed: {errs:?}")))?; + + let mut lowering = AstLowering::new_with_type_info(tc.type_info().clone()); + let ir_program = lowering + .lower_program(&ast) + .map_err(|err| std::io::Error::other(format!("lowering failed: {err:?}")))?; + let mut emitter = IrEmitter::new(&ir_program.function_registry); + let code = emitter + .emit_program(&ir_program) + .map_err(|err| std::io::Error::other(format!("emit failed: {err:?}")))?; + + assert!( + code.contains("join.r#type") + && code.contains("join.r#match") + && code.contains("join.type_") + && code.contains("r#type: join.r#type") + && code.contains("r#match: join.r#match") + && code.contains("type_: join.type_"), + "expected keyword fields to emit raw Rust identifiers while ordinary trailing-underscore fields stay unchanged; got:\n{code}" + ); + assert!( + !code.contains("r#type: join.type_") && !code.contains("type_: join.r#type"), + "Rust keyword fields and ordinary trailing-underscore fields must not be cross-wired; got:\n{code}" + ); + Ok(()) + } + #[cfg(feature = "rust_inspect")] #[test] fn test_codegen_uses_source_field_names_for_metadata_free_rust_type_constructor() diff --git a/src/backend/ir/emit/expressions/indexing.rs b/src/backend/ir/emit/expressions/indexing.rs index 0797848b1..ec81c1d9e 100644 --- a/src/backend/ir/emit/expressions/indexing.rs +++ b/src/backend/ir/emit/expressions/indexing.rs @@ -306,7 +306,7 @@ impl<'a> IrEmitter<'a> { let ident = format_ident!("{}", name); quote! { #ident } }; - let f = format_ident!("{}", canonical_field); + let f = Self::rust_ident(canonical_field); return Ok(quote! { #type_ident::#f }); } if Self::expr_is_type_like(object) { @@ -318,7 +318,7 @@ impl<'a> IrEmitter<'a> { let ident = format_ident!("{}", name); quote! { #ident } }; - let f = format_ident!("{}", field); + let f = Self::rust_ident(field); return Ok(quote! { #type_ident::#f }); } } @@ -331,7 +331,7 @@ impl<'a> IrEmitter<'a> { .unwrap_or_else(|_| syn::Index::from(0)); Ok(quote! { #o.#idx }) } else { - let f = format_ident!("{}", field); + let f = Self::rust_ident(field); Ok(quote! { #o.#f }) } } diff --git a/src/backend/ir/emit/expressions/lvalue.rs b/src/backend/ir/emit/expressions/lvalue.rs index ce7294683..e7fdcae3e 100644 --- a/src/backend/ir/emit/expressions/lvalue.rs +++ b/src/backend/ir/emit/expressions/lvalue.rs @@ -10,7 +10,7 @@ //! - `indexing.rs`: shared negative-index handling logic use proc_macro2::TokenStream; -use quote::{format_ident, quote}; +use quote::quote; use super::super::super::expr::{IrExprKind, TypedExpr}; use super::super::super::stmt::AssignTarget; @@ -84,7 +84,7 @@ impl<'a> IrEmitter<'a> { } IrExprKind::Field { object, field } => { let o = self.emit_lvalue_expr(object)?; - let f = format_ident!("{}", field); + let f = Self::rust_ident(field); // Only parenthesize when needed. // // `emit_lvalue_expr` may emit a leading `*` for list indexing (`*list_get_mut(..)`). @@ -128,7 +128,7 @@ impl<'a> IrEmitter<'a> { } AssignTarget::Field { object, field } => { let o = self.emit_lvalue_expr(object)?; - let f = format_ident!("{}", field); + let f = Self::rust_ident(field); // Same precedence rule as in `emit_lvalue_expr`: only parenthesize when the receiver may start with a // unary `*` (e.g. list index lvalues). if matches!(object.kind, IrExprKind::Index { .. }) { diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index b2403b10a..95068f23a 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -1080,6 +1080,19 @@ impl AstLowering { result_ty, ) } else { + if let Some(rust_field) = self + .type_info + .as_ref() + .and_then(|info| info.rust_field_access_name(expr_span)) + { + return Ok(TypedExpr::new( + IrExprKind::Field { + object: Box::new(obj), + field: rust_field.to_string(), + }, + IrType::Unknown, + )); + } // RFC 021: resolve field alias to canonical name if object is a known struct type let struct_name = obj.ty.nominal_type_name().or_else(|| match &obj.kind { IrExprKind::Var { name, .. } if name == "self" => self.current_impl_type.as_deref(), diff --git a/src/backend/ir/lower/stmt.rs b/src/backend/ir/lower/stmt.rs index d74201ded..39c3a76c0 100644 --- a/src/backend/ir/lower/stmt.rs +++ b/src/backend/ir/lower/stmt.rs @@ -1067,7 +1067,12 @@ impl AstLowering { ast::Statement::FieldAssignment(fa) => IrStmtKind::Assign { target: AssignTarget::Field { object: Box::new(self.lower_expr_spanned(&fa.object)?), - field: fa.field.clone(), + field: self + .type_info + .as_ref() + .and_then(|info| info.rust_field_access_name(fa.target_span)) + .unwrap_or(fa.field.as_str()) + .to_string(), }, value: self.lower_expr_spanned(&fa.value)?, }, @@ -1357,6 +1362,12 @@ impl AstLowering { } ast::Statement::Surface(surface_stmt) => self.lower_surface_statement(surface_stmt)?, + ast::Statement::VocabExpressionItem(_item) => { + return Err(LoweringError { + message: "raw vocab expression-list item reached lowering before desugaring".to_string(), + span: IrSpan::default(), + }); + } ast::Statement::VocabBlock(vocab_block) => { return Err(LoweringError { message: format!( @@ -2012,6 +2023,12 @@ impl AstLowering { } } ast::Statement::Expr(expr) => self.count_expr_ident_reads(&expr.node, counts), + ast::Statement::VocabExpressionItem(item) => { + self.count_expr_ident_reads(&item.expr.node, counts); + for modifier in &item.modifiers { + self.count_expr_ident_reads(&modifier.value.node, counts); + } + } ast::Statement::Break(Some(expr)) => self.count_expr_ident_reads(&expr.node, counts), ast::Statement::Pass | ast::Statement::Break(None) | ast::Statement::Continue => {} ast::Statement::CompoundAssignment(ca) => { diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index de2266ec9..76cb02f6a 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -1098,6 +1098,13 @@ fn statement_references_name(stmt: &Statement, name: &str) -> bool { Statement::ChainedAssignment(assign) => expr_references_name(&assign.value.node, name), Statement::Return(None) | Statement::Pass | Statement::Break(None) | Statement::Continue => false, Statement::Break(Some(expr)) => expr_references_name(&expr.node, name), + Statement::VocabExpressionItem(item) => { + expr_references_name(&item.expr.node, name) + || item + .modifiers + .iter() + .any(|modifier| expr_references_name(&modifier.value.node, name)) + } Statement::Surface(_) | Statement::VocabBlock(_) => false, } } diff --git a/src/format/formatter/statements.rs b/src/format/formatter/statements.rs index cdd1aef46..62d02859c 100644 --- a/src/format/formatter/statements.rs +++ b/src/format/formatter/statements.rs @@ -17,6 +17,20 @@ impl Formatter { self.writer.newline(); } } + Statement::VocabExpressionItem(item) => { + self.format_expr(&item.expr.node); + if let Some(alias) = &item.alias { + self.writer.write(" as "); + self.writer.write(alias); + } + for modifier in &item.modifiers { + self.writer.write(" "); + self.writer.write(&modifier.keyword); + self.writer.write(" "); + self.format_expr(&modifier.value.node); + } + self.writer.newline(); + } Statement::Assert(assert_stmt) => self.format_assert(assert_stmt), Statement::Assignment(assign) => { self.format_assignment(assign); diff --git a/src/frontend/ast_walk.rs b/src/frontend/ast_walk.rs index c6bb74de2..c9baaa7a1 100644 --- a/src/frontend/ast_walk.rs +++ b/src/frontend/ast_walk.rs @@ -183,6 +183,13 @@ where } Statement::Return(Some(expr)) => expr_has(&expr.node, pred), Statement::Expr(expr) => expr_has(&expr.node, pred), + Statement::VocabExpressionItem(item) => { + expr_has(&item.expr.node, pred) + || item + .modifiers + .iter() + .any(|modifier| expr_has(&modifier.value.node, pred)) + } Statement::CompoundAssignment(a) => expr_has(&a.value.node, pred), Statement::TupleUnpack(u) => expr_has(&u.value.node, pred), Statement::TupleAssign(a) => { diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 2772d24c0..ed24b7c84 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -13,7 +13,9 @@ use crate::frontend::typechecker::helpers::{ option_ty, string_method_return, }; use crate::frontend::typechecker::type_info::{RustMethodTraitImportUse, RustTraitImportInfo}; -use incan_core::interop::{RustCollectionFamily, RustFunctionSig, RustItemKind, metadata_free_method_signature}; +use incan_core::interop::{ + RustCollectionFamily, RustFieldInfo, RustFunctionSig, RustItemKind, metadata_free_method_signature, +}; use incan_core::lang::magic_methods; use incan_core::lang::surface::collection_helpers::{self, BuiltinCollectionHelperId}; use incan_core::lang::surface::types as surface_types; @@ -75,6 +77,19 @@ fn rust_receiver_display(path: &str) -> String { } impl TypeChecker { + /// Resolve a source-facing Rust field spelling to the metadata field it names. + /// + /// Rust raw identifier fields should be written with the Rust source name at Incan field-use sites. For example, a + /// Rust field declared as `r#type` is accessed as `obj.type` and constructed with `TypeName(type=...)`; emission + /// rawifies the keyword identifier back to `r#type`. An ordinary Rust field declared as `type_` remains available + /// only as `obj.type_`. + pub(in crate::frontend::typechecker::check_expr) fn rust_field_for_source_name<'a>( + fields: &'a [RustFieldInfo], + source_name: &str, + ) -> Option<&'a RustFieldInfo> { + fields.iter().find(|field| field.name == source_name) + } + /// Return the target display for a Rust type alias when the expected destination type names one. fn rust_callable_alias_target_display(&self, expected_ty: &ResolvedType) -> Option { let ResolvedType::RustPath(path) = expected_ty else { @@ -1489,7 +1504,7 @@ impl TypeChecker { } if let Some(meta) = self.rust_item_metadata_for_path(path) && let RustItemKind::Type(info) = &meta.kind - && let Some(rust_field) = info.fields.iter().find(|f| f.name == field) + && let Some(rust_field) = Self::rust_field_for_source_name(&info.fields, field) { return Some(self.resolved_type_from_rust_shape(&rust_field.type_shape)); } @@ -2745,8 +2760,10 @@ impl TypeChecker { return ResolvedType::Function(params, Box::new(ResolvedType::RustPath(path.to_string()))); } if let RustItemKind::Type(info) = &meta.kind - && let Some(rust_field) = info.fields.iter().find(|f| f.name == field) + && let Some(rust_field) = Self::rust_field_for_source_name(&info.fields, field) { + self.type_info + .record_rust_field_access_name(span, rust_field.name.clone()); return self.resolved_type_from_rust_shape(&rust_field.type_shape); } // Metadata may still be missing constants, type aliases, trait-provided items, or private fields. diff --git a/src/frontend/typechecker/check_expr/calls.rs b/src/frontend/typechecker/check_expr/calls.rs index 0f3f5a059..9772b1bec 100644 --- a/src/frontend/typechecker/check_expr/calls.rs +++ b/src/frontend/typechecker/check_expr/calls.rs @@ -13,7 +13,7 @@ use incan_core::lang::derives::{self, DeriveId}; use incan_core::lang::keywords::{self, KeywordId}; use incan_core::lang::stdlib; use incan_core::lang::surface::types::{self as surface_types, SurfaceTypeId}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use super::TypeChecker; @@ -445,11 +445,6 @@ impl TypeChecker { args: &[CallArg], span: Span, ) -> ResolvedType { - let fields_by_name: HashMap<&str, &RustFieldInfo> = type_info - .fields - .iter() - .map(|field| (field.name.as_str(), field)) - .collect(); let mut selected_fields = Vec::with_capacity(args.len()); let mut provided = HashSet::new(); let mut positional_index = 0usize; @@ -483,7 +478,7 @@ impl TypeChecker { selected_fields.push(field.name.clone()); } CallArg::Named(field_name, expr) => { - let Some(field) = fields_by_name.get(field_name.as_str()) else { + let Some(field) = Self::rust_field_for_source_name(&type_info.fields, field_name.as_str()) else { self.check_expr(expr); self.errors.push(errors::missing_field(path, field_name, expr.span)); has_shape_error = true; diff --git a/src/frontend/typechecker/check_stmt.rs b/src/frontend/typechecker/check_stmt.rs index 73f396ac0..1798d5473 100644 --- a/src/frontend/typechecker/check_stmt.rs +++ b/src/frontend/typechecker/check_stmt.rs @@ -111,6 +111,12 @@ impl TypeChecker { Statement::Expr(expr) => { self.check_expr(expr); } + Statement::VocabExpressionItem(_item) => { + self.errors.push(crate::frontend::diagnostics::CompileError::new( + "raw vocab expression-list item reached typechecker before desugaring".to_string(), + stmt.span, + )); + } Statement::Pass => {} Statement::Break(value) => self.check_break_stmt(value.as_ref(), stmt.span), Statement::Continue => self.check_continue_stmt(stmt.span), diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index 5c85f13ad..b3de24205 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -2528,6 +2528,16 @@ impl TypeChecker { Statement::Break(Some(expr)) => { self.collect_static_initializer_static_writes_from_expr(expr, current_static, visiting_functions); } + Statement::VocabExpressionItem(item) => { + self.collect_static_initializer_static_writes_from_expr(&item.expr, current_static, visiting_functions); + for modifier in &item.modifiers { + self.collect_static_initializer_static_writes_from_expr( + &modifier.value, + current_static, + visiting_functions, + ); + } + } Statement::Return(None) | Statement::Pass | Statement::Break(None) @@ -2641,6 +2651,12 @@ impl TypeChecker { Statement::ChainedAssignment(assign) => { self.collect_static_dependencies_from_expr(&assign.value.node, deps, visiting_functions); } + Statement::VocabExpressionItem(item) => { + self.collect_static_dependencies_from_expr(&item.expr.node, deps, visiting_functions); + for modifier in &item.modifiers { + self.collect_static_dependencies_from_expr(&modifier.value.node, deps, visiting_functions); + } + } Statement::Assert(assert_stmt) => { match &assert_stmt.kind { AssertKind::Condition(condition) => { diff --git a/src/frontend/typechecker/type_info.rs b/src/frontend/typechecker/type_info.rs index f64ca9cfc..64a7d742e 100644 --- a/src/frontend/typechecker/type_info.rs +++ b/src/frontend/typechecker/type_info.rs @@ -172,6 +172,12 @@ pub struct RustInteropArtifacts { /// resolved field names so `Range(1, 3)` can emit `Range { start: 1, end: 3 }` instead of an invalid tuple-style /// Rust constructor. pub named_field_constructor_fields: HashMap<(usize, usize), Vec>, + /// Imported Rust field accesses keyed by full field-expression span. + /// + /// The parser may use an Incan-safe source spelling such as `type_` for a Rust field whose metadata name is the + /// Rust keyword `type`. Lowering consumes this resolved Rust field name so emission can use the real Rust field + /// identifier rather than guessing from source text. + pub field_access_names: HashMap<(usize, usize), String>, /// Rust closure parameter displays keyed by closure-expression span. /// /// This is populated when contextual Rust metadata proves a closure is being used as a Rust callable boundary @@ -615,6 +621,19 @@ impl TypeCheckInfo { .insert((span.start, span.end), fields); } + /// Return the Rust field name resolved for one Rust field-access expression, if one was recorded. + pub fn rust_field_access_name(&self, span: Span) -> Option<&str> { + self.rust + .field_access_names + .get(&(span.start, span.end)) + .map(String::as_str) + } + + /// Record the Rust field name resolved for one Rust field-access expression. + pub(crate) fn record_rust_field_access_name(&mut self, span: Span, field: String) { + self.rust.field_access_names.insert((span.start, span.end), field); + } + /// Return rest-aware callable metadata recorded for the full call expression span, if any. pub fn call_site_callable_params(&self, span: Span) -> Option<&[CallableParam]> { self.calls diff --git a/src/frontend/vocab_ast_bridge.rs b/src/frontend/vocab_ast_bridge.rs index 59014019e..12787360b 100644 --- a/src/frontend/vocab_ast_bridge.rs +++ b/src/frontend/vocab_ast_bridge.rs @@ -149,7 +149,7 @@ fn internal_vocab_clause_to_public( .iter() .map(|arg| internal_expr_to_public(&arg.node)) .collect::, _>>()?; - let body = internal_clause_body_to_public(&block.body)?; + let body = internal_clause_body_to_public(&block.body, block.keyword_binding.clause_body_kind)?; Ok(incan_vocab::VocabClause { keyword: block.keyword.clone(), compound_tokens: Vec::new(), @@ -172,10 +172,14 @@ fn internal_vocab_clause_to_public( /// Returns the first bridge failure while probing or converting the contained statements. fn internal_clause_body_to_public( statements: &[ast::Spanned], + declared_body_kind: Option, ) -> Result { if statements.is_empty() { return Ok(incan_vocab::VocabClauseBody::Empty); } + if matches!(declared_body_kind, Some(incan_vocab::ClauseBodyKind::ExpressionList)) { + return expression_list_body_to_public(statements); + } if let Some(fields) = try_internal_field_set(statements)? { return Ok(incan_vocab::VocabClauseBody::FieldSet(fields)); } @@ -183,30 +187,36 @@ fn internal_clause_body_to_public( let expression_only = statements .iter() .map(|statement| match &statement.node { - ast::Statement::Expr(expr) => internal_expr_to_public(&expr.node).map(Some), + ast::Statement::Expr(expr) => Ok(Some(incan_vocab::VocabExpressionItem { + expr: internal_expr_to_public(&expr.node)?, + alias: None, + modifiers: Vec::new(), + span: public_span(statement.span), + })), _ => Ok(None), }) .collect::, _>>()?; if expression_only.iter().all(Option::is_some) { - let expressions = expression_only + let expression_items = expression_only .into_iter() - .map(|expr| { - expr.ok_or(VocabAstBridgeError::UnsupportedInternalStatement( + .map(|item| { + item.ok_or(VocabAstBridgeError::UnsupportedInternalStatement( "clause expression extraction expected expression statements", )) }) .collect::, _>>()?; - return if expressions.len() == 1 { + return if expression_items.len() == 1 { Ok(incan_vocab::VocabClauseBody::Expression( - expressions + expression_items .into_iter() .next() .ok_or(VocabAstBridgeError::UnsupportedInternalStatement( "single-expression clause conversion expected one expression item", - ))?, + ))? + .expr, )) } else { - Ok(incan_vocab::VocabClauseBody::ExpressionList(expressions)) + Ok(incan_vocab::VocabClauseBody::ExpressionList(expression_items)) }; } @@ -217,6 +227,48 @@ fn internal_clause_body_to_public( Ok(incan_vocab::VocabClauseBody::Items(items)) } +/// Convert a clause body declared as `ClauseBodyKind::ExpressionList`. +/// +/// This preserves declared trailing keyword metadata as first-class public AST rather than forcing desugarers to +/// recover DSL item structure from ordinary statements. +fn expression_list_body_to_public( + statements: &[ast::Spanned], +) -> Result { + let mut items = Vec::with_capacity(statements.len()); + for statement in statements { + match &statement.node { + ast::Statement::Expr(expr) => items.push(incan_vocab::VocabExpressionItem { + expr: internal_expr_to_public(&expr.node)?, + alias: None, + modifiers: Vec::new(), + span: public_span(statement.span), + }), + ast::Statement::VocabExpressionItem(item) => items.push(incan_vocab::VocabExpressionItem { + expr: internal_expr_to_public(&item.expr.node)?, + alias: item.alias.clone(), + modifiers: item + .modifiers + .iter() + .map(|modifier| { + Ok(incan_vocab::VocabExpressionItemModifier { + keyword: modifier.keyword.clone(), + value: internal_expr_to_public(&modifier.value.node)?, + span: public_span(modifier.span), + }) + }) + .collect::, VocabAstBridgeError>>()?, + span: public_span(statement.span), + }), + _ => { + return Err(VocabAstBridgeError::UnsupportedInternalStatement( + "expression-list clause body expected expression entries", + )); + } + } + } + Ok(incan_vocab::VocabClauseBody::ExpressionList(items)) +} + /// Detect whether a clause body is representable as a public field-set payload. /// /// A field set is only recognized when every statement is a non-reassignment assignment. Any other statement shape @@ -1029,6 +1081,16 @@ mod tests { activation_namespace: "demo.dsl".to_string(), surface_kind, placement: incan_vocab::KeywordPlacement::TopLevel, + clause_body_kind: None, + } + } + + fn expression_list_clause_binding() -> ast::VocabKeywordBinding { + ast::VocabKeywordBinding { + surface_kind: incan_vocab::KeywordSurfaceKind::BlockContextKeyword, + placement: incan_vocab::KeywordPlacement::in_block(["query"]), + clause_body_kind: Some(incan_vocab::ClauseBodyKind::ExpressionList), + ..default_keyword_binding(incan_vocab::KeywordSurfaceKind::BlockContextKeyword) } } @@ -1069,6 +1131,55 @@ mod tests { Ok(()) } + #[test] + fn bridges_expression_list_clause_alias_items() -> Result<(), Box> { + let clause_block = ast::VocabBlockStmt { + keyword: "SELECT".to_string(), + keyword_binding: expression_list_clause_binding(), + decorators: Vec::new(), + header_args: Vec::new(), + body: vec![ + ast::Spanned::new( + ast::Statement::VocabExpressionItem(ast::VocabExpressionItemStmt { + expr: ast::Spanned::new(ast::Expr::Ident("amount".to_string()), ast::Span::new(10, 16)), + alias: Some("total".to_string()), + modifiers: vec![ast::VocabExpressionItemModifierStmt { + keyword: "for".to_string(), + value: ast::Spanned::new(ast::Expr::Ident("customer".to_string()), ast::Span::new(30, 38)), + span: ast::Span::new(26, 38), + }], + }), + ast::Span::new(10, 38), + ), + ast::Spanned::new( + ast::Statement::Expr(ast::Spanned::new( + ast::Expr::Ident("region".to_string()), + ast::Span::new(30, 36), + )), + ast::Span::new(30, 36), + ), + ], + }; + + let clause = internal_vocab_clause_to_public(&clause_block, ast::Span::default())?; + let incan_vocab::VocabClauseBody::ExpressionList(items) = clause.body else { + return Err(format!("expected expression-list body, got {:?}", clause.body).into()); + }; + assert_eq!(items.len(), 2); + assert_eq!(items[0].alias.as_deref(), Some("total")); + assert_eq!(items[0].modifiers.len(), 1); + assert_eq!(items[0].modifiers[0].keyword, "for"); + assert_eq!( + items[0].modifiers[0].value, + incan_vocab::IncanExpr::Name("customer".to_string()) + ); + assert_eq!(items[0].expr, incan_vocab::IncanExpr::Name("amount".to_string())); + assert_eq!(items[1].alias, None); + assert!(items[1].modifiers.is_empty()); + assert_eq!(items[1].expr, incan_vocab::IncanExpr::Name("region".to_string())); + Ok(()) + } + #[test] fn infers_declaration_head_name_from_first_identifier_arg() -> Result<(), Box> { let block = ast::VocabBlockStmt { diff --git a/src/lsp/backend.rs b/src/lsp/backend.rs index af8b4eeb7..97ccb1484 100644 --- a/src/lsp/backend.rs +++ b/src/lsp/backend.rs @@ -1767,6 +1767,13 @@ fn local_signature_in_statement( .find_map(|target| local_signature_in_expr(target, ast, source, offset)) .or_else(|| local_signature_in_expr(&assign.value, ast, source, offset)), Statement::ChainedAssignment(assignment) => local_signature_in_expr(&assignment.value, ast, source, offset), + Statement::VocabExpressionItem(item) => { + local_signature_in_expr(&item.expr, ast, source, offset).or_else(|| { + item.modifiers + .iter() + .find_map(|modifier| local_signature_in_expr(&modifier.value, ast, source, offset)) + }) + } Statement::Surface(surface) => match &surface.payload { crate::frontend::ast::SurfaceStmtPayload::KeywordArgs(args) => args .iter() @@ -3420,6 +3427,12 @@ fn scoped_symbol_in_statement<'a>( Statement::ChainedAssignment(assign) => { scoped_symbol_in_expr(&assign.value, ident, symbol_span, surfaces, found); } + Statement::VocabExpressionItem(item) => { + scoped_symbol_in_expr(&item.expr, ident, symbol_span, surfaces, found); + for modifier in &item.modifiers { + scoped_symbol_in_expr(&modifier.value, ident, symbol_span, surfaces, found); + } + } Statement::TupleAssign(assign) => { for target in &assign.targets { scoped_symbol_in_expr(target, ident, symbol_span, surfaces, found); @@ -3952,6 +3965,12 @@ fn scoped_symbol_context_in_statement(stmt: &Spanned, offset: usize, Statement::CompoundAssignment(assign) => scoped_symbol_context_in_expr(&assign.value, offset, context), Statement::TupleUnpack(assign) => scoped_symbol_context_in_expr(&assign.value, offset, context), Statement::ChainedAssignment(assign) => scoped_symbol_context_in_expr(&assign.value, offset, context), + Statement::VocabExpressionItem(item) => { + scoped_symbol_context_in_expr(&item.expr, offset, context); + for modifier in &item.modifiers { + scoped_symbol_context_in_expr(&modifier.value, offset, context); + } + } Statement::TupleAssign(assign) => { for target in &assign.targets { scoped_symbol_context_in_expr(target, offset, context); diff --git a/src/lsp/call_site_type_args.rs b/src/lsp/call_site_type_args.rs index 476864ef4..29e2b0386 100644 --- a/src/lsp/call_site_type_args.rs +++ b/src/lsp/call_site_type_args.rs @@ -341,6 +341,11 @@ fn call_site_type_in_stmt(stmt: &Statement, offset: usize) -> Option<&Spanned call_site_type_in_expr(&c.value, offset), + Statement::VocabExpressionItem(item) => call_site_type_in_expr(&item.expr, offset).or_else(|| { + item.modifiers + .iter() + .find_map(|modifier| call_site_type_in_expr(&modifier.value, offset)) + }), Statement::Assert(assert_stmt) => call_site_type_in_assert_stmt(assert_stmt, offset), Statement::Surface(s) => match &s.payload { crate::frontend::ast::SurfaceStmtPayload::KeywordArgs(exprs) => { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 201b129b9..25f5e914e 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -11314,6 +11314,53 @@ pub def display[T](data: DataSet[T]) -> None: Ok(()) } + fn write_pub_library_with_querykit_select_desugarer( + root: &Path, + desugarer_bytes: &[u8], + ) -> Result<(), Box> { + let artifact_root = root.join("deps").join("querykit").join("target").join("lib"); + std::fs::create_dir_all(artifact_root.join("desugarers"))?; + write_minimal_library_crate(&artifact_root, "querykit_core")?; + let desugarer_path = artifact_root.join("desugarers").join("querykit_desugarer.wasm"); + std::fs::write(&desugarer_path, desugarer_bytes)?; + + let metadata = incan_vocab::VocabRegistration::new() + .with_surface( + incan_vocab::DslSurface::on_import("querykit.query").with_declaration( + incan_vocab::DeclarationSurface::named("query") + .with_clause_body() + .with_clause( + incan_vocab::ClauseSurface::expr_list("SELECT") + .with_expression_item_modifiers([ + incan_vocab::ExpressionItemModifierSurface::expr("for"), + incan_vocab::ExpressionItemModifierSurface::expr("with"), + ]) + .required(), + ), + ), + ) + .metadata(); + let mut manifest = LibraryManifest::new("querykit_core", "0.1.0"); + manifest.vocab = Some(incan::library_manifest::VocabExports { + crate_path: "vocab_companion".to_string(), + package_name: "vocab_companion".to_string(), + keyword_registrations: metadata.keyword_registrations, + dsl_surfaces: metadata.dsl_surfaces, + provider_manifest: incan_vocab::LibraryManifest::default(), + desugarer_artifact: Some(incan::library_manifest::VocabDesugarerArtifact { + artifact_kind: incan_vocab::DesugarerArtifactKind::WasmModule, + abi_version: incan_vocab::WASM_DESUGAR_ABI_VERSION, + relative_path: "desugarers/querykit_desugarer.wasm".to_string(), + target: "wasm32-wasip1".to_string(), + profile: "release".to_string(), + entrypoint: "desugar_block".to_string(), + sha256: hex::encode(Sha256::digest(desugarer_bytes)), + }), + }); + manifest.write_to_path(&artifact_root.join("querykit_core.incnlib"))?; + Ok(()) + } + fn write_pub_library_with_vocab_desugarer_and_filter_helper( root: &Path, dependency_key: &str, @@ -12615,6 +12662,44 @@ def main() -> None: Ok(()) } + #[test] + fn consumer_check_passes_expr_list_item_metadata_to_desugarer_issue724() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::statements(vec![incan_vocab::IncanStatement::Let { + name: "query_generated".to_string(), + mutable: false, + value: incan_vocab::IncanExpr::Int(1), + }]); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing expression-list modifier payload", + r#""keyword":"with""#, + )?; + write_pub_library_with_querykit_select_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +def main() -> None: + query: + SELECT: + sum(amount) as total for customer with context +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected check to pass expression-list item metadata to the desugarer.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn equivalent_helper_backed_keywords_typecheck() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md b/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md index 6ae57c4e9..4ea1f68f7 100644 --- a/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md +++ b/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md @@ -127,6 +127,7 @@ Key rules: - `DslSurface::on_import("routekit")` must match the consumer-facing import spelling after `pub::`. - Declarations own their clause grammar directly, so nested DSL structure stays close to the declaration that introduces it. +- Use `ClauseSurface::expr_list("SELECT")` for SQL-shaped projection clauses that accept entries such as `sum(amount) as total`; add declared item modifiers with `ExpressionItemModifierSurface::expr("for")` or similar when a projection item needs metadata such as `sum(amount) for customer with context`. The desugarer receives structured expression-list items with alias and modifier metadata, while `ClauseSurface::fields(...)` remains for config-style `name = value` sections. - `LibraryManifest` is where you describe exported module metadata plus any Cargo dependencies or stdlib features that must travel with the library artifact. - `KeywordRegistration` remains available only as a lower-level escape hatch for especially simple or incremental cases. diff --git a/workspaces/docs-site/docs/language/how-to/rust_interop.md b/workspaces/docs-site/docs/language/how-to/rust_interop.md index 9f719c2cf..54f86fa16 100644 --- a/workspaces/docs-site/docs/language/how-to/rust_interop.md +++ b/workspaces/docs-site/docs/language/how-to/rust_interop.md @@ -48,6 +48,8 @@ from rust::my_crate::proto import type as proto_type The same rule applies to path segments after `rust::` (for example `rust::substrait::proto::type::Binary`). +For Rust struct fields whose real Rust identifier is a keyword, use the keyword spelling in Incan field access and named constructor arguments. For example, a Rust field declared as `r#type` is available as `value.type` and `TypeName(type=value)` in Incan source; generated Rust still uses the real raw identifier. An ordinary Rust field named `type_` remains `value.type_`. + ## Dependency Management When you use `import rust::crate_name`, Incan automatically adds the dependency to your generated `Cargo.toml`. Dependencies are resolved using a three-tier precedence system: diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 8a77a3c9a..7c6d9bb1b 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -46,7 +46,7 @@ Use this section as the map. The release note names each larger feature, says wh - **Callable presets with RHS `partial` declarations**: Write `pub get = partial route(method="GET")` when a new API name is really the same callable with named defaults, not a new function body. Read [Callable presets explained](../language/explanation/callable_presets.md), then [Callable presets](../language/reference/callable_presets.md) ([RFC 084], #453). - **Variadics and call unpacking**: Describe call shapes that accept or forward flexible argument lists without losing static checks. Read [Functions](../language/reference/functions.md) ([RFC 038], #83). - **Generators and lazy iterators**: Build pipelines with generator values, lazy adapters, and explicit terminal consumers such as `collect`, `count`, `find`, and `fold`. Read [Generators](../language/how-to/generators.md) and [Generator semantics](../language/explanation/generators.md) ([RFC 006], [RFC 088], #127, #324, #386). -- **Scoped DSL surfaces**: Let vocab crates activate scoped block, clause, glyph, leading-dot, and symbol syntax for their own DSL contexts instead of turning library-specific syntax into global parser behavior. Read [Author library DSLs with incan_vocab](../contributing/how-to/authoring_vocab_crates.md) ([RFC 040], [RFC 045]). +- **Scoped DSL surfaces**: Let vocab crates activate scoped block, clause, glyph, leading-dot, symbol, and expression-list item syntax for their own DSL contexts instead of turning library-specific syntax into global parser behavior. Read [Author library DSLs with incan_vocab](../contributing/how-to/authoring_vocab_crates.md) ([RFC 040], [RFC 045]). ### Rust interop and API metadata @@ -120,6 +120,8 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Decorator helpers can inspect generic callables**: `func.__name__` works in generic `(F) -> F` decorator helpers, including imported alias and union callable signatures, so registry decorators can infer the decorated helper name instead of repeating it as a string (#694, #701). - **Decorated wrappers preserve defaults**: Decorated functions and methods keep source default-argument call behavior when the final decorated callable surface still matches the original declaration, including direct imports and public facade re-exports (#703). - **Stdlib implementation modules stay internal**: Generated stdlib source dependencies no longer leak unimported helper classes into project modules, so explicit sibling imports keep precedence over unrelated `std.*` imports (#710). +- **Vocab expression-list clauses preserve item metadata**: `ClauseSurface::expr_list(...)` accepts `expr as alias` entries and declared trailing item modifiers such as `expr for target with context`, exposing structured metadata to desugarers instead of forcing SQL-shaped projections through field-set syntax (#724). +- **Rust raw identifier fields emit correctly**: Rust-backed fields whose real Rust name is a keyword can be accessed with the keyword spelling, such as `value.type` and `TypeName(type=value)`, while generated Rust emits the actual raw identifier access such as `value.r#type` (#725). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). ### Formatter And Test Runner From 826584e1672a0aab6da203b3519da9be64e66a69 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 1 Jun 2026 00:53:37 +0200 Subject: [PATCH 53/58] bugfix - support expression-position vocab declarations (#727) Co-authored-by: Codex --- Cargo.lock | 18 +- Cargo.toml | 2 +- crates/incan_syntax/src/ast/exprs.rs | 4 +- crates/incan_syntax/src/ast/stmts.rs | 1 + crates/incan_syntax/src/parser/core.rs | 35 + crates/incan_syntax/src/parser/expr.rs | 359 +++++ crates/incan_syntax/src/parser/stmts.rs | 214 ++- crates/incan_syntax/src/parser/tests.rs | 133 ++ crates/incan_vocab/README.md | 2 + crates/incan_vocab/src/ast.rs | 3 + src/backend/ir/lower/expr/mod.rs | 10 + src/backend/ir/lower/stmt.rs | 8 + src/cli/test_runner/execution.rs | 7 + src/format/formatter/expressions.rs | 20 + src/frontend/ast_walk.rs | 3 + src/frontend/typechecker/check_expr/mod.rs | 10 + src/frontend/typechecker/const_eval.rs | 1 + src/frontend/typechecker/mod.rs | 16 + src/frontend/vocab_ast_bridge.rs | 55 +- .../vocab_desugar_pass/helper_bindings.rs | 2 +- src/frontend/vocab_desugar_pass/rewrite.rs | 1326 ++++++++++++++++- src/lsp/backend.rs | 26 + src/lsp/call_site_type_args.rs | 5 + tests/integration_tests.rs | 228 +++ .../how-to/authoring_vocab_crates.md | 1 + .../docs-site/docs/release_notes/0_3.md | 1 + 26 files changed, 2368 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6be83b220..5ed0a46bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc33" +version = "0.3.0-rc35" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 9fcd93fd1..6d8ffc973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc33" +version = "0.3.0-rc35" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/incan_syntax/src/ast/exprs.rs b/crates/incan_syntax/src/ast/exprs.rs index 2788f22b0..18b0f879a 100644 --- a/crates/incan_syntax/src/ast/exprs.rs +++ b/crates/incan_syntax/src/ast/exprs.rs @@ -4,7 +4,7 @@ use std::fmt; use incan_semantics_core::SurfaceFeatureKey; -use super::{Ident, Param, Spanned, Statement, Type}; +use super::{Ident, Param, Spanned, Statement, Type, VocabBlockStmt}; // ============================================================================ // Expressions @@ -83,6 +83,8 @@ pub enum Expr { }, /// Generic surface expression routed to semantics handlers. Surface(Box), + /// Raw library vocab declaration used as an expression before vocab desugaring. + VocabBlock(Box), } /// One entry in a list literal. diff --git a/crates/incan_syntax/src/ast/stmts.rs b/crates/incan_syntax/src/ast/stmts.rs index c64b54b84..8ec9c9e7b 100644 --- a/crates/incan_syntax/src/ast/stmts.rs +++ b/crates/incan_syntax/src/ast/stmts.rs @@ -229,6 +229,7 @@ pub struct VocabKeywordBinding { pub dependency_key: String, pub activation_namespace: String, pub surface_kind: incan_vocab::KeywordSurfaceKind, + pub compound_tokens: Vec, pub placement: incan_vocab::KeywordPlacement, pub clause_body_kind: Option, } diff --git a/crates/incan_syntax/src/parser/core.rs b/crates/incan_syntax/src/parser/core.rs index 33a837846..3fa54ebc0 100644 --- a/crates/incan_syntax/src/parser/core.rs +++ b/crates/incan_syntax/src/parser/core.rs @@ -23,11 +23,13 @@ enum IndexOrSlice { #[derive(Debug, Clone)] struct ActiveImportedKeywordSpec { keyword_name: String, + compound_tokens: Vec, dependency_key: String, activation_namespace: String, valid_decorators: Vec, surface_kind: incan_vocab::KeywordSurfaceKind, placement: incan_vocab::KeywordPlacement, + desugar_target: incan_vocab::DesugarTarget, clause_body_kind: Option, expression_item_modifiers: Vec, } @@ -352,6 +354,10 @@ impl<'a> Parser<'a> { } for keyword in ®istration.keywords { + let declaration_surface = self.active_declaration_surface_for_keyword(library, keyword); + let desugar_target = declaration_surface + .map(|declaration| declaration.desugars_to) + .unwrap_or(incan_vocab::DesugarTarget::Statements); let (clause_body_kind, expression_item_modifiers) = self .active_clause_surface_for_keyword(library, keyword) .map(|clause| (Some(clause.body_kind), clause.expression_item_modifiers.clone())) @@ -362,6 +368,7 @@ impl<'a> Parser<'a> { .or_default(); specs.push(ActiveImportedKeywordSpec { keyword_name: keyword.name.clone(), + compound_tokens: keyword.compound_tokens.clone(), dependency_key: library.to_string(), activation_namespace: match ®istration.activation { incan_vocab::KeywordActivation::OnImport { namespace } => namespace.clone(), @@ -370,6 +377,7 @@ impl<'a> Parser<'a> { valid_decorators: registration.valid_decorators.clone(), surface_kind: keyword.surface_kind, placement: keyword.placement.clone(), + desugar_target, clause_body_kind, expression_item_modifiers, }); @@ -382,6 +390,33 @@ impl<'a> Parser<'a> { } } + /// Return the declaration surface declared by a rich DSL surface for one imported keyword. + /// + /// Keyword registrations are still the parser activation index, but declaration-only contract such as the desugar + /// target lives on the richer `DslSurface`. Joining them here keeps expression-position vocab parsing driven by + /// metadata instead of keyword spellings. + fn active_declaration_surface_for_keyword( + &self, + library: &str, + keyword: &incan_vocab::KeywordSpec, + ) -> Option<&incan_vocab::DeclarationSurface> { + if !matches!(keyword.surface_kind, incan_vocab::KeywordSurfaceKind::BlockDeclaration) { + return None; + } + let surfaces = self.library_imported_dsl_surfaces.get(library)?; + for surface in surfaces { + if !dsl_surface_applies_to_pub_import(surface, library) { + continue; + } + for declaration in &surface.declarations { + if declaration.keyword == keyword.name && declaration.compound_tokens == keyword.compound_tokens { + return Some(declaration); + } + } + } + None + } + /// Return the clause surface declared by a rich DSL surface for one imported keyword. /// /// Low-level keyword registrations do not carry clause-body structure. When the same library also provides the diff --git a/crates/incan_syntax/src/parser/expr.rs b/crates/incan_syntax/src/parser/expr.rs index 125dc8397..0633c7444 100644 --- a/crates/incan_syntax/src/parser/expr.rs +++ b/crates/incan_syntax/src/parser/expr.rs @@ -670,6 +670,10 @@ impl<'a> Parser<'a> { return self.race_for_expr(start); } + if let Some(expr) = self.try_vocab_block_expression(start)? { + return Ok(expr); + } + // self if self.match_token(&TokenKind::Keyword(KeywordId::SelfKw)) { let end = self.tokens[self.pos - 1].span.end; @@ -728,6 +732,360 @@ impl<'a> Parser<'a> { )) } + /// Parse a metadata-declared vocab block in expression position. + /// + /// Only declarations whose rich DSL surface says `desugars_to_expression()` are accepted here. The low-level + /// keyword activation still supplies the parser entrypoint, while the rich declaration metadata decides whether the + /// raw block can occupy a value position. + fn try_vocab_block_expression(&mut self, start: usize) -> Result>, CompileError> { + let Some(keyword_name) = self.current_vocab_keyword_name() else { + return Ok(None); + }; + let parent_keyword = self.vocab_block_stack.last().cloned(); + let Some(spec) = self + .find_active_vocab_block_spec(&keyword_name, parent_keyword.as_deref()) + .cloned() + else { + return Ok(None); + }; + if spec.desugar_target != incan_vocab::DesugarTarget::Expression { + return Ok(None); + } + if !self.has_top_level_colon_before_statement_end(self.pos + 1) + && !self.vocab_expression_block_has_brace_delimiter(&spec) + { + return Ok(None); + } + + let block = self.parse_vocab_expression_block(keyword_name, spec)?; + let end = self.tokens[self.pos.saturating_sub(1)].span.end; + Ok(Some(Spanned::new( + Expr::VocabBlock(Box::new(block)), + Span::new(start, end), + ))) + } + + /// Return whether a vocab expression declaration has a top-level `{` delimiter after its optional header args. + fn vocab_expression_block_has_brace_delimiter(&self, spec: &ActiveImportedKeywordSpec) -> bool { + let mut idx = self.pos + 1 + spec.compound_tokens.len(); + let mut paren_depth = 0usize; + let mut bracket_depth = 0usize; + let mut brace_depth = 0usize; + + while let Some(token) = self.tokens.get(idx) { + match token.kind { + TokenKind::Punctuation(PunctuationId::LParen) => paren_depth += 1, + TokenKind::Punctuation(PunctuationId::RParen) => { + paren_depth = paren_depth.saturating_sub(1); + } + TokenKind::Punctuation(PunctuationId::LBracket) => bracket_depth += 1, + TokenKind::Punctuation(PunctuationId::RBracket) => { + bracket_depth = bracket_depth.saturating_sub(1); + } + TokenKind::Punctuation(PunctuationId::LBrace) + if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => + { + return true; + } + TokenKind::Punctuation(PunctuationId::LBrace) => brace_depth += 1, + TokenKind::Punctuation(PunctuationId::RBrace) => { + brace_depth = brace_depth.saturating_sub(1); + } + TokenKind::Newline | TokenKind::Dedent | TokenKind::Eof + if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => + { + return false; + } + _ => {} + } + idx += 1; + } + + false + } + + /// Parse the raw block carrier shared by expression-position vocab declarations. + fn parse_vocab_expression_block( + &mut self, + keyword_name: String, + spec: ActiveImportedKeywordSpec, + ) -> Result { + let spec_compound_tokens = spec.compound_tokens.clone(); + self.advance(); + self.consume_vocab_compound_tokens(&spec_compound_tokens)?; + + let mut header_args = Vec::new(); + while !self.check_punct(PunctuationId::Colon) && !self.check_punct(PunctuationId::LBrace) { + header_args.push(self.expression()?); + if !self.match_punct(PunctuationId::Comma) { + break; + } + } + + let body = if self.match_punct(PunctuationId::Colon) { + self.expect(&TokenKind::Newline, "Expected newline after ':'")?; + self.expect_suite_indent("Expected indented block after vocab keyword")?; + let body = self.parse_scoped_vocab_indented_body( + &keyword_name, + spec.clause_body_kind, + spec.expression_item_modifiers.clone(), + )?; + self.expect(&TokenKind::Dedent, "Expected dedent after vocab block body")?; + body + } else if self.match_punct(PunctuationId::LBrace) { + self.parse_scoped_vocab_braced_body( + &keyword_name, + spec.clause_body_kind, + spec.expression_item_modifiers.clone(), + )? + } else { + return Err(errors::expected_token_message( + "Expected ':' or '{' after vocab expression declaration header", + &format!("{:?}", self.peek().kind), + self.current_span(), + )); + }; + + Ok(VocabBlockStmt { + keyword: keyword_name, + keyword_binding: VocabKeywordBinding { + dependency_key: spec.dependency_key, + activation_namespace: spec.activation_namespace, + surface_kind: spec.surface_kind, + compound_tokens: spec_compound_tokens, + placement: spec.placement, + clause_body_kind: spec.clause_body_kind, + }, + decorators: Vec::new(), + header_args, + body, + }) + } + + /// Parse an indentation-delimited vocab body with the same scoped context used by statement vocab blocks. + fn parse_scoped_vocab_indented_body( + &mut self, + keyword_name: &str, + clause_body_kind: Option, + expression_item_modifiers: Vec, + ) -> Result>, CompileError> { + self.vocab_block_stack.push(keyword_name.to_string()); + self.vocab_body_kind_stack.push(clause_body_kind); + self.vocab_expression_item_modifier_stack + .push(expression_item_modifiers); + let body = self.block(); + self.vocab_block_stack.pop(); + self.vocab_body_kind_stack.pop(); + self.vocab_expression_item_modifier_stack.pop(); + body + } + + /// Parse a brace-delimited vocab body. + /// + /// Braced vocab syntax does not receive lexer newline/indent tokens, so clause boundaries are recognized from the + /// active child keyword metadata for the owning declaration rather than from source line breaks. + fn parse_scoped_vocab_braced_body( + &mut self, + keyword_name: &str, + clause_body_kind: Option, + expression_item_modifiers: Vec, + ) -> Result>, CompileError> { + self.vocab_block_stack.push(keyword_name.to_string()); + self.vocab_body_kind_stack.push(clause_body_kind); + self.vocab_expression_item_modifier_stack + .push(expression_item_modifiers); + let body = self.braced_vocab_body(None); + self.vocab_block_stack.pop(); + self.vocab_body_kind_stack.pop(); + self.vocab_expression_item_modifier_stack.pop(); + body + } + + /// Parse braced body items until the matching `}`. + fn braced_vocab_body( + &mut self, + sibling_parent_keyword: Option, + ) -> Result>, CompileError> { + let mut body = Vec::new(); + self.skip_newlines(); + while !self.check_punct(PunctuationId::RBrace) && !self.is_at_end() { + if self.braced_vocab_body_boundary(sibling_parent_keyword.as_deref()) { + break; + } + if let Some(child) = self.try_braced_vocab_child()? { + body.push(child); + } else if self.vocab_expression_list_items_enabled() { + body.push(self.braced_vocab_expression_list_item()?); + } else { + let start = self.current_span().start; + let stmt = self.assignment_or_expr_stmt()?; + let end = self.tokens[self.pos.saturating_sub(1)].span.end; + body.push(Spanned::new(stmt, Span::new(start, end))); + } + self.skip_newlines(); + self.match_punct(PunctuationId::Comma); + self.skip_newlines(); + } + if sibling_parent_keyword.is_none() { + self.expect_punct(PunctuationId::RBrace, "Expected '}' after vocab expression block")?; + } + Ok(body) + } + + /// Parse one child clause/declaration inside a braced vocab body when metadata says the current token starts one. + fn try_braced_vocab_child(&mut self) -> Result>, CompileError> { + let parent_keyword = self.vocab_block_stack.last().cloned(); + let Some(keyword_name) = self.current_vocab_keyword_name() else { + return Ok(None); + }; + let Some(spec) = self + .find_active_vocab_block_spec(&keyword_name, parent_keyword.as_deref()) + .cloned() + else { + return Ok(None); + }; + let start = self.current_span().start; + let block = self.parse_braced_vocab_child_from_spec(keyword_name, spec, parent_keyword)?; + let end = self.tokens[self.pos.saturating_sub(1)].span.end; + Ok(Some(Spanned::new( + Statement::VocabBlock(block), + Span::new(start, end), + ))) + } + + /// Parse one metadata-selected child item in braced vocab syntax. + fn parse_braced_vocab_child_from_spec( + &mut self, + keyword_name: String, + spec: ActiveImportedKeywordSpec, + sibling_parent_keyword: Option, + ) -> Result { + let spec_compound_tokens = spec.compound_tokens.clone(); + self.advance(); + self.consume_vocab_compound_tokens(&spec_compound_tokens)?; + + let body = if self.match_punct(PunctuationId::Colon) { + self.expect(&TokenKind::Newline, "Expected newline after ':'")?; + self.expect_suite_indent("Expected indented block after vocab keyword")?; + let body = self.parse_scoped_vocab_indented_body( + &keyword_name, + spec.clause_body_kind, + spec.expression_item_modifiers.clone(), + )?; + self.expect(&TokenKind::Dedent, "Expected dedent after vocab block body")?; + body + } else if self.match_punct(PunctuationId::LBrace) { + self.parse_scoped_vocab_braced_body( + &keyword_name, + spec.clause_body_kind, + spec.expression_item_modifiers.clone(), + )? + } else { + self.parse_braced_vocab_inline_body( + &keyword_name, + spec.clause_body_kind, + spec.expression_item_modifiers.clone(), + sibling_parent_keyword, + )? + }; + + Ok(VocabBlockStmt { + keyword: keyword_name, + keyword_binding: VocabKeywordBinding { + dependency_key: spec.dependency_key, + activation_namespace: spec.activation_namespace, + surface_kind: spec.surface_kind, + compound_tokens: spec_compound_tokens, + placement: spec.placement, + clause_body_kind: spec.clause_body_kind, + }, + decorators: Vec::new(), + header_args: Vec::new(), + body, + }) + } + + /// Parse the inline body after a braced child keyword, e.g. `FROM orders` or `SELECT amount as total`. + fn parse_braced_vocab_inline_body( + &mut self, + keyword_name: &str, + clause_body_kind: Option, + expression_item_modifiers: Vec, + sibling_parent_keyword: Option, + ) -> Result>, CompileError> { + self.vocab_block_stack.push(keyword_name.to_string()); + self.vocab_body_kind_stack.push(clause_body_kind); + self.vocab_expression_item_modifier_stack + .push(expression_item_modifiers); + + let body = match clause_body_kind { + Some(incan_vocab::ClauseBodyKind::ExpressionList) => { + self.braced_expression_items_until_boundary(sibling_parent_keyword.as_deref()) + } + Some(incan_vocab::ClauseBodyKind::Expression) | None => { + self.braced_single_expression_until_boundary(sibling_parent_keyword.as_deref()) + } + _ => self.braced_vocab_body(sibling_parent_keyword), + }; + + self.vocab_block_stack.pop(); + self.vocab_body_kind_stack.pop(); + self.vocab_expression_item_modifier_stack.pop(); + body + } + + /// Parse one expression clause body in braced syntax. + fn braced_single_expression_until_boundary( + &mut self, + sibling_parent_keyword: Option<&str>, + ) -> Result>, CompileError> { + if self.braced_vocab_body_boundary(sibling_parent_keyword) { + return Ok(Vec::new()); + } + let start = self.current_span().start; + let expr = self.expression()?; + let end = expr.span.end; + Ok(vec![Spanned::new(Statement::Expr(expr), Span::new(start, end))]) + } + + /// Parse expression-list entries in braced syntax until the next sibling clause/declaration. + fn braced_expression_items_until_boundary( + &mut self, + sibling_parent_keyword: Option<&str>, + ) -> Result>, CompileError> { + let mut statements = Vec::new(); + while !self.braced_vocab_body_boundary(sibling_parent_keyword) { + statements.push(self.braced_vocab_expression_list_item()?); + self.skip_newlines(); + if !self.match_punct(PunctuationId::Comma) && self.braced_vocab_body_boundary(sibling_parent_keyword) { + break; + } + self.skip_newlines(); + } + Ok(statements) + } + + /// Parse one expression-list item in braced syntax. + fn braced_vocab_expression_list_item(&mut self) -> Result, CompileError> { + let start = self.current_span().start; + let expr = self.expression()?; + let statement = if let Some(item) = self.vocab_expression_list_item_tail(expr.clone())? { + Statement::VocabExpressionItem(item) + } else { + Statement::Expr(expr) + }; + let end = self.tokens[self.pos.saturating_sub(1)].span.end; + Ok(Spanned::new(statement, Span::new(start, end))) + } + + /// Return whether braced parsing reached a terminator for the current inline body. + fn braced_vocab_body_boundary(&self, sibling_parent_keyword: Option<&str>) -> bool { + self.check_punct(PunctuationId::RBrace) + || self.is_at_end() + || sibling_parent_keyword + .is_some_and(|parent| self.current_starts_vocab_block_for_parent(Some(parent))) + } + /// Parse `partial Target(name=value)` after the `partial` marker has already been consumed. fn partial_expr(&mut self, start: usize) -> Result, CompileError> { let template = self.postfix()?; @@ -1487,6 +1845,7 @@ impl<'a> Parser<'a> { } } }, + Expr::VocabBlock(_) => {} } } diff --git a/crates/incan_syntax/src/parser/stmts.rs b/crates/incan_syntax/src/parser/stmts.rs index c8468ec21..585f15ba0 100644 --- a/crates/incan_syntax/src/parser/stmts.rs +++ b/crates/incan_syntax/src/parser/stmts.rs @@ -175,6 +175,7 @@ impl<'a> Parser<'a> { )); }; let spec_keyword_name = spec.keyword_name.clone(); + let spec_compound_tokens = spec.compound_tokens.clone(); let spec_dependency_key = spec.dependency_key.clone(); let spec_activation_namespace = spec.activation_namespace.clone(); let spec_surface_kind = spec.surface_kind; @@ -185,55 +186,78 @@ impl<'a> Parser<'a> { // Avoid committing to vocab-block parsing unless a top-level header-delimiting `:` is visible ahead. This // preserves `assignment_or_expr_stmt` fallback for statements like `route = "/health"`, `route(args)`, and - // `route: str = "/health"` when `route` is an imported vocab keyword. - if decorators.is_empty() && !self.has_top_level_colon_before_statement_end(self.pos + 1) { + // `route: str = "/health"` when `route` is an imported vocab keyword. Clause keywords inside an owning vocab + // block may still use an inline body (`FROM orders`), but only when the registered clause body kind makes that + // expression payload explicit. + let has_header_colon = self.has_top_level_colon_before_statement_end(self.pos + 1); + let parses_inline_clause = decorators.is_empty() + && parent_keyword.is_some() + && matches!( + spec_surface_kind, + incan_vocab::KeywordSurfaceKind::BlockContextKeyword | incan_vocab::KeywordSurfaceKind::SubBlock + ) + && matches!( + spec_clause_body_kind, + Some(incan_vocab::ClauseBodyKind::Expression | incan_vocab::ClauseBodyKind::ExpressionList) + ); + if decorators.is_empty() && !has_header_colon && !parses_inline_clause { return Ok(None); } self.advance(); + self.consume_vocab_compound_tokens(&spec_compound_tokens)?; let mut header_args = Vec::new(); - if !self.check_punct(PunctuationId::Colon) { - header_args.push(self.expression()?); - while self.match_punct(PunctuationId::Comma) { + let body = if parses_inline_clause && !has_header_colon { + self.parse_inline_vocab_clause_body( + &keyword_name, + spec_clause_body_kind, + spec_expression_item_modifiers, + )? + } else { + if !self.check_punct(PunctuationId::Colon) { header_args.push(self.expression()?); + while self.match_punct(PunctuationId::Comma) { + header_args.push(self.expression()?); + } } - } - self.expect_punct(PunctuationId::Colon, "Expected ':' after vocab block header")?; - self.expect(&TokenKind::Newline, "Expected newline after ':'")?; - self.expect_suite_indent("Expected indented block after vocab keyword")?; - - if !spec_valid_decorators.is_empty() { - for decorator in &decorators { - let decorator_name = decorator.node.name.as_str(); - let decorator_full_name = decorator.node.path.segments.join("."); - let is_valid = spec_valid_decorators.iter().any(|allowed| { - let normalized = allowed.trim().trim_start_matches('@'); - normalized == decorator_name || normalized == decorator_full_name - }); - if !is_valid { - return Err(errors::expected_token_message( - &format!( - "Decorator `{decorator_full_name}` is not valid on vocab block `{}`", - spec_keyword_name - ), - &format!("{:?}", decorator.node), - decorator.span, - )); + self.expect_punct(PunctuationId::Colon, "Expected ':' after vocab block header")?; + self.expect(&TokenKind::Newline, "Expected newline after ':'")?; + self.expect_suite_indent("Expected indented block after vocab keyword")?; + + if !spec_valid_decorators.is_empty() { + for decorator in &decorators { + let decorator_name = decorator.node.name.as_str(); + let decorator_full_name = decorator.node.path.segments.join("."); + let is_valid = spec_valid_decorators.iter().any(|allowed| { + let normalized = allowed.trim().trim_start_matches('@'); + normalized == decorator_name || normalized == decorator_full_name + }); + if !is_valid { + return Err(errors::expected_token_message( + &format!( + "Decorator `{decorator_full_name}` is not valid on vocab block `{}`", + spec_keyword_name + ), + &format!("{:?}", decorator.node), + decorator.span, + )); + } } } - } - self.vocab_block_stack.push(keyword_name.clone()); - self.vocab_body_kind_stack.push(spec_clause_body_kind); - self.vocab_expression_item_modifier_stack - .push(spec_expression_item_modifiers); - let body = self.block(); - self.vocab_block_stack.pop(); - self.vocab_body_kind_stack.pop(); - self.vocab_expression_item_modifier_stack.pop(); - let body = body?; - self.expect(&TokenKind::Dedent, "Expected dedent after vocab block body")?; + self.vocab_block_stack.push(keyword_name.clone()); + self.vocab_body_kind_stack.push(spec_clause_body_kind); + self.vocab_expression_item_modifier_stack + .push(spec_expression_item_modifiers); + let body = self.block(); + self.vocab_block_stack.pop(); + self.vocab_body_kind_stack.pop(); + self.vocab_expression_item_modifier_stack.pop(); + let body = body?; + self.expect(&TokenKind::Dedent, "Expected dedent after vocab block body")?; + body + }; Ok(Some(Statement::VocabBlock(VocabBlockStmt { keyword: keyword_name, @@ -241,6 +265,7 @@ impl<'a> Parser<'a> { dependency_key: spec_dependency_key, activation_namespace: spec_activation_namespace, surface_kind: spec_surface_kind, + compound_tokens: spec_compound_tokens, placement: spec_placement, clause_body_kind: spec_clause_body_kind, }, @@ -250,6 +275,56 @@ impl<'a> Parser<'a> { }))) } + /// Parse an indentation-line clause with an inline expression payload, such as `FROM orders`. + fn parse_inline_vocab_clause_body( + &mut self, + keyword_name: &str, + clause_body_kind: Option, + expression_item_modifiers: Vec, + ) -> Result>, CompileError> { + self.vocab_block_stack.push(keyword_name.to_string()); + self.vocab_body_kind_stack.push(clause_body_kind); + self.vocab_expression_item_modifier_stack + .push(expression_item_modifiers); + let body = match clause_body_kind { + Some(incan_vocab::ClauseBodyKind::ExpressionList) => self.inline_vocab_expression_list_body(), + Some(incan_vocab::ClauseBodyKind::Expression) => self.inline_vocab_expression_body(), + _ => Ok(Vec::new()), + }; + self.vocab_block_stack.pop(); + self.vocab_body_kind_stack.pop(); + self.vocab_expression_item_modifier_stack.pop(); + body + } + + /// Parse one inline expression clause body until the physical statement boundary. + fn inline_vocab_expression_body(&mut self) -> Result>, CompileError> { + if self.current_ends_inline_vocab_clause() { + return Ok(Vec::new()); + } + let start = self.current_span().start; + let expr = self.expression()?; + let end = expr.span.end; + Ok(vec![Spanned::new(Statement::Expr(expr), Span::new(start, end))]) + } + + /// Parse comma-separated inline expression-list items until the physical statement boundary. + fn inline_vocab_expression_list_body(&mut self) -> Result>, CompileError> { + let mut body = Vec::new(); + while !self.current_ends_inline_vocab_clause() { + body.push(self.braced_vocab_expression_list_item()?); + if !self.match_punct(PunctuationId::Comma) { + break; + } + } + Ok(body) + } + + /// Return whether the current token ends an inline clause payload. + fn current_ends_inline_vocab_clause(&self) -> bool { + matches!(self.peek().kind, TokenKind::Newline | TokenKind::Dedent | TokenKind::Eof) + } + /// Return `true` if there is a top-level block-header `:` before the current statement ends. /// /// This is used as a lookahead gate for imported vocab block keywords so we only consume the keyword token when the @@ -295,6 +370,7 @@ impl<'a> Parser<'a> { false } + /// Find the active vocab block metadata for the current keyword and parent block context. fn find_active_vocab_block_spec( &self, keyword_name: &str, @@ -307,7 +383,8 @@ impl<'a> Parser<'a> { incan_vocab::KeywordSurfaceKind::BlockDeclaration | incan_vocab::KeywordSurfaceKind::BlockContextKeyword | incan_vocab::KeywordSurfaceKind::SubBlock - ) && match (&spec.placement, parent_keyword) { + ) && self.vocab_compound_tokens_match_at(&spec.compound_tokens, self.pos + 1) + && match (&spec.placement, parent_keyword) { (incan_vocab::KeywordPlacement::TopLevel, None) => true, (incan_vocab::KeywordPlacement::TopLevel, Some(_)) => false, (incan_vocab::KeywordPlacement::InBlock(allowed), Some(parent)) => { @@ -319,6 +396,65 @@ impl<'a> Parser<'a> { }) } + /// Return whether the current token starts a registered vocab block in the requested parent context. + fn current_starts_vocab_block_for_parent(&self, parent_keyword: Option<&str>) -> bool { + let Some(keyword_name) = self.current_vocab_keyword_name() else { + return false; + }; + self.find_active_vocab_block_spec(&keyword_name, parent_keyword) + .is_some() + } + + /// Return the current identifier or keyword spelling if it can name a vocab keyword. + fn current_vocab_keyword_name(&self) -> Option { + match &self.peek().kind { + TokenKind::Ident(name) => Some(name.clone()), + TokenKind::Keyword(id) => Some(incan_core::lang::keywords::as_str(*id).to_string()), + _ => None, + } + } + + /// Return true when the token stream contains the expected compound keyword tail at `start_idx`. + fn vocab_compound_tokens_match_at(&self, compound_tokens: &[String], start_idx: usize) -> bool { + compound_tokens.iter().enumerate().all(|(offset, expected)| { + self.tokens + .get(start_idx + offset) + .and_then(|token| Self::vocab_word_token_from_kind(&token.kind)) + .is_some_and(|actual| actual == expected) + }) + } + + /// Consume a compound keyword tail already selected by metadata-driven lookahead. + fn consume_vocab_compound_tokens(&mut self, compound_tokens: &[String]) -> Result<(), CompileError> { + for expected in compound_tokens { + let Some(actual) = Self::vocab_word_token_from_kind(&self.peek().kind) else { + return Err(errors::expected_token_message( + &format!("Expected compound vocab keyword token `{expected}`"), + &format!("{:?}", self.peek().kind), + self.current_span(), + )); + }; + if actual != expected { + return Err(errors::expected_token_message( + &format!("Expected compound vocab keyword token `{expected}`"), + actual, + self.current_span(), + )); + } + self.advance(); + } + Ok(()) + } + + /// Return a token spelling usable for metadata-driven vocab keyword matching. + fn vocab_word_token_from_kind(kind: &TokenKind) -> Option<&str> { + match kind { + TokenKind::Ident(name) => Some(name.as_str()), + TokenKind::Keyword(id) => Some(incan_core::lang::keywords::as_str(*id)), + _ => None, + } + } + /// Parse a generic soft-keyword statement payload (`kw expr[, expr]`) and hand off to semantics. fn try_surface_keyword_statement(&mut self) -> Result, CompileError> { let Some(id) = self.current_surface_keyword(KeywordSurfaceKind::StatementKeywordArgs) else { diff --git a/crates/incan_syntax/src/parser/tests.rs b/crates/incan_syntax/src/parser/tests.rs index 7e0b27eeb..ee823ae5f 100644 --- a/crates/incan_syntax/src/parser/tests.rs +++ b/crates/incan_syntax/src/parser/tests.rs @@ -4560,6 +4560,139 @@ def has_name(name: str | None) -> bool: Ok(()) } + #[test] + fn test_expression_desugaring_vocab_block_parses_in_assignment_value() -> Result<(), Box> { + let source = + "import pub::analytics\n\ndef configure() -> None:\n value = query:\n FROM orders\n SELECT:\n amount as total\n"; + let tokens = crate::lexer::lex(source).map_err(|errs| format!("lex errors: {errs:?}"))?; + + let metadata = incan_vocab::VocabRegistration::new() + .with_surface( + incan_vocab::DslSurface::on_import("analytics.query").with_declaration( + incan_vocab::DeclarationSurface::named("query") + .with_clause_body() + .desugars_to_expression() + .with_clause(incan_vocab::ClauseSurface::expr("FROM").required()) + .with_clause(incan_vocab::ClauseSurface::expr_list("SELECT").required()), + ), + ) + .metadata(); + let mut keyword_map = std::collections::HashMap::new(); + keyword_map.insert("analytics".to_string(), metadata.keyword_registrations); + let mut surface_map = std::collections::HashMap::new(); + surface_map.insert("analytics".to_string(), metadata.dsl_surfaces); + + let program = + crate::parser::parse_with_context_and_surfaces(&tokens, None, Some(&keyword_map), Some(&surface_map)) + .map_err(|errs| format!("parse errors: {errs:?}"))?; + let function = match &program.declarations[1].node { + crate::ast::Declaration::Function(function) => function, + other => return Err(format!("expected function declaration, got {other:?}").into()), + }; + let crate::ast::Statement::Assignment(assign) = &function.body[0].node else { + return Err(format!("expected assignment, got {:?}", function.body[0].node).into()); + }; + let crate::ast::Expr::VocabBlock(block) = &assign.value.node else { + return Err(format!("expected vocab expression block, got {:?}", assign.value.node).into()); + }; + assert_eq!(block.keyword, "query"); + assert!(matches!( + &block.body[0].node, + crate::ast::Statement::VocabBlock(from) + if from.keyword == "FROM" + && matches!( + &from.body[0].node, + crate::ast::Statement::Expr(expr) + if matches!(&expr.node, crate::ast::Expr::Ident(name) if name == "orders") + ) + )); + assert!(matches!( + &block.body[1].node, + crate::ast::Statement::VocabBlock(select) + if select.keyword == "SELECT" + && matches!( + &select.body[0].node, + crate::ast::Statement::VocabExpressionItem(item) + if item.alias.as_deref() == Some("total") + ) + )); + Ok(()) + } + + #[test] + fn test_braced_expression_vocab_block_uses_clause_metadata_boundaries() + -> Result<(), Box> { + let source = + "import pub::analytics\n\ndef configure() -> None:\n value = query { FROM orders GROUP BY amount as grouped SELECT total as total }\n"; + let tokens = crate::lexer::lex(source).map_err(|errs| format!("lex errors: {errs:?}"))?; + + let metadata = incan_vocab::VocabRegistration::new() + .with_surface( + incan_vocab::DslSurface::on_import("analytics.query").with_declaration( + incan_vocab::DeclarationSurface::named("query") + .with_clause_body() + .desugars_to_expression() + .with_clauses([ + incan_vocab::ClauseSurface::expr("FROM").required(), + incan_vocab::ClauseSurface::expr_list("GROUP BY").optional(), + incan_vocab::ClauseSurface::expr_list("SELECT").required(), + ]), + ), + ) + .metadata(); + let mut keyword_map = std::collections::HashMap::new(); + keyword_map.insert("analytics".to_string(), metadata.keyword_registrations); + let mut surface_map = std::collections::HashMap::new(); + surface_map.insert("analytics".to_string(), metadata.dsl_surfaces); + + let program = + crate::parser::parse_with_context_and_surfaces(&tokens, None, Some(&keyword_map), Some(&surface_map)) + .map_err(|errs| format!("parse errors: {errs:?}"))?; + let function = match &program.declarations[1].node { + crate::ast::Declaration::Function(function) => function, + other => return Err(format!("expected function declaration, got {other:?}").into()), + }; + let crate::ast::Statement::Assignment(assign) = &function.body[0].node else { + return Err(format!("expected assignment, got {:?}", function.body[0].node).into()); + }; + let crate::ast::Expr::VocabBlock(block) = &assign.value.node else { + return Err(format!("expected vocab expression block, got {:?}", assign.value.node).into()); + }; + assert_eq!(block.body.len(), 3); + assert!(matches!( + &block.body[0].node, + crate::ast::Statement::VocabBlock(from) + if from.keyword == "FROM" + && matches!( + &from.body[0].node, + crate::ast::Statement::Expr(expr) + if matches!(&expr.node, crate::ast::Expr::Ident(name) if name == "orders") + ) + )); + assert!(matches!( + &block.body[1].node, + crate::ast::Statement::VocabBlock(group) + if group.keyword == "GROUP" + && group.keyword_binding.compound_tokens == vec!["BY".to_string()] + && matches!( + &group.body[0].node, + crate::ast::Statement::VocabExpressionItem(item) + if item.alias.as_deref() == Some("grouped") + ) + )); + assert!(matches!( + &block.body[2].node, + crate::ast::Statement::VocabBlock(select) + if select.keyword == "SELECT" + && matches!( + &select.body[0].node, + crate::ast::Statement::VocabExpressionItem(item) + if item.alias.as_deref() == Some("total") + ) + )); + Ok(()) + } + #[test] fn test_scoped_symbol_descriptor_does_not_change_call_outside_vocab_block() -> Result<(), Box> { diff --git a/crates/incan_vocab/README.md b/crates/incan_vocab/README.md index 283bba245..3a30a49cf 100644 --- a/crates/incan_vocab/README.md +++ b/crates/incan_vocab/README.md @@ -98,6 +98,8 @@ pub fn library_vocab() -> VocabRegistration { `VocabRegistration` is the source of truth for one library's activated DSL surfaces, machine-readable manifest metadata, and optional Rust desugarer. +Declarations marked with `DeclarationSurface::desugars_to_expression()` are value-producing DSL forms. The compiler accepts them in expression positions such as assignments and returns, then hands the structured `VocabDeclaration` to the desugarer before typechecking the returned expression. Clause boundaries, expression-list aliases/modifiers, and compound clause tokens such as `GROUP BY` are preserved in the public AST instead of being reparsed from source text. + ### High-level surface types These are the main author-facing types: diff --git a/crates/incan_vocab/src/ast.rs b/crates/incan_vocab/src/ast.rs index 32bf5ccfa..22a6dbf73 100644 --- a/crates/incan_vocab/src/ast.rs +++ b/crates/incan_vocab/src/ast.rs @@ -195,6 +195,9 @@ pub enum VocabClauseBody { pub struct VocabDeclaration { /// The leading keyword that introduced the declaration. pub keyword: String, + /// Additional tokens for compound declaration spellings such as `MATCH AGAINST`. + #[cfg_attr(feature = "serde", serde(default))] + pub compound_tokens: Vec, /// Optional parser-provided keyword metadata for resolver/runtime routing. pub keyword_metadata: Option, /// Structured declaration head preserved across the parse/desugar boundary. diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index 95068f23a..9ce492bfc 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -1155,6 +1155,16 @@ impl AstLowering { } } + ast::Expr::VocabBlock(block) => { + return Err(LoweringError { + message: format!( + "vocab expression declaration `{}` reached lowering before desugaring", + block.keyword + ), + span: super::super::IrSpan::default(), + }); + } + // ---- Try (?) ---- ast::Expr::Try(e) => { let inner = self.lower_expr_spanned(e)?; diff --git a/src/backend/ir/lower/stmt.rs b/src/backend/ir/lower/stmt.rs index 39c3a76c0..9cd23479d 100644 --- a/src/backend/ir/lower/stmt.rs +++ b/src/backend/ir/lower/stmt.rs @@ -2225,6 +2225,14 @@ impl AstLowering { self.count_expr_ident_reads(&start.node, counts); self.count_expr_ident_reads(&end.node, counts); } + ast::Expr::VocabBlock(block) => { + for arg in &block.header_args { + self.count_expr_ident_reads(&arg.node, counts); + } + for stmt in &block.body { + self.count_statement_ident_reads(&stmt.node, counts); + } + } } } } diff --git a/src/cli/test_runner/execution.rs b/src/cli/test_runner/execution.rs index 76cb02f6a..12fc1181e 100644 --- a/src/cli/test_runner/execution.rs +++ b/src/cli/test_runner/execution.rs @@ -1040,6 +1040,13 @@ fn expr_references_name(expr: &Expr, name: &str) -> bool { Expr::Range { start, end, .. } => { expr_references_name(&start.node, name) || expr_references_name(&end.node, name) } + Expr::VocabBlock(block) => { + block + .header_args + .iter() + .any(|arg| expr_references_name(&arg.node, name)) + || body_references_name(&block.body, name) + } Expr::Literal(_) | Expr::SelfExpr | Expr::Yield(None) | Expr::Partial(_) | Expr::Surface(_) => false, } } diff --git a/src/format/formatter/expressions.rs b/src/format/formatter/expressions.rs index 5e0a9ae90..8147be63c 100644 --- a/src/format/formatter/expressions.rs +++ b/src/format/formatter/expressions.rs @@ -253,6 +253,26 @@ impl Formatter { } _ => self.writer.write(""), }, + Expr::VocabBlock(block) => { + self.writer.write(&block.keyword); + for token in &block.keyword_binding.compound_tokens { + self.writer.write(" "); + self.writer.write(token); + } + for arg in &block.header_args { + self.writer.write(" "); + self.format_expr(&arg.node); + } + self.writer.writeln(":"); + self.writer.indent(); + for stmt in &block.body { + self.format_statement(stmt); + } + if block.body.is_empty() { + self.writer.writeln("pass"); + } + self.writer.dedent(); + } Expr::Try(inner) => { self.format_expr(&inner.node); self.writer.write("?"); diff --git a/src/frontend/ast_walk.rs b/src/frontend/ast_walk.rs index c9baaa7a1..23385f238 100644 --- a/src/frontend/ast_walk.rs +++ b/src/frontend/ast_walk.rs @@ -388,6 +388,9 @@ where crate::frontend::ast::FStringPart::Expr { expr, .. } => expr_has(&expr.node, pred), }), Expr::Yield(Some(expr)) => expr_has(&expr.node, pred), + Expr::VocabBlock(block) => { + block.header_args.iter().any(|arg| expr_has(&arg.node, pred)) || any_expr_in_body_impl(&block.body, pred) + } Expr::Yield(None) | Expr::Partial(_) => false, } } diff --git a/src/frontend/typechecker/check_expr/mod.rs b/src/frontend/typechecker/check_expr/mod.rs index ffe454417..6056b0c20 100644 --- a/src/frontend/typechecker/check_expr/mod.rs +++ b/src/frontend/typechecker/check_expr/mod.rs @@ -230,6 +230,16 @@ impl TypeChecker { end, inclusive: _, } => self.check_range_expr(start, end), + Expr::VocabBlock(block) => { + self.errors.push(CompileError::type_error( + format!( + "Vocab expression declaration `{}` reached typechecking before desugaring", + block.keyword + ), + expr.span, + )); + ResolvedType::Unknown + } }; // Record for downstream stages (lowering/codegen). diff --git a/src/frontend/typechecker/const_eval.rs b/src/frontend/typechecker/const_eval.rs index 799727cc1..6bd867acc 100644 --- a/src/frontend/typechecker/const_eval.rs +++ b/src/frontend/typechecker/const_eval.rs @@ -859,6 +859,7 @@ impl TypeChecker { | Expr::Range { .. } | Expr::Field(_, _) | Expr::Surface(_) + | Expr::VocabBlock(_) | Expr::Try(_) | Expr::Paren(_) | Expr::Constructor(_, _) diff --git a/src/frontend/typechecker/mod.rs b/src/frontend/typechecker/mod.rs index b3de24205..9e3d8beca 100644 --- a/src/frontend/typechecker/mod.rs +++ b/src/frontend/typechecker/mod.rs @@ -1978,6 +1978,14 @@ impl TypeChecker { self.collect_static_dependencies_from_call_args(args, deps, visiting_functions); } }, + Expr::VocabBlock(block) => { + for arg in &block.header_args { + self.collect_static_dependencies_from_expr(&arg.node, deps, visiting_functions); + } + for stmt in &block.body { + self.collect_static_dependencies_from_statement(&stmt.node, deps, visiting_functions); + } + } } } @@ -2288,6 +2296,14 @@ impl TypeChecker { ); } }, + Expr::VocabBlock(block) => { + for arg in &block.header_args { + self.collect_static_initializer_static_writes_from_expr(arg, current_static, visiting_functions); + } + for stmt in &block.body { + self.collect_static_initializer_static_writes_from_stmt(stmt, current_static, visiting_functions); + } + } } } diff --git a/src/frontend/vocab_ast_bridge.rs b/src/frontend/vocab_ast_bridge.rs index 12787360b..3343595a1 100644 --- a/src/frontend/vocab_ast_bridge.rs +++ b/src/frontend/vocab_ast_bridge.rs @@ -81,6 +81,7 @@ pub fn internal_vocab_block_to_public( Ok(incan_vocab::VocabDeclaration { keyword: block.keyword.clone(), + compound_tokens: block.keyword_binding.compound_tokens.clone(), keyword_metadata: Some(incan_vocab::VocabKeywordMetadata { dependency_key: block.keyword_binding.dependency_key.clone(), activation_namespace: block.keyword_binding.activation_namespace.clone(), @@ -152,7 +153,7 @@ fn internal_vocab_clause_to_public( let body = internal_clause_body_to_public(&block.body, block.keyword_binding.clause_body_kind)?; Ok(incan_vocab::VocabClause { keyword: block.keyword.clone(), - compound_tokens: Vec::new(), + compound_tokens: block.keyword_binding.compound_tokens.clone(), head, body, span: public_span(span), @@ -714,7 +715,7 @@ fn internal_race_for_to_public(race: &ast::RaceForExpr) -> Result Result { +pub fn public_expr_to_internal(expr: &incan_vocab::IncanExpr) -> Result { match expr { incan_vocab::IncanExpr::Name(name) => Ok(ast::Expr::Ident(name.clone())), incan_vocab::IncanExpr::Str(value) => Ok(ast::Expr::Literal(ast::Literal::String(value.clone()))), @@ -785,6 +786,17 @@ fn public_expr_to_internal(expr: &incan_vocab::IncanExpr) -> Result, _>>()?; + if let incan_vocab::IncanExpr::Field { object, field } = callee.as_ref() { + return Ok(ast::Expr::MethodCall( + Box::new(ast::Spanned::new( + public_expr_to_internal(object)?, + ast::Span::default(), + )), + field.clone(), + Vec::new(), + mapped, + )); + } Ok(ast::Expr::Call( Box::new(ast::Spanned::new( public_expr_to_internal(callee)?, @@ -1080,6 +1092,7 @@ mod tests { dependency_key: "demo".to_string(), activation_namespace: "demo.dsl".to_string(), surface_kind, + compound_tokens: Vec::new(), placement: incan_vocab::KeywordPlacement::TopLevel, clause_body_kind: None, } @@ -1131,6 +1144,44 @@ mod tests { Ok(()) } + #[test] + fn bridges_compound_declaration_tokens() -> Result<(), Box> { + let mut keyword_binding = default_keyword_binding(incan_vocab::KeywordSurfaceKind::BlockDeclaration); + keyword_binding.compound_tokens = vec!["AGAINST".to_string()]; + let block = ast::VocabBlockStmt { + keyword: "MATCH".to_string(), + keyword_binding, + decorators: Vec::new(), + header_args: Vec::new(), + body: Vec::new(), + }; + + let bridged = internal_vocab_block_to_public(&block, ast::Span::default())?; + assert_eq!(bridged.keyword, "MATCH"); + assert_eq!(bridged.compound_tokens, vec!["AGAINST".to_string()]); + Ok(()) + } + + #[test] + fn bridges_public_field_callee_calls_back_to_method_calls() -> Result<(), Box> { + let expr = incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Field { + object: Box::new(incan_vocab::IncanExpr::Name("orders".to_string())), + field: "select".to_string(), + }), + args: vec![incan_vocab::IncanExpr::Name("amount".to_string())], + }; + + let internal = public_expr_to_internal(&expr)?; + let ast::Expr::MethodCall(receiver, method, _, args) = internal else { + return Err(format!("expected public field-callee call to bridge as method call, got {internal:?}").into()); + }; + assert_eq!(method, "select"); + assert!(matches!(receiver.node, ast::Expr::Ident(ref name) if name == "orders")); + assert_eq!(args.len(), 1); + Ok(()) + } + #[test] fn bridges_expression_list_clause_alias_items() -> Result<(), Box> { let clause_block = ast::VocabBlockStmt { diff --git a/src/frontend/vocab_desugar_pass/helper_bindings.rs b/src/frontend/vocab_desugar_pass/helper_bindings.rs index 1f7fff1ca..e63d4b6f1 100644 --- a/src/frontend/vocab_desugar_pass/helper_bindings.rs +++ b/src/frontend/vocab_desugar_pass/helper_bindings.rs @@ -182,7 +182,7 @@ fn resolve_helper_bindings_in_statement( } /// Resolve helper references recursively inside one desugared public expression. -fn resolve_helper_bindings_in_expr( +pub(super) fn resolve_helper_bindings_in_expr( expr: &mut incan_vocab::IncanExpr, keyword_metadata: Option<&incan_vocab::VocabKeywordMetadata>, keyword: &str, diff --git a/src/frontend/vocab_desugar_pass/rewrite.rs b/src/frontend/vocab_desugar_pass/rewrite.rs index 9377f977f..67d4ef531 100644 --- a/src/frontend/vocab_desugar_pass/rewrite.rs +++ b/src/frontend/vocab_desugar_pass/rewrite.rs @@ -1,9 +1,14 @@ use crate::frontend::ast; use crate::frontend::diagnostics::CompileError; use crate::frontend::library_manifest_index::LibraryManifestIndex; -use crate::frontend::vocab_ast_bridge::{internal_vocab_block_to_public, public_statements_to_internal}; +use crate::frontend::vocab_ast_bridge::{ + internal_vocab_block_to_public, public_expr_to_internal, public_statements_to_internal, +}; -use super::helper_bindings::{HelperImportAccumulator, inject_helper_imports, resolve_helper_bindings_in_statements}; +use super::helper_bindings::{ + HelperImportAccumulator, inject_helper_imports, resolve_helper_bindings_in_expr, + resolve_helper_bindings_in_statements, +}; use super::{VocabDesugarPassError, WasmDesugarerRuntime}; /// Rewrite all raw vocab blocks in a parsed program before typechecking/lowering. @@ -36,84 +41,294 @@ pub fn desugar_program_vocab_blocks( let mut helper_imports = HelperImportAccumulator::default(); for declaration in &mut program.declarations { - match &mut declaration.node { - ast::Declaration::Function(function) => rewrite_statement_list( + rewrite_declaration( + &mut declaration.node, + module_path, + library_manifest_index, + &mut runtime, + &mut helper_imports, + &mut errors, + ); + } + + if errors.is_empty() { + inject_helper_imports(program, &helper_imports); + Ok(()) + } else { + Err(errors) + } +} + +/// Rewrite vocab blocks inside every expression-bearing surface of one declaration. +fn rewrite_declaration( + declaration: &mut ast::Declaration, + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + match declaration { + ast::Declaration::Const(konst) => { + rewrite_spanned_expr( + &mut konst.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Declaration::Static(static_decl) => rewrite_spanned_expr( + &mut static_decl.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + ast::Declaration::Partial(partial) => { + rewrite_partial_args( + &mut partial.args, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Declaration::Function(function) => { + rewrite_statement_list( &mut function.body, module_path, library_manifest_index, - &mut runtime, - &mut helper_imports, - &mut errors, - ), - ast::Declaration::Model(model) => { - for method in &mut model.methods { - if let Some(body) = method.node.body.as_mut() { - rewrite_statement_list( - body, - module_path, - library_manifest_index, - &mut runtime, - &mut helper_imports, - &mut errors, - ); - } + runtime, + helper_imports, + errors, + ); + } + ast::Declaration::Model(model) => { + rewrite_field_defaults( + &mut model.fields, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_method_partial_args( + &mut model.method_partials, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for property in &mut model.properties { + if let Some(body) = property.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); } } - ast::Declaration::Class(class) => { - for method in &mut class.methods { - if let Some(body) = method.node.body.as_mut() { - rewrite_statement_list( - body, - module_path, - library_manifest_index, - &mut runtime, - &mut helper_imports, - &mut errors, - ); - } + for method in &mut model.methods { + if let Some(body) = method.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); } } - ast::Declaration::Trait(trait_decl) => { - for method in &mut trait_decl.methods { - if let Some(body) = method.node.body.as_mut() { - rewrite_statement_list( - body, - module_path, - library_manifest_index, - &mut runtime, - &mut helper_imports, - &mut errors, - ); - } + } + ast::Declaration::Class(class) => { + rewrite_field_defaults( + &mut class.fields, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_method_partial_args( + &mut class.method_partials, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for property in &mut class.properties { + if let Some(body) = property.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); } } - ast::Declaration::Newtype(newtype_decl) => { - for method in &mut newtype_decl.methods { - if let Some(body) = method.node.body.as_mut() { - rewrite_statement_list( - body, - module_path, - library_manifest_index, - &mut runtime, - &mut helper_imports, - &mut errors, - ); - } + for method in &mut class.methods { + if let Some(body) = method.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + ast::Declaration::Trait(trait_decl) => { + rewrite_method_partial_args( + &mut trait_decl.method_partials, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for property in &mut trait_decl.properties { + if let Some(body) = property.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); } } - _ => {} + for method in &mut trait_decl.methods { + if let Some(body) = method.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + ast::Declaration::Newtype(newtype_decl) => { + rewrite_method_partial_args( + &mut newtype_decl.method_partials, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for method in &mut newtype_decl.methods { + if let Some(body) = method.node.body.as_mut() { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + ast::Declaration::TestModule(test_module) => { + for nested in &mut test_module.body { + rewrite_declaration( + &mut nested.node, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } } + _ => {} } +} - if errors.is_empty() { - inject_helper_imports(program, &helper_imports); - Ok(()) - } else { - Err(errors) +/// Rewrite vocab expressions in model or class field default values. +fn rewrite_field_defaults( + fields: &mut [ast::Spanned], + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + for field in fields { + if let Some(default) = field.node.default.as_mut() { + rewrite_spanned_expr( + default, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } +} + +/// Rewrite vocab expressions inside method-level partial preset arguments. +fn rewrite_method_partial_args( + partials: &mut [ast::Spanned], + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + for partial in partials { + rewrite_partial_args( + &mut partial.node.args, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } +} + +/// Rewrite vocab expressions inside one partial preset argument list. +fn rewrite_partial_args( + args: &mut [ast::PartialArg], + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + for arg in args { + rewrite_spanned_expr( + &mut arg.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); } } -/// Recursively rewrite a statement list so no `Statement::VocabBlock` nodes survive past this pass. +/// Recursively rewrite a statement list so no raw vocab block statement or expression nodes survive past this pass. /// /// The recursion matters because desugarers may emit statements that themselves still contain nested control-flow /// bodies, and those bodies may contain additional vocab blocks introduced earlier by parsing. @@ -218,8 +433,86 @@ fn rewrite_statement_list( ); rewritten.extend(lowered); } + ast::Statement::Assignment(mut assignment) => { + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::Assignment(assignment), span)); + } + ast::Statement::FieldAssignment(mut assignment) => { + rewrite_spanned_expr( + &mut assignment.object, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::FieldAssignment(assignment), span)); + } + ast::Statement::IndexAssignment(mut assignment) => { + rewrite_spanned_expr( + &mut assignment.object, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + &mut assignment.index, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::IndexAssignment(assignment), span)); + } + ast::Statement::Return(mut expr) => { + if let Some(expr) = expr.as_mut() { + rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + rewritten.push(ast::Spanned::new(ast::Statement::Return(expr), span)); + } ast::Statement::If(mut if_stmt) => { // ---- Context: recurse into ordinary control-flow bodies ---- + rewrite_condition_exprs( + &mut if_stmt.condition, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); rewrite_statement_list( &mut if_stmt.then_body, module_path, @@ -228,7 +521,15 @@ fn rewrite_statement_list( helper_imports, errors, ); - for (_, elif_body) in &mut if_stmt.elif_branches { + for (elif_condition, elif_body) in &mut if_stmt.elif_branches { + rewrite_spanned_expr( + elif_condition, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); rewrite_statement_list( elif_body, module_path, @@ -251,6 +552,14 @@ fn rewrite_statement_list( rewritten.push(ast::Spanned::new(ast::Statement::If(if_stmt), span)); } ast::Statement::While(mut while_stmt) => { + rewrite_condition_exprs( + &mut while_stmt.condition, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); rewrite_statement_list( &mut while_stmt.body, module_path, @@ -273,6 +582,14 @@ fn rewrite_statement_list( rewritten.push(ast::Spanned::new(ast::Statement::Loop(loop_stmt), span)); } ast::Statement::For(mut for_stmt) => { + rewrite_spanned_expr( + &mut for_stmt.iter, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); rewrite_statement_list( &mut for_stmt.body, module_path, @@ -283,11 +600,882 @@ fn rewrite_statement_list( ); rewritten.push(ast::Spanned::new(ast::Statement::For(for_stmt), span)); } - other => rewritten.push(ast::Spanned::new(other, span)), - } - } - - *statements = rewritten; + ast::Statement::Expr(mut expr) => { + rewrite_spanned_expr( + &mut expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::Expr(expr), span)); + } + ast::Statement::VocabExpressionItem(mut item) => { + rewrite_spanned_expr( + &mut item.expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for modifier in &mut item.modifiers { + rewrite_spanned_expr( + &mut modifier.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + rewritten.push(ast::Spanned::new(ast::Statement::VocabExpressionItem(item), span)); + } + ast::Statement::Assert(mut assert_stmt) => { + rewrite_assert_exprs( + &mut assert_stmt, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::Assert(assert_stmt), span)); + } + ast::Statement::Break(mut expr) => { + if let Some(expr) = expr.as_mut() { + rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + rewritten.push(ast::Spanned::new(ast::Statement::Break(expr), span)); + } + ast::Statement::CompoundAssignment(mut assignment) => { + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::CompoundAssignment(assignment), span)); + } + ast::Statement::TupleUnpack(mut assignment) => { + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::TupleUnpack(assignment), span)); + } + ast::Statement::TupleAssign(mut assignment) => { + for target in &mut assignment.targets { + rewrite_spanned_expr( + target, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::TupleAssign(assignment), span)); + } + ast::Statement::ChainedAssignment(mut assignment) => { + rewrite_spanned_expr( + &mut assignment.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewritten.push(ast::Spanned::new(ast::Statement::ChainedAssignment(assignment), span)); + } + other => rewritten.push(ast::Spanned::new(other, span)), + } + } + + *statements = rewritten; +} + +/// Rewrite vocab expressions inside conditional expression wrappers. +fn rewrite_condition_exprs( + condition: &mut ast::Condition, + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + match condition { + ast::Condition::Expr(expr) | ast::Condition::Let { value: expr, .. } => rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + } +} + +/// Rewrite vocab expressions inside all assert statement payloads. +fn rewrite_assert_exprs( + assert_stmt: &mut ast::AssertStmt, + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + match &mut assert_stmt.kind { + ast::AssertKind::Condition(expr) => rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + ast::AssertKind::IsPattern { value, .. } => rewrite_spanned_expr( + value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + ast::AssertKind::Raises { call, .. } => rewrite_spanned_expr( + call, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + } + if let Some(message) = assert_stmt.message.as_mut() { + rewrite_spanned_expr( + message, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } +} + +/// Recursively rewrite raw vocab expression declarations nested inside ordinary expressions. +fn rewrite_spanned_expr( + expr: &mut ast::Spanned, + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + if matches!(expr.node, ast::Expr::VocabBlock(_)) { + let placeholder = ast::Expr::Literal(ast::Literal::None); + let ast::Expr::VocabBlock(block) = std::mem::replace(&mut expr.node, placeholder) else { + unreachable!("raw vocab expression was checked immediately before replacement"); + }; + match desugar_vocab_block_to_expression( + &block, + expr.span, + module_path, + library_manifest_index, + runtime, + helper_imports, + ) { + Ok(node) => expr.node = node, + Err(err) => { + errors.push(err); + return; + } + } + } + + match &mut expr.node { + ast::Expr::Binary(left, _, right) => { + rewrite_spanned_expr( + left, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + right, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Unary(_, inner) + | ast::Expr::Try(inner) + | ast::Expr::Paren(inner) + | ast::Expr::Yield(Some(inner)) => { + rewrite_spanned_expr( + inner, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Call(callee, _, args) => { + rewrite_spanned_expr( + callee, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_call_args( + args, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Index(base, index) => { + rewrite_spanned_expr( + base, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + index, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Slice(base, slice) => { + rewrite_spanned_expr( + base, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + if let Some(start) = slice.start.as_mut() { + rewrite_spanned_expr( + start, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + if let Some(end) = slice.end.as_mut() { + rewrite_spanned_expr( + end, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + if let Some(step) = slice.step.as_mut() { + rewrite_spanned_expr( + step, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + ast::Expr::Field(base, _) => { + rewrite_spanned_expr( + base, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::MethodCall(receiver, _, _, args) => { + rewrite_spanned_expr( + receiver, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_call_args( + args, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Partial(partial) => { + rewrite_spanned_expr( + &mut partial.target, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for arg in &mut partial.args { + rewrite_spanned_expr( + &mut arg.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + ast::Expr::Match(scrutinee, arms) => { + rewrite_spanned_expr( + scrutinee, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + for arm in arms { + if let Some(guard) = arm.node.guard.as_mut() { + rewrite_spanned_expr( + guard, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + match &mut arm.node.body { + ast::MatchBody::Expr(body) => rewrite_spanned_expr( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + ast::MatchBody::Block(body) => rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + } + } + } + ast::Expr::If(if_expr) => { + rewrite_spanned_expr( + &mut if_expr.condition, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_statement_list( + &mut if_expr.then_body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + if let Some(else_body) = if_expr.else_body.as_mut() { + rewrite_statement_list( + else_body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + ast::Expr::Loop(loop_expr) => { + rewrite_statement_list( + &mut loop_expr.body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::ListComp(comp) => { + rewrite_spanned_expr( + &mut comp.expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + &mut comp.iter, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + if let Some(filter) = comp.filter.as_mut() { + rewrite_spanned_expr( + filter, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + rewrite_comprehension_clauses( + &mut comp.clauses, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::DictComp(comp) => { + rewrite_spanned_expr( + &mut comp.key, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + &mut comp.value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + &mut comp.iter, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + if let Some(filter) = comp.filter.as_mut() { + rewrite_spanned_expr( + filter, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + rewrite_comprehension_clauses( + &mut comp.clauses, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Generator(generator) => { + rewrite_spanned_expr( + &mut generator.expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_comprehension_clauses( + &mut generator.clauses, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Closure(_, body) => { + rewrite_spanned_expr( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Tuple(items) | ast::Expr::Set(items) => { + for item in items { + rewrite_spanned_expr( + item, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + ast::Expr::List(items) => { + for item in items { + match item { + ast::ListEntry::Element(expr) | ast::ListEntry::Spread(expr) => { + rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + } + ast::Expr::Dict(entries) => { + for entry in entries { + match entry { + ast::DictEntry::Pair(key, value) => { + rewrite_spanned_expr( + key, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::DictEntry::Spread(value) => { + rewrite_spanned_expr( + value, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + } + ast::Expr::Constructor(_, args) => { + rewrite_call_args( + args, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::FString(parts) => { + for part in parts { + if let ast::FStringPart::Expr { expr, .. } = part { + rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + ast::Expr::Range { start, end, .. } => { + rewrite_spanned_expr( + start, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + end, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::Expr::Surface(surface) => rewrite_surface_expr( + surface, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ), + ast::Expr::Ident(_) + | ast::Expr::Literal(_) + | ast::Expr::SelfExpr + | ast::Expr::Yield(None) + | ast::Expr::VocabBlock(_) => {} + } +} + +/// Rewrite vocab expressions inside positional, named, and unpacked call arguments. +fn rewrite_call_args( + args: &mut [ast::CallArg], + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + for arg in args { + match arg { + ast::CallArg::Positional(expr) + | ast::CallArg::Named(_, expr) + | ast::CallArg::PositionalUnpack(expr) + | ast::CallArg::KeywordUnpack(expr) => { + rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } +} + +/// Rewrite vocab expressions inside comprehension iterator and filter clauses. +fn rewrite_comprehension_clauses( + clauses: &mut [ast::ComprehensionClause], + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + for clause in clauses { + match clause { + ast::ComprehensionClause::For { iter, .. } | ast::ComprehensionClause::If(iter) => { + rewrite_spanned_expr( + iter, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } +} + +/// Rewrite nested expressions held by non-core surface-expression payloads. +fn rewrite_surface_expr( + surface: &mut ast::SurfaceExpr, + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, + errors: &mut Vec, +) { + match &mut surface.payload { + ast::SurfaceExprPayload::PrefixUnary(inner) => { + rewrite_spanned_expr( + inner, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::SurfaceExprPayload::RaceFor(race) => { + for arm in &mut race.arms { + rewrite_spanned_expr( + &mut arm.awaitable, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + match &mut arm.body { + ast::RaceForBody::Expr(expr) => { + rewrite_spanned_expr( + expr, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::RaceForBody::Block(body) => { + rewrite_statement_list( + body, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } + } + } + ast::SurfaceExprPayload::LeadingDotPath { .. } => {} + ast::SurfaceExprPayload::ScopedGlyph { left, right, .. } => { + rewrite_spanned_expr( + left, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + rewrite_spanned_expr( + right, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + ast::SurfaceExprPayload::ScopedSymbolCall { args, .. } => { + rewrite_call_args( + args, + module_path, + library_manifest_index, + runtime, + helper_imports, + errors, + ); + } + } +} + +/// Desugar one expression-position vocab block and convert the expression result back to compiler AST. +fn desugar_vocab_block_to_expression( + block: &ast::VocabBlockStmt, + span: ast::Span, + module_path: Option<&str>, + library_manifest_index: &LibraryManifestIndex, + runtime: &mut WasmDesugarerRuntime, + helper_imports: &mut HelperImportAccumulator, +) -> Result { + let bridged = internal_vocab_block_to_public(block, span).map_err(|source| { + error_from_pass_error( + VocabDesugarPassError::Bridge { + keyword: block.keyword.clone(), + source, + }, + span, + ) + })?; + let bridged_keyword = bridged.keyword.clone(); + let bridged_keyword_metadata = bridged.keyword_metadata.clone(); + let request_node = incan_vocab::VocabSyntaxNode::Declaration(bridged); + let mut desugared = runtime + .desugar_node(library_manifest_index, &request_node, module_path) + .map_err(|err| error_from_pass_error(err, span))?; + + let incan_vocab::DesugarOutput::Expression(expression) = &mut desugared.output else { + return Err(error_from_pass_error( + VocabDesugarPassError::UnsupportedOutput { + keyword: bridged_keyword, + }, + span, + )); + }; + + resolve_helper_bindings_in_expr( + expression, + bridged_keyword_metadata.as_ref(), + &bridged_keyword, + library_manifest_index, + helper_imports, + ) + .map_err(|message| { + error_from_pass_error( + VocabDesugarPassError::HelperBinding { + keyword: bridged_keyword.clone(), + message, + }, + span, + ) + })?; + + public_expr_to_internal(expression).map_err(|source| { + error_from_pass_error( + VocabDesugarPassError::Bridge { + keyword: bridged_keyword, + source, + }, + span, + ) + }) } /// Map a pass/runtime error into a compiler diagnostic. diff --git a/src/lsp/backend.rs b/src/lsp/backend.rs index 97ccb1484..13d58b351 100644 --- a/src/lsp/backend.rs +++ b/src/lsp/backend.rs @@ -1971,6 +1971,11 @@ fn local_signature_in_expr( local_signature_in_call_args(args, ast, source, offset) } }, + Expr::VocabBlock(block) => block + .header_args + .iter() + .find_map(|arg| local_signature_in_expr(arg, ast, source, offset)) + .or_else(|| local_signature_in_statements(&block.body, ast, source, offset)), Expr::Ident(_) | Expr::Literal(_) | Expr::SelfExpr => None, } } @@ -3617,6 +3622,12 @@ fn scoped_symbol_in_expr<'a>( } SurfaceExprPayload::LeadingDotPath { .. } => {} }, + Expr::VocabBlock(block) => { + for arg in &block.header_args { + scoped_symbol_in_expr(arg, ident, symbol_span, surfaces, found); + } + scoped_symbol_in_statements(&block.body, ident, symbol_span, surfaces, found); + } Expr::Ident(_) | Expr::Literal(_) | Expr::SelfExpr | Expr::Yield(None) => {} Expr::Field(inner, _) => scoped_symbol_in_expr(inner, ident, symbol_span, surfaces, found), } @@ -4140,6 +4151,21 @@ fn scoped_symbol_context_in_expr(expr: &Spanned, offset: usize, context: & } SurfaceExprPayload::LeadingDotPath { .. } => {} }, + Expr::VocabBlock(block) => { + let previous_len = context.vocab_stack.len(); + context.vocab_stack.push(block.keyword.clone()); + for arg in &block.header_args { + scoped_symbol_context_in_expr(arg, offset, context); + } + scoped_symbol_context_in_statements(&block.body, offset, context); + let matched_body = block + .body + .iter() + .any(|stmt| stmt.span.start <= offset && offset <= stmt.span.end); + if !matched_body { + context.vocab_stack.truncate(previous_len); + } + } Expr::Ident(_) | Expr::Literal(_) | Expr::SelfExpr | Expr::Yield(None) => {} Expr::Field(inner, _) => scoped_symbol_context_in_expr(inner, offset, context), } diff --git a/src/lsp/call_site_type_args.rs b/src/lsp/call_site_type_args.rs index 29e2b0386..c329dd776 100644 --- a/src/lsp/call_site_type_args.rs +++ b/src/lsp/call_site_type_args.rs @@ -284,6 +284,11 @@ fn call_site_type_in_expr(expr: &Spanned, offset: usize) -> Option<&Spanne }) } }, + Expr::VocabBlock(block) => block + .header_args + .iter() + .find_map(|arg| call_site_type_in_expr(arg, offset)) + .or_else(|| call_site_types_in_stmts(&block.body, offset)), Expr::Ident(_) | Expr::Literal(_) | Expr::SelfExpr => None, } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 25f5e914e..2a7ea7e60 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -11329,6 +11329,7 @@ pub def display[T](data: DataSet[T]) -> None: incan_vocab::DslSurface::on_import("querykit.query").with_declaration( incan_vocab::DeclarationSurface::named("query") .with_clause_body() + .desugars_to_expression() .with_clause( incan_vocab::ClauseSurface::expr_list("SELECT") .with_expression_item_modifiers([ @@ -11361,6 +11362,51 @@ pub def display[T](data: DataSet[T]) -> None: Ok(()) } + fn write_pub_library_with_querykit_expression_clause_desugarer( + root: &Path, + desugarer_bytes: &[u8], + ) -> Result<(), Box> { + let artifact_root = root.join("deps").join("querykit").join("target").join("lib"); + std::fs::create_dir_all(artifact_root.join("desugarers"))?; + write_minimal_library_crate(&artifact_root, "querykit_core")?; + let desugarer_path = artifact_root.join("desugarers").join("querykit_desugarer.wasm"); + std::fs::write(&desugarer_path, desugarer_bytes)?; + + let metadata = incan_vocab::VocabRegistration::new() + .with_surface( + incan_vocab::DslSurface::on_import("querykit.query").with_declaration( + incan_vocab::DeclarationSurface::named("query") + .with_clause_body() + .desugars_to_expression() + .with_clauses([ + incan_vocab::ClauseSurface::expr("FROM").required(), + incan_vocab::ClauseSurface::expr_list("GROUP BY").optional(), + incan_vocab::ClauseSurface::expr_list("SELECT").required(), + ]), + ), + ) + .metadata(); + let mut manifest = LibraryManifest::new("querykit_core", "0.1.0"); + manifest.vocab = Some(incan::library_manifest::VocabExports { + crate_path: "vocab_companion".to_string(), + package_name: "vocab_companion".to_string(), + keyword_registrations: metadata.keyword_registrations, + dsl_surfaces: metadata.dsl_surfaces, + provider_manifest: incan_vocab::LibraryManifest::default(), + desugarer_artifact: Some(incan::library_manifest::VocabDesugarerArtifact { + artifact_kind: incan_vocab::DesugarerArtifactKind::WasmModule, + abi_version: incan_vocab::WASM_DESUGAR_ABI_VERSION, + relative_path: "desugarers/querykit_desugarer.wasm".to_string(), + target: "wasm32-wasip1".to_string(), + profile: "release".to_string(), + entrypoint: "desugar_block".to_string(), + sha256: hex::encode(Sha256::digest(desugarer_bytes)), + }), + }); + manifest.write_to_path(&artifact_root.join("querykit_core.incnlib"))?; + Ok(()) + } + fn write_pub_library_with_vocab_desugarer_and_filter_helper( root: &Path, dependency_key: &str, @@ -12700,6 +12746,188 @@ def main() -> None: Ok(()) } + #[test] + fn consumer_check_desugars_colon_vocab_expression_in_assignment_issue727() -> Result<(), Box> + { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Int(7)); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing expression-desugaring declaration payload", + r#""keyword":"query""#, + )?; + write_pub_library_with_querykit_select_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +def main() -> None: + value: int = query: + SELECT: + amount as total +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected check to desugar expression-position vocab block in assignment.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[test] + fn consumer_check_desugars_colon_vocab_expression_in_return_issue727() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Int(7)); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing expression-desugaring declaration payload", + r#""keyword":"query""#, + )?; + write_pub_library_with_querykit_select_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +def build_value() -> int: + return query: + SELECT: + amount as total +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected check to desugar expression-position vocab block in return.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[test] + fn consumer_check_desugars_colon_vocab_expression_preserves_inline_clauses_issue727() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Int(7)); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing inline FROM clause payload", + r#""keyword":"FROM""#, + )?; + write_pub_library_with_querykit_expression_clause_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +def main() -> None: + selected: int = query: + FROM orders + SELECT: + amount as total +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected check to pass inline colon-expression clauses to the desugarer.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[test] + fn consumer_check_desugars_braced_vocab_expression_with_compound_clauses_issue727() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Int(7)); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing compound clause payload", + r#""compound_tokens":["BY"]"#, + )?; + write_pub_library_with_querykit_expression_clause_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +def main() -> None: + value: int = query { FROM orders GROUP BY amount as grouped SELECT total as total } +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected check to desugar braced expression-position vocab block.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + + #[test] + fn consumer_check_desugared_public_field_callee_call_typechecks_as_method_issue727() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Field { + object: Box::new(incan_vocab::IncanExpr::Name("orders".to_string())), + field: "select".to_string(), + }), + args: Vec::new(), + }); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing FROM clause payload", + r#""keyword":"FROM""#, + )?; + write_pub_library_with_querykit_expression_clause_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +class LazyFrame: + def select(self) -> Self: + return self + +def main() -> None: + orders = LazyFrame() + selected: LazyFrame = query { FROM orders SELECT amount as amount } +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected public field-callee desugar output to typecheck as a method call.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn equivalent_helper_backed_keywords_typecheck() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md b/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md index 4ea1f68f7..cd6c5e962 100644 --- a/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md +++ b/workspaces/docs-site/docs/contributing/how-to/authoring_vocab_crates.md @@ -127,6 +127,7 @@ Key rules: - `DslSurface::on_import("routekit")` must match the consumer-facing import spelling after `pub::`. - Declarations own their clause grammar directly, so nested DSL structure stays close to the declaration that introduces it. +- Use `DeclarationSurface::desugars_to_expression()` when the DSL declaration produces a value. Expression-desugaring declarations can be used in ordinary expression positions such as assignment values and returns, and the compiler desugars them before typechecking. - Use `ClauseSurface::expr_list("SELECT")` for SQL-shaped projection clauses that accept entries such as `sum(amount) as total`; add declared item modifiers with `ExpressionItemModifierSurface::expr("for")` or similar when a projection item needs metadata such as `sum(amount) for customer with context`. The desugarer receives structured expression-list items with alias and modifier metadata, while `ClauseSurface::fields(...)` remains for config-style `name = value` sections. - `LibraryManifest` is where you describe exported module metadata plus any Cargo dependencies or stdlib features that must travel with the library artifact. - `KeywordRegistration` remains available only as a lower-level escape hatch for especially simple or incremental cases. diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 7c6d9bb1b..b186e8d25 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -121,6 +121,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Decorated wrappers preserve defaults**: Decorated functions and methods keep source default-argument call behavior when the final decorated callable surface still matches the original declaration, including direct imports and public facade re-exports (#703). - **Stdlib implementation modules stay internal**: Generated stdlib source dependencies no longer leak unimported helper classes into project modules, so explicit sibling imports keep precedence over unrelated `std.*` imports (#710). - **Vocab expression-list clauses preserve item metadata**: `ClauseSurface::expr_list(...)` accepts `expr as alias` entries and declared trailing item modifiers such as `expr for target with context`, exposing structured metadata to desugarers instead of forcing SQL-shaped projections through field-set syntax (#724). +- **Expression-desugaring vocab declarations work as values**: `DeclarationSurface::desugars_to_expression()` blocks can now appear where expressions are valid, including assignment values and return values; colon and brace forms desugar before typechecking while preserving inline clause bodies, expression-list item metadata, compound clause tokens such as `GROUP BY`, and public vocab method-call output (#727). - **Rust raw identifier fields emit correctly**: Rust-backed fields whose real Rust name is a keyword can be accessed with the keyword spelling, such as `value.type` and `TypeName(type=value)`, while generated Rust emits the actual raw identifier access such as `value.r#type` (#725). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). From be5435476cd2aa5048a2f35779b831e9c8e22d0c Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 1 Jun 2026 01:16:31 +0200 Subject: [PATCH 54/58] bugfix - unify helper and generic method default planning (#729, #731) Closes #729 Closes #731 --- Cargo.lock | 18 +- Cargo.toml | 2 +- src/backend/ir/codegen.rs | 8 +- src/backend/ir/emit/expressions/calls.rs | 27 +- src/backend/ir/emit/expressions/indexing.rs | 106 ++++- src/backend/ir/emit/expressions/methods.rs | 10 +- src/backend/ir/emit/program.rs | 32 +- src/backend/ir/lower/expr/calls.rs | 378 +++++++++++++++- src/backend/ir/lower/expr/mod.rs | 24 +- src/backend/ir/lower/mod.rs | 10 + src/backend/ir/lower/types.rs | 20 + src/backend/project/generator.rs | 8 +- src/cli/commands/build.rs | 3 + src/frontend/api_metadata.rs | 2 + src/frontend/library_exports.rs | 256 ++++++++++- .../typechecker/collect/stdlib_imports.rs | 11 +- src/frontend/typechecker/tests.rs | 93 +++- src/frontend/vocab_ast_bridge.rs | 240 +++++++--- src/frontend/vocab_desugar_pass/rewrite.rs | 6 +- src/library_manifest/model.rs | 146 ++++++- src/library_manifest/tests.rs | 107 +++++ tests/codegen_snapshot_tests.rs | 9 + .../issue731_generic_method_defaults.incn | 24 + .../consumer_main_rs.fragments | 2 +- .../semantic_string_audit.json | 10 +- tests/integration_tests.rs | 410 ++++++++++++++++++ ...sts__issue731_generic_method_defaults.snap | 118 +++++ ...napshot_tests__pub_import_expressions.snap | 4 +- ...apshot_tests__pub_import_module_alias.snap | 2 +- ...tests__vocab_helper_backed_desugaring.snap | 2 +- .../docs-site/docs/release_notes/0_3.md | 2 + 31 files changed, 1969 insertions(+), 121 deletions(-) create mode 100644 tests/codegen_snapshots/issue731_generic_method_defaults.incn create mode 100644 tests/snapshots/codegen_snapshot_tests__issue731_generic_method_defaults.snap diff --git a/Cargo.lock b/Cargo.lock index 5ed0a46bb..3f4acb3fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc35" +version = "0.3.0-rc37" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 6d8ffc973..97f643ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc35" +version = "0.3.0-rc37" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/codegen.rs b/src/backend/ir/codegen.rs index 099c3a6f5..600cee341 100644 --- a/src/backend/ir/codegen.rs +++ b/src/backend/ir/codegen.rs @@ -566,6 +566,7 @@ impl<'a> IrCodegen<'a> { // Lower AST to IR using typechecker output when available let mut lowering = AstLowering::new_with_type_info(type_info_opt); + lowering.set_library_manifest_index(self.library_manifest_index.clone()); lowering.set_current_source_module_name( program .source_path @@ -724,6 +725,7 @@ impl<'a> IrCodegen<'a> { pub fn try_generate_module(&mut self, module_name: &str, program: &Program) -> Result { // Use the IR pipeline for module generation too let mut lowering = AstLowering::new(); + lowering.set_library_manifest_index(self.library_manifest_index.clone()); lowering.set_current_source_module_name( program .source_path @@ -741,6 +743,7 @@ impl<'a> IrCodegen<'a> { continue; } let mut dep_lowering = AstLowering::new(); + dep_lowering.set_library_manifest_index(self.library_manifest_index.clone()); dep_lowering.set_current_source_module_name( dep_path_segments .clone() @@ -869,6 +872,7 @@ impl<'a> IrCodegen<'a> { } }; let mut lowering = AstLowering::new_with_type_info(module_type_info); + lowering.set_library_manifest_index(self.library_manifest_index.clone()); lowering.set_current_source_module_name(Some( path_segments .clone() @@ -1104,6 +1108,7 @@ impl<'a> IrCodegen<'a> { } }; let mut lowering = AstLowering::new_with_type_info(module_type_info); + lowering.set_library_manifest_index(self.library_manifest_index.clone()); lowering.set_current_source_module_name(Some(path.join("."))); lowering.seed_dependency_trait_decls(&self.dependency_modules); lowering.seed_struct_field_aliases(global_aliases.clone()); @@ -2368,6 +2373,7 @@ def main() -> None: }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "Widget".to_string(), @@ -3011,7 +3017,7 @@ def main() -> None: codegen.set_library_manifest_index(library_index_with_widgets_exports()); let code = must_ok(codegen.try_generate(&ast)); assert!( - code.contains("let _w: Widget = make_widget(DEFAULT_NAME);"), + code.contains("let _w: Widget = widgets::make_widget(DEFAULT_NAME.to_string());"), "Generated code did not match expected. Code was:\n{code}" ); } diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index 99a7c119f..2179e478f 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -549,7 +549,11 @@ impl<'a> IrEmitter<'a> { } } - let f = if let Some(path) = canonical_path { + let f = if canonical_path.is_some_and(|path| path.first().map(String::as_str) == Some("pub")) + && Self::callee_is_imported_module_path(func) + { + self.emit_expr(func)? + } else if let Some(path) = canonical_path { self.emit_canonical_callee_path(path)?.unwrap_or(self.emit_expr(func)?) } else { self.emit_expr(func)? @@ -812,6 +816,17 @@ impl<'a> IrEmitter<'a> { } } + /// Return whether the callee is already spelled as a module-rooted path in source, such as `lib.function`. + fn callee_is_imported_module_path(func: &TypedExpr) -> bool { + match &func.kind { + IrExprKind::Field { object, .. } => Self::callee_is_imported_module_path(object), + IrExprKind::Var { ref_kind, .. } => { + matches!(ref_kind, VarRefKind::ExternalName | VarRefKind::ExternalRustName) + } + _ => false, + } + } + /// Emit call arguments while preserving rest-argument expansion semantics. pub(in super::super) fn emit_rest_aware_call_args( &self, @@ -1035,6 +1050,16 @@ impl<'a> IrEmitter<'a> { segments.push(quote! { #ident }); } segments + } else if module_path.first().map(String::as_str) == Some("pub") { + let mut segments = Vec::new(); + for seg in module_path.iter().skip(1) { + let ident = Self::rust_ident(seg); + segments.push(quote! { #ident }); + } + if segments.is_empty() { + return Ok(None); + } + segments } else if *self.qualify_internal_canonical_paths.borrow() && self.is_internal_module_path(&module_path) { let mut segments = vec![quote! { crate }]; for seg in &module_path { diff --git a/src/backend/ir/emit/expressions/indexing.rs b/src/backend/ir/emit/expressions/indexing.rs index ec81c1d9e..8c563ecda 100644 --- a/src/backend/ir/emit/expressions/indexing.rs +++ b/src/backend/ir/emit/expressions/indexing.rs @@ -13,7 +13,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use super::super::super::expr::{IrExprKind, TypedExpr, UnaryOp}; +use super::super::super::expr::{IrExprKind, TypedExpr, UnaryOp, VarRefKind}; use super::super::super::types::IrType; use super::super::{EmitError, IrEmitter}; @@ -290,8 +290,6 @@ impl<'a> IrEmitter<'a> { return self.emit_storage_with_ref(object, quote! { (#inner).clone() }); } - let o = self.emit_expr(object)?; - // Check if this is an enum variant access using the actual enum registry, not capitalization heuristics if let IrExprKind::Var { name, .. } = &object.kind { let key = (name.to_string(), field.to_string()); @@ -322,7 +320,11 @@ impl<'a> IrEmitter<'a> { return Ok(quote! { #type_ident::#f }); } } + if let Some(path) = Self::type_like_field_path(object, field) { + return Ok(path); + } + let o = self.emit_expr(object)?; // Check if field is a numeric index (tuple access) if field.chars().all(|c| c.is_ascii_digit()) { let idx: syn::Index = field @@ -336,6 +338,35 @@ impl<'a> IrEmitter<'a> { } } + /// Emit a field chain rooted in a module-like symbol as a Rust path. + fn type_like_field_path(object: &TypedExpr, field: &str) -> Option { + let mut segments = Self::type_like_field_segments(object)?; + segments.push(field.to_string()); + let mut emitted = segments.into_iter().map(|segment| { + let ident = Self::rust_ident(&segment); + quote! { #ident } + }); + let first = emitted.next()?; + Some(emitted.fold(first, |acc, segment| quote! { #acc::#segment })) + } + + /// Return the path segments for a field chain rooted in a module-like symbol. + fn type_like_field_segments(expr: &TypedExpr) -> Option> { + match &expr.kind { + IrExprKind::Var { + name, + ref_kind: VarRefKind::ExternalName | VarRefKind::ExternalRustName, + .. + } => Some(vec![name.clone()]), + IrExprKind::Field { object, field } => { + let mut segments = Self::type_like_field_segments(object)?; + segments.push(field.clone()); + Some(segments) + } + _ => None, + } + } + /// Helper: emit an index expression with negative-index handling. /// /// Converts Python-style negative indices to `len() - offset`. @@ -372,3 +403,72 @@ impl<'a> IrEmitter<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::ir::FunctionRegistry; + use crate::backend::ir::expr::VarAccess; + + fn render(tokens: TokenStream) -> String { + tokens.to_string().replace(' ', "") + } + + fn module_ref(name: &str) -> TypedExpr { + TypedExpr::new( + IrExprKind::Var { + name: name.to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::ExternalName, + }, + IrType::Unknown, + ) + } + + fn type_ref(name: &str) -> TypedExpr { + TypedExpr::new( + IrExprKind::Var { + name: name.to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::TypeName, + }, + IrType::Unknown, + ) + } + + #[test] + fn module_field_chain_emits_as_path() -> Result<(), Box> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let object = TypedExpr::new( + IrExprKind::Field { + object: Box::new(module_ref("querykit")), + field: "helpers".to_string(), + }, + IrType::Unknown, + ); + + let emitted = emitter.emit_field_expr(&object, "DEFAULT_LABEL")?; + + assert_eq!(render(emitted), "querykit::helpers::DEFAULT_LABEL"); + Ok(()) + } + + #[test] + fn associated_value_field_chain_keeps_value_field_access() -> Result<(), Box> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let object = TypedExpr::new( + IrExprKind::Field { + object: Box::new(type_ref("Widget")), + field: "DEFAULT".to_string(), + }, + IrType::Unknown, + ); + + let emitted = emitter.emit_field_expr(&object, "name")?; + + assert_eq!(render(emitted), "Widget::DEFAULT.name"); + Ok(()) + } +} diff --git a/src/backend/ir/emit/expressions/methods.rs b/src/backend/ir/emit/expressions/methods.rs index f96fcf16b..54e2478ad 100644 --- a/src/backend/ir/emit/expressions/methods.rs +++ b/src/backend/ir/emit/expressions/methods.rs @@ -267,7 +267,8 @@ impl<'a> IrEmitter<'a> { Some(IrType::Result(ok_ty, _)) => Some(ok_ty.as_ref()), other => other, }; - let specialized_signature = + let receiver_specialized_signature = self.specialized_method_signature_for_receiver(&receiver.ty, method); + let target_specialized_signature = receiver_target_ty.and_then(|ty| self.specialized_method_signature_for_receiver(ty, method)); let result_specialized_call_signature = callable_signature.and_then(|signature| { result_target_ty.and_then(|ty| Self::specialize_signature_by_result_target(signature, ty)) @@ -279,9 +280,10 @@ impl<'a> IrEmitter<'a> { .as_ref() .or(receiver_specialized_call_signature.as_ref()) .or(callable_signature); - let receiver_signature = self - .method_signature_for_receiver(&receiver.ty, method) - .or(specialized_signature.as_ref()); + let receiver_signature = receiver_specialized_signature + .as_ref() + .or_else(|| self.method_signature_for_receiver(&receiver.ty, method)) + .or(target_specialized_signature.as_ref()); let has_incan_receiver_signature = receiver_signature.is_some(); let callable_signature = FunctionSignature::merge_default_source_by(callable_signature, receiver_signature, |left, right| { diff --git a/src/backend/ir/emit/program.rs b/src/backend/ir/emit/program.rs index 8f4ad11b8..c2d6d8eaf 100644 --- a/src/backend/ir/emit/program.rs +++ b/src/backend/ir/emit/program.rs @@ -1778,7 +1778,7 @@ impl<'a> IrEmitter<'a> { Self::collect_union_types_from_type(&expr.ty, out); match &expr.kind { IrExprKind::Call { func, args, .. } => { - Self::collect_union_types_from_expr(func, out); + Self::collect_union_types_from_call_callee(func, out); for arg in args { Self::collect_union_types_from_expr(&arg.expr, out); } @@ -1970,6 +1970,36 @@ impl<'a> IrEmitter<'a> { } } + /// Collect anonymous unions needed by a call callee expression without treating the callee's own function type as + /// an emitted type position. + /// + /// Imported public helpers can carry function signatures that mention dependency-owned anonymous unions. Those + /// signatures guide argument planning, but the function type itself is not printed into the generated Rust call. + /// Only nested value expressions inside the callee need collection. + fn collect_union_types_from_call_callee(expr: &TypedExpr, out: &mut HashMap) { + match &expr.kind { + IrExprKind::Field { object, .. } => Self::collect_union_types_from_expr(object, out), + IrExprKind::Index { object, index } => { + Self::collect_union_types_from_expr(object, out); + Self::collect_union_types_from_expr(index, out); + } + IrExprKind::Call { func, args, .. } => { + Self::collect_union_types_from_call_callee(func, out); + for arg in args { + Self::collect_union_types_from_expr(&arg.expr, out); + } + } + IrExprKind::MethodCall { receiver, args, .. } => { + Self::collect_union_types_from_expr(receiver, out); + for arg in args { + Self::collect_union_types_from_expr(&arg.expr, out); + } + } + IrExprKind::Var { .. } | IrExprKind::Literal(_) => {} + _ => Self::collect_union_types_from_expr(expr, out), + } + } + /// Collect anonymous union shapes referenced by a statement tree. fn collect_union_types_from_stmt(stmt: &IrStmt, out: &mut HashMap) { match &stmt.kind { diff --git a/src/backend/ir/lower/expr/calls.rs b/src/backend/ir/lower/expr/calls.rs index e47f6f9f9..24cbe8385 100644 --- a/src/backend/ir/lower/expr/calls.rs +++ b/src/backend/ir/lower/expr/calls.rs @@ -12,9 +12,14 @@ use super::super::super::{FunctionSignature, IrStmt, Mutability, TypedExpr}; use super::super::AstLowering; use super::super::errors::LoweringError; use crate::frontend::ast::{self, TypeConstraintKey}; +use crate::frontend::library_manifest_index::LibraryManifestIndexEntry; use crate::frontend::symbols::{CallableParam, NewtypePrimitiveConstraint, ResolvedType}; use crate::frontend::typechecker::{FixedUnpackPlan, RustArgCoercionKind, ValidatedNewtypeCoercionMode}; use crate::frontend::typechecker::{IdentKind, ResolvedOperatorKind}; +use crate::library_manifest::{ + FunctionExport, ParamDefaultCallArgExport, ParamDefaultCallSignatureExport, ParamDefaultExport, ParamExport, + ParamKindExport, resolved_type_from_manifest_type_ref, +}; use incan_core::lang::keywords::{self, KeywordId}; use incan_core::lang::stdlib; use incan_core::lang::stdlib::{STDLIB_BUILTINS, STDLIB_ROOT}; @@ -137,6 +142,341 @@ impl AstLowering { } } + /// Resolve a callable signature from a public dependency manifest, including materialized default expressions. + fn callable_signature_for_imported_pub_path(&mut self, path: &[String]) -> Option { + if path.len() < 3 || path.first().map(String::as_str) != Some("pub") { + return None; + } + let library = path.get(1)?; + let function_name = path.last()?; + let function = self.pub_function_export(library, function_name)?; + Some(self.callable_signature_from_pub_function_export(library, &function)) + } + + /// Resolve the canonical imported callee path for identifier and module-qualified calls. + fn imported_callee_path_for_expr(&self, expr: &ast::Expr) -> Option> { + match expr { + ast::Expr::Ident(name) => self + .active_trait_default_function_path(name) + .or_else(|| self.import_aliases.get(name).cloned()), + ast::Expr::Field(object, field) => { + let mut path = self.imported_field_base_path(&object.node)?; + path.push(field.clone()); + Some(path) + } + _ => None, + } + } + + /// Resolve the imported module path that roots a field-chain callee such as `widgets.make_widget`. + fn imported_field_base_path(&self, expr: &ast::Expr) -> Option> { + match expr { + ast::Expr::Ident(name) => self.import_aliases.get(name).cloned(), + ast::Expr::Field(object, field) => { + let mut path = self.imported_field_base_path(&object.node)?; + path.push(field.clone()); + Some(path) + } + _ => None, + } + } + + /// Resolve `module.function(...)` syntax when the receiver is an imported public dependency module. + pub(in crate::backend::ir::lower) fn imported_pub_method_callee_path( + &self, + receiver: &ast::Expr, + method_name: &str, + ) -> Option> { + let mut path = self.imported_field_base_path(receiver)?; + if path.first().map(String::as_str) != Some("pub") { + return None; + } + let library = path.get(1)?; + self.pub_function_export(library, method_name)?; + path.push(method_name.to_string()); + Some(path) + } + + /// Fetch the public function export or projected alias export that backs an imported public callable. + fn pub_function_export(&self, library: &str, function_name: &str) -> Option { + let index = self.library_manifest_index.as_ref()?; + let LibraryManifestIndexEntry::Loaded { manifest, .. } = index.get(library)? else { + return None; + }; + if let Some(function) = manifest + .exports + .functions + .iter() + .find(|function| function.name == function_name) + { + return Some(function.clone()); + } + manifest + .exports + .aliases + .iter() + .find(|alias| alias.name == function_name) + .and_then(|alias| alias.projected_function.clone()) + } + + /// Rebuild a public dependency callable signature from manifest metadata, including materialized parameter + /// defaults. + fn callable_signature_from_pub_function_export( + &mut self, + library: &str, + function: &FunctionExport, + ) -> FunctionSignature { + FunctionSignature { + params: function + .params + .iter() + .map(|param| { + let base_ty = self.lower_resolved_type(&resolved_type_from_manifest_type_ref(¶m.ty)); + let kind = param_kind_from_manifest(param.kind); + FunctionParam { + name: param.name.clone(), + ty: Self::lower_param_container_type(kind, base_ty), + mutability: Mutability::Immutable, + is_self: false, + kind, + default: self.lower_pub_param_default(library, param), + } + }) + .collect(), + return_type: self.lower_resolved_type(&resolved_type_from_manifest_type_ref(&function.return_type)), + } + } + + /// Lower one exported parameter default into IR so omitted public dependency arguments can be emitted at call + /// sites. + fn lower_pub_param_default(&mut self, library: &str, param: &ParamExport) -> Option { + match param.default.as_ref() { + Some(ParamDefaultExport::Unsupported) | None => None, + Some(default) if default.is_materializable() => self.lower_pub_default_expr(library, default), + Some(_) => None, + } + } + + /// Lower a metadata-safe exported default expression into the subset of IR that can be materialized by consumers. + fn lower_pub_default_expr(&mut self, library: &str, default: &ParamDefaultExport) -> Option { + match default { + ParamDefaultExport::Int(value) => Some(TypedExpr::new(IrExprKind::Int(*value), IrType::Int)), + ParamDefaultExport::Float(value) => value + .parse::() + .ok() + .map(|value| TypedExpr::new(IrExprKind::Float(value), IrType::Float)), + ParamDefaultExport::Bool(value) => Some(TypedExpr::new(IrExprKind::Bool(*value), IrType::Bool)), + ParamDefaultExport::String(value) => Some(TypedExpr::new( + IrExprKind::Literal(IrLiteral::StaticStr(value.clone())), + IrType::StaticStr, + )), + ParamDefaultExport::Bytes(value) => Some(TypedExpr::new(IrExprKind::Bytes(value.clone()), IrType::Bytes)), + ParamDefaultExport::None => Some(TypedExpr::new(IrExprKind::None, IrType::Unit)), + ParamDefaultExport::List(values) => { + let entries = values + .iter() + .map(|value| self.lower_pub_default_expr(library, value).map(IrListEntry::Element)) + .collect::>>()?; + Some(TypedExpr::new( + IrExprKind::List(entries), + IrType::List(Box::new(IrType::Unknown)), + )) + } + ParamDefaultExport::Dict(entries) => { + let entries = entries + .iter() + .map(|entry| { + Some(IrDictEntry::Pair( + self.lower_pub_default_expr(library, &entry.key)?, + Box::new(self.lower_pub_default_expr(library, &entry.value)?), + )) + }) + .collect::>>()?; + Some(TypedExpr::new( + IrExprKind::Dict(entries), + IrType::Dict(Box::new(IrType::Unknown), Box::new(IrType::Unknown)), + )) + } + ParamDefaultExport::ConstRef(path) => self.lower_pub_default_const_ref(library, path), + ParamDefaultExport::Call { path, args, signature } => { + self.lower_pub_default_call(library, path, args, signature.as_ref()) + } + ParamDefaultExport::Unsupported => None, + } + } + + /// Lower a default constant reference as a dependency-qualified value expression. + fn lower_pub_default_const_ref(&mut self, library: &str, path: &[String]) -> Option { + if path.is_empty() { + return None; + } + let mut expr = TypedExpr::new( + IrExprKind::Var { + name: library.to_string(), + access: VarAccess::Read, + ref_kind: VarRefKind::ExternalName, + }, + IrType::Unknown, + ); + for segment in path { + expr = TypedExpr::new( + IrExprKind::Field { + object: Box::new(expr), + field: segment.clone(), + }, + IrType::Unknown, + ); + } + Some(expr) + } + + /// Lower an exported default call while preserving the public dependency canonical path for nested call planning. + fn lower_pub_default_call( + &mut self, + library: &str, + path: &[String], + args: &[ParamDefaultCallArgExport], + signature: Option<&ParamDefaultCallSignatureExport>, + ) -> Option { + let function_name = path.last()?.clone(); + let canonical_path = self.pub_default_canonical_path(library, path); + let function = self.pub_function_export(library, &function_name); + let callable_signature = signature + .map(|signature| self.callable_signature_from_pub_default_call_signature(library, signature)) + .or_else(|| { + function + .as_ref() + .map(|function| self.callable_signature_from_pub_function_export(library, function)) + }); + let return_type = signature + .map(|signature| self.lower_resolved_type(&resolved_type_from_manifest_type_ref(&signature.return_type))) + .or_else(|| { + function.as_ref().map(|function| { + self.lower_resolved_type(&resolved_type_from_manifest_type_ref(&function.return_type)) + }) + }) + .unwrap_or(IrType::Unknown); + let args = args + .iter() + .map(|arg| { + Some(IrCallArg { + name: arg.name.clone(), + kind: if arg.name.is_some() { + IrCallArgKind::Named + } else { + IrCallArgKind::Positional + }, + expr: self.lower_pub_default_expr(library, &arg.value)?, + }) + }) + .collect::>>()?; + Some(TypedExpr::new( + IrExprKind::Call { + func: Box::new(TypedExpr::new( + IrExprKind::Var { + name: function_name, + access: VarAccess::Read, + ref_kind: VarRefKind::Value, + }, + IrType::Unknown, + )), + type_args: Vec::new(), + args, + callable_signature, + canonical_path: Some(canonical_path), + }, + self.pub_external_type(library, return_type), + )) + } + + /// Rebuild the source callable surface captured for a provider-owned default helper call. + fn callable_signature_from_pub_default_call_signature( + &mut self, + library: &str, + signature: &ParamDefaultCallSignatureExport, + ) -> FunctionSignature { + FunctionSignature { + params: signature + .params + .iter() + .map(|param| { + let base_ty = self.lower_resolved_type(&resolved_type_from_manifest_type_ref(¶m.ty)); + let kind = param_kind_from_manifest(param.kind); + FunctionParam { + name: param.name.clone(), + ty: Self::lower_param_container_type(kind, base_ty), + mutability: Mutability::Immutable, + is_self: false, + kind, + default: self.lower_pub_param_default(library, param), + } + }) + .collect(), + return_type: self.lower_resolved_type(&resolved_type_from_manifest_type_ref(&signature.return_type)), + } + } + + /// Convert a default-expression path from manifest-local spelling into a public dependency canonical path. + fn pub_default_canonical_path(&self, library: &str, path: &[String]) -> Vec { + let mut canonical = vec!["pub".to_string(), library.to_string()]; + canonical.extend(path.iter().cloned()); + canonical + } + + /// Rewrite dependency-owned anonymous union types to exact Rust display paths so consumers do not re-own them. + fn pub_external_type(&self, library: &str, ty: IrType) -> IrType { + if let Some(union_name) = ty.union_type_name() { + return IrType::RustDisplay(format!("{library}::{union_name}")); + } + match ty { + IrType::List(inner) => IrType::List(Box::new(self.pub_external_type(library, *inner))), + IrType::Dict(key, value) => IrType::Dict( + Box::new(self.pub_external_type(library, *key)), + Box::new(self.pub_external_type(library, *value)), + ), + IrType::Set(inner) => IrType::Set(Box::new(self.pub_external_type(library, *inner))), + IrType::Tuple(items) => IrType::Tuple( + items + .into_iter() + .map(|item| self.pub_external_type(library, item)) + .collect(), + ), + IrType::Option(inner) => IrType::Option(Box::new(self.pub_external_type(library, *inner))), + IrType::Result(ok, err) => IrType::Result( + Box::new(self.pub_external_type(library, *ok)), + Box::new(self.pub_external_type(library, *err)), + ), + IrType::Function { params, ret } => IrType::Function { + params: params + .into_iter() + .map(|param| self.pub_external_type(library, param)) + .collect(), + ret: Box::new(self.pub_external_type(library, *ret)), + }, + IrType::Ref(inner) => IrType::Ref(Box::new(self.pub_external_type(library, *inner))), + IrType::RefMut(inner) => IrType::RefMut(Box::new(self.pub_external_type(library, *inner))), + IrType::NamedGeneric(name, args) => IrType::NamedGeneric( + name, + args.into_iter() + .map(|arg| self.pub_external_type(library, arg)) + .collect(), + ), + other => other, + } + } + + /// Build the emitted function type for a public dependency callable without losing semantic call-planning metadata. + fn pub_external_function_type(&self, library: &str, signature: &FunctionSignature) -> IrType { + IrType::Function { + params: signature + .params + .iter() + .map(|param| self.pub_external_type(library, param.ty.clone())) + .collect(), + ret: Box::new(self.pub_external_type(library, signature.return_type.clone())), + } + } + /// Resolve an imported stdlib type method signature by loading the owning stdlib stub AST. /// /// Function metadata already has a direct stdlib lookup path, but type-member calls such as `App.run()` arrive as @@ -1605,12 +1945,7 @@ impl AstLowering { } } - let imported_callee_path = match &f.node { - ast::Expr::Ident(name) => self - .active_trait_default_function_path(name) - .or_else(|| self.import_aliases.get(name).cloned()), - _ => None, - }; + let imported_callee_path = self.imported_callee_path_for_expr(&f.node); let mut func = self.lower_expr_spanned(f)?; if let Some(resolved_operator) = self .type_info @@ -1713,6 +2048,7 @@ impl AstLowering { .as_ref() .and_then(|info| info.resolved_operator_call(call_span).cloned()) && resolved_operator.kind == ResolvedOperatorKind::Call + && imported_callee_path.is_none() { let ret_ty = self .type_info @@ -1735,7 +2071,10 @@ impl AstLowering { } let callable_signature = imported_callee_path .as_deref() - .and_then(|path| self.callable_signature_for_imported_stdlib_path(path)) + .and_then(|path| { + self.callable_signature_for_imported_stdlib_path(path) + .or_else(|| self.callable_signature_for_imported_pub_path(path)) + }) .or_else(|| match &f.node { ast::Expr::Ident(name) => self.lookup_local_callable_signature(name), ast::Expr::Partial(_) => self.partial_expr_signature_for_span(f.span), @@ -1744,9 +2083,23 @@ impl AstLowering { .or_else(|| self.callable_signature_for_call_span(call_span)) .or_else(|| self.callable_signature_for_callee_span(f.span)); let callable_signature = self.refine_function_typed_local_call(&mut func, &args_ir, callable_signature); + let imported_pub_library = imported_callee_path.as_deref().and_then(|path| { + if path.first().is_some_and(|segment| segment == "pub") { + path.get(1) + } else { + None + } + }); + if let (Some(library), Some(signature)) = (imported_pub_library, callable_signature.as_ref()) { + func.ty = self.pub_external_function_type(library, signature); + } let ret_ty = if let IrType::Function { ret, .. } = &func.ty { - (**ret).clone() + let ret_ty = (**ret).clone(); + match imported_pub_library { + Some(library) => self.pub_external_type(library, ret_ty), + None => ret_ty, + } } else { IrType::Unknown }; @@ -2108,6 +2461,15 @@ impl AstLowering { } } +/// Convert manifest parameter kind metadata back to the frontend enum used by IR call signatures. +fn param_kind_from_manifest(kind: ParamKindExport) -> ast::ParamKind { + match kind { + ParamKindExport::Normal => ast::ParamKind::Normal, + ParamKindExport::RestPositional => ast::ParamKind::RestPositional, + ParamKindExport::RestKeyword => ast::ParamKind::RestKeyword, + } +} + #[cfg(test)] mod tests { use super::AstLowering; diff --git a/src/backend/ir/lower/expr/mod.rs b/src/backend/ir/lower/expr/mod.rs index 9ce492bfc..6d061b153 100644 --- a/src/backend/ir/lower/expr/mod.rs +++ b/src/backend/ir/lower/expr/mod.rs @@ -552,7 +552,22 @@ impl AstLowering { IrType::Struct("Logger".to_string()), )); } - let access = self.select_var_access_for_ident(&lowered_name, &ty); + // Imported string-like bindings are dependency-owned path references, not local owned strings that can + // be consumed by the current block's last-use analysis. + let inferred_import_ty = self + .type_info + .as_ref() + .and_then(|info| info.expr_type(expr_span).cloned()) + .map(|ty| self.lower_resolved_type(&ty)); + let access = if self.import_aliases.contains_key(name) + && matches!( + inferred_import_ty.as_ref().unwrap_or(&ty), + IrType::String | IrType::StaticStr | IrType::StrRef | IrType::FrozenStr + ) { + VarAccess::Read + } else { + self.select_var_access_for_ident(&lowered_name, &ty) + }; ( IrExprKind::Var { name: lowered_name.clone(), @@ -793,6 +808,13 @@ impl AstLowering { // ---- Method calls ---- ast::Expr::MethodCall(o, m, type_args, args) => { + if self.imported_pub_method_callee_path(&o.node, m).is_some() { + let callee = ast::Spanned::new(ast::Expr::Field(o.clone(), m.clone()), expr_span); + return self + .lower_call_expr(&callee, type_args, args, expr_span) + .map(|(kind, ty)| TypedExpr::new(kind, ty)); + } + if Self::is_explicit_builtin_namespace_expr(o) && let Some(builtin) = BuiltinFn::from_name(m) { diff --git a/src/backend/ir/lower/mod.rs b/src/backend/ir/lower/mod.rs index d6cf63366..538e9c273 100644 --- a/src/backend/ir/lower/mod.rs +++ b/src/backend/ir/lower/mod.rs @@ -33,6 +33,7 @@ mod stmt; mod types; use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use super::TypedExpr; use super::decl::{FunctionParam, IrDecl, IrDeclKind, IrImportOrigin, IrImportQualifier, IrTypeParam}; @@ -42,6 +43,7 @@ use super::types::IrType; use super::{FunctionReexport, FunctionSignature, IrProgram, Mutability}; use crate::frontend::ast; use crate::frontend::decorator_resolution; +use crate::frontend::library_manifest_index::LibraryManifestIndex; use crate::frontend::symbols::{CallableParam, NewtypePrimitiveConstraint}; use crate::frontend::typechecker::TypeCheckInfo; use crate::frontend::typechecker::stdlib_loader::StdlibAstCache; @@ -117,6 +119,8 @@ pub struct AstLowering { pub(super) iterator_adopter_names: HashSet, /// Optional typechecker output used to drive lowering (avoid heuristics). pub(super) type_info: Option, + /// Public dependency manifests used to rehydrate callable defaults across `pub::` boundaries. + pub(super) library_manifest_index: Option>, /// Newtype -> chosen validated constructor method name (e.g. "from_underlying", "from_str"), /// used for checked construction lowering of `T(x)` at call sites. pub(super) newtype_checked_ctor: HashMap, @@ -233,6 +237,7 @@ impl AstLowering { active_trait_default_function_paths: Vec::new(), iterator_adopter_names: HashSet::new(), type_info: None, + library_manifest_index: None, newtype_checked_ctor: HashMap::new(), newtype_constraints: HashMap::new(), current_impl_type: None, @@ -258,6 +263,11 @@ impl AstLowering { self.current_source_module_name = name; } + /// Provide public dependency manifests for lowering metadata-backed call signatures. + pub fn set_library_manifest_index(&mut self, index: Option>) { + self.library_manifest_index = index; + } + /// Lower one typechecker-resolved callable surface into IR parameters, attaching an already-planned default /// expression for each parameter when present. fn function_params_from_callable_surface( diff --git a/src/backend/ir/lower/types.rs b/src/backend/ir/lower/types.rs index 7f9703d75..19739ec17 100644 --- a/src/backend/ir/lower/types.rs +++ b/src/backend/ir/lower/types.rs @@ -116,6 +116,7 @@ impl AstLowering { (IrType::Generic(existing_name), IrType::Struct(inferred_name)) if existing_name == &inferred_name => { existing.clone() } + (IrType::RustDisplay(_), _) => existing.clone(), (IrType::Ref(existing_inner), IrType::Ref(inferred_inner)) => { IrType::Ref(Box::new(Self::merge_inferred_ir_type(existing_inner, *inferred_inner))) } @@ -738,4 +739,23 @@ mod tests { ) ); } + + #[test] + fn merge_inferred_ir_type_preserves_exact_rust_display_types() { + let merged = AstLowering::merge_inferred_ir_type( + &IrType::RustDisplay("querykit::__IncanUniond6a8fda7c78e7109".to_string()), + IrType::NamedGeneric( + crate::backend::ir::types::IR_UNION_TYPE_NAME.to_string(), + vec![ + IrType::Struct("IntLiteralExpr".to_string()), + IrType::Struct("StringLiteralExpr".to_string()), + ], + ), + ); + + assert_eq!( + merged, + IrType::RustDisplay("querykit::__IncanUniond6a8fda7c78e7109".to_string()) + ); + } } diff --git a/src/backend/project/generator.rs b/src/backend/project/generator.rs index 827a9e94e..a412fa79c 100644 --- a/src/backend/project/generator.rs +++ b/src/backend/project/generator.rs @@ -330,9 +330,10 @@ impl ProjectGenerator { // Add mod declarations for each module (sorted for deterministic output) let mut module_names: Vec<_> = modules.keys().collect(); module_names.sort(); + let visibility = if self.is_binary { "" } else { "pub " }; let mods: String = module_names .iter() - .map(|m| Self::render_module_decl(m, &format!("{m}.rs"), "")) + .map(|m| Self::render_module_decl(m, &format!("{m}.rs"), visibility)) .collect::>() .join("\n") + "\n"; @@ -523,6 +524,7 @@ impl ProjectGenerator { let mut sorted_top: Vec<_> = top_level_modules.into_iter().collect(); sorted_top.sort(); if !sorted_top.is_empty() { + let visibility = if self.is_binary { "" } else { "pub " }; let mods: String = sorted_top .iter() .map(|m| { @@ -532,7 +534,7 @@ impl ProjectGenerator { } else { format!("{m}.rs") }; - Self::render_module_decl(m, &relative_path, "") + Self::render_module_decl(m, &relative_path, visibility) }) .collect::>() .join("\n") @@ -775,7 +777,7 @@ mod tests { assert!(temp_dir.join("src/type/helpers.rs").exists()); let main_content = fs::read_to_string(temp_dir.join("src/lib.rs"))?; - assert!(main_content.contains("#[path = \"type/mod.rs\"]\nmod r#type;")); + assert!(main_content.contains("#[path = \"type/mod.rs\"]\npub mod r#type;")); let mod_rs_content = fs::read_to_string(temp_dir.join("src/api/mod.rs"))?; assert!(mod_rs_content.contains("#[path = \"async.rs\"]\npub mod r#async;")); diff --git a/src/cli/commands/build.rs b/src/cli/commands/build.rs index a51b8e8e0..e65e5913b 100644 --- a/src/cli/commands/build.rs +++ b/src/cli/commands/build.rs @@ -792,6 +792,7 @@ pub fn build_library( for (idx, module) in modules.iter().enumerate() { let deps_for_module = imported_module_deps_for_with_index(&modules, idx, &module_idx_by_key); let mut checker = typechecker::TypeChecker::new(); + checker.set_current_module_path(Some(module.path_segments.clone())); checker.set_declared_crate_names(declared.clone()); checker.set_library_manifest_index(library_manifest_index.clone()); #[cfg(feature = "rust_inspect")] @@ -1350,6 +1351,7 @@ mod tests { name: "filter_ds".to_string(), type_params: Vec::new(), params: Vec::new(), + param_defaults: Vec::new(), return_type: ResolvedType::Named("DataSet".to_string()), is_async: false, }), @@ -1402,6 +1404,7 @@ mod tests { name: "filter_ds".to_string(), type_params: Vec::new(), params: Vec::new(), + param_defaults: Vec::new(), return_type: ResolvedType::Named("DataSet".to_string()), is_async: false, }), diff --git a/src/frontend/api_metadata.rs b/src/frontend/api_metadata.rs index 6ac080f15..1d68584d5 100644 --- a/src/frontend/api_metadata.rs +++ b/src/frontend/api_metadata.rs @@ -770,6 +770,7 @@ fn source_function_params(function: &FunctionDecl, checker: &TypeChecker) -> Vec crate::frontend::ast::ParamKind::RestKeyword => ParamKindExport::RestKeyword, }, has_default: param.node.default.is_some(), + default: None, }) .collect() } @@ -1166,6 +1167,7 @@ fn params(params: &[crate::frontend::symbols::CallableParam]) -> Vec ParamKindExport::RestKeyword, }, has_default: param.has_default, + default: None, }) }) .collect() diff --git a/src/frontend/library_exports.rs b/src/frontend/library_exports.rs index fc85d2732..256777331 100644 --- a/src/frontend/library_exports.rs +++ b/src/frontend/library_exports.rs @@ -18,6 +18,62 @@ use crate::frontend::symbols::{ }; use crate::frontend::typechecker::TypeChecker; +#[derive(Clone, Copy)] +struct DefaultPathContext<'a> { + checker: &'a TypeChecker, + owner_module_path: Option<&'a [String]>, +} + +impl<'a> DefaultPathContext<'a> { + /// Build the default-expression path context for the checker currently exporting one source module. + fn for_checker(checker: &'a TypeChecker) -> Self { + Self { + checker, + owner_module_path: non_root_module_path(checker.current_module_path.as_deref()), + } + } + + /// Resolve a default-expression value path to the module that owns it. + fn canonical_value_path(self, path: Vec) -> Vec { + let Some(first) = path.first() else { + return path; + }; + if let Some(imported_path) = self.checker.import_aliases.get(first) { + let mut canonical = imported_path.clone(); + canonical.extend(path.into_iter().skip(1)); + return canonical; + } + if path_is_already_absolute(&path) { + return path; + } + let Some(owner_module_path) = self.owner_module_path else { + return path; + }; + if path.starts_with(owner_module_path) { + return path; + } + let mut canonical = owner_module_path.to_vec(); + canonical.extend(path); + canonical + } +} + +/// Return the non-root source module path that should qualify provider-owned default expressions. +fn non_root_module_path(path: Option<&[String]>) -> Option<&[String]> { + let path = path?; + if matches!(path, [segment] if segment == "main" || segment == "lib") { + None + } else { + Some(path) + } +} + +/// Return whether a default-expression path is already rooted in a compiler-known namespace. +fn path_is_already_absolute(path: &[String]) -> bool { + matches!(path.first().map(String::as_str), Some("std" | "rust" | "pub")) + || path.first().map(String::as_str) == Some(incan_core::lang::stdlib::INCAN_STD_NAMESPACE) +} + #[derive(Debug, Clone)] pub struct CheckedTypeParam { pub name: String, @@ -58,10 +114,42 @@ pub struct CheckedFunctionExport { pub name: String, pub type_params: Vec, pub params: Vec, + pub param_defaults: Vec>, pub return_type: ResolvedType, pub is_async: bool, } +#[derive(Debug, Clone, PartialEq)] +pub enum CheckedParamDefault { + Int(i64), + Float(f64), + Bool(bool), + String(String), + Bytes(Vec), + None, + List(Vec), + Dict(Vec<(CheckedParamDefault, CheckedParamDefault)>), + ConstRef(Vec), + Call { + path: Vec, + args: Vec, + signature: Option, + }, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CheckedParamDefaultArg { + pub name: Option, + pub value: CheckedParamDefault, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CheckedParamDefaultCallSignature { + pub params: Vec, + pub return_type: ResolvedType, +} + #[derive(Debug, Clone)] pub struct CheckedPartialExport { pub name: String, @@ -402,6 +490,7 @@ fn checked_alias_function_export(name: &str, info: &FunctionInfo) -> CheckedFunc name: name.to_string(), type_params: checked_function_type_params(info), params: info.params.clone(), + param_defaults: vec![None; info.params.len()], return_type: info.return_type.clone(), is_async: info.is_async, } @@ -418,6 +507,7 @@ fn checked_projected_function_export(name: &str, kind: &SymbolKind) -> Option Optio let SymbolKind::Function(info) = &symbol.kind else { return None; }; + let default_context = DefaultPathContext::for_checker(checker); let presets = partial .args .iter() @@ -442,7 +533,7 @@ fn checked_partial_export(partial: &PartialDecl, checker: &TypeChecker) -> Optio .find(|param| param.name() == Some(arg.name.as_str())) .map(|param| param.ty.clone()) .unwrap_or(ResolvedType::Unknown), - value: checked_preset_value(&arg.value.node), + value: checked_preset_value(&arg.value.node, default_context), }) .collect(); Some(CheckedPartialExport { @@ -488,24 +579,24 @@ fn checked_partial_target_kind(partial: &PartialDecl, checker: &TypeChecker) -> } /// Convert a preset expression into the metadata-safe subset used by public partial provenance. -fn checked_preset_value(expr: &Expr) -> CheckedPresetValue { +fn checked_preset_value(expr: &Expr, context: DefaultPathContext<'_>) -> CheckedPresetValue { match expr { Expr::Literal(literal) => checked_preset_literal(literal), - Expr::Ident(name) => CheckedPresetValue::ConstRef(vec![name.clone()]), + Expr::Ident(name) => CheckedPresetValue::ConstRef(context.canonical_value_path(vec![name.clone()])), Expr::Field(base, field) => { let mut path = checked_preset_path(&base.node); if path.is_empty() { CheckedPresetValue::Unsupported } else { path.push(field.clone()); - CheckedPresetValue::ConstRef(path) + CheckedPresetValue::ConstRef(context.canonical_value_path(path)) } } Expr::List(entries) => CheckedPresetValue::List( entries .iter() .map(|entry| match entry { - ListEntry::Element(value) => checked_preset_value(&value.node), + ListEntry::Element(value) => checked_preset_value(&value.node, context), ListEntry::Spread(_) => CheckedPresetValue::Unsupported, }) .collect(), @@ -514,7 +605,10 @@ fn checked_preset_value(expr: &Expr) -> CheckedPresetValue { let pairs = entries .iter() .map(|entry| match entry { - DictEntry::Pair(key, value) => (checked_preset_value(&key.node), checked_preset_value(&value.node)), + DictEntry::Pair(key, value) => ( + checked_preset_value(&key.node, context), + checked_preset_value(&value.node, context), + ), DictEntry::Spread(_) => (CheckedPresetValue::Unsupported, CheckedPresetValue::Unsupported), }) .collect(); @@ -530,7 +624,7 @@ fn checked_preset_value(expr: &Expr) -> CheckedPresetValue { let crate::frontend::ast::CallArg::Named(field, value) = arg else { return CheckedPresetValue::Unsupported; }; - fields.push((field.clone(), checked_preset_value(&value.node))); + fields.push((field.clone(), checked_preset_value(&value.node, context))); } CheckedPresetValue::ModelLiteral { name: name.clone(), @@ -543,7 +637,7 @@ fn checked_preset_value(expr: &Expr) -> CheckedPresetValue { let crate::frontend::ast::CallArg::Named(field, value) = arg else { return CheckedPresetValue::Unsupported; }; - fields.push((field.clone(), checked_preset_value(&value.node))); + fields.push((field.clone(), checked_preset_value(&value.node, context))); } CheckedPresetValue::ModelLiteral { name: name.clone(), @@ -554,6 +648,134 @@ fn checked_preset_value(expr: &Expr) -> CheckedPresetValue { } } +/// Convert a source parameter default into the metadata-safe subset that public library consumers can materialize. +fn checked_param_default(expr: &Spanned, context: DefaultPathContext<'_>) -> CheckedParamDefault { + match &expr.node { + Expr::Literal(literal) => checked_param_default_literal(literal), + Expr::Ident(name) => CheckedParamDefault::ConstRef(context.canonical_value_path(vec![name.clone()])), + Expr::Field(base, field) => { + let mut path = checked_preset_path(&base.node); + if path.is_empty() { + CheckedParamDefault::Unsupported + } else { + path.push(field.clone()); + CheckedParamDefault::ConstRef(context.canonical_value_path(path)) + } + } + Expr::List(entries) => CheckedParamDefault::List( + entries + .iter() + .map(|entry| match entry { + ListEntry::Element(value) => checked_param_default(value, context), + ListEntry::Spread(_) => CheckedParamDefault::Unsupported, + }) + .collect(), + ), + Expr::Dict(entries) => CheckedParamDefault::Dict( + entries + .iter() + .map(|entry| match entry { + DictEntry::Pair(key, value) => ( + checked_param_default(key, context), + checked_param_default(value, context), + ), + DictEntry::Spread(_) => (CheckedParamDefault::Unsupported, CheckedParamDefault::Unsupported), + }) + .collect(), + ), + Expr::Call(callee, _type_args, args) => { + let path = context.canonical_value_path(checked_preset_path(&callee.node)); + if path.is_empty() { + return CheckedParamDefault::Unsupported; + } + let args = args + .iter() + .map(|arg| match arg { + crate::frontend::ast::CallArg::Positional(value) => CheckedParamDefaultArg { + name: None, + value: checked_param_default(value, context), + }, + crate::frontend::ast::CallArg::Named(name, value) => CheckedParamDefaultArg { + name: Some(name.clone()), + value: checked_param_default(value, context), + }, + crate::frontend::ast::CallArg::PositionalUnpack(_) + | crate::frontend::ast::CallArg::KeywordUnpack(_) => CheckedParamDefaultArg { + name: None, + value: CheckedParamDefault::Unsupported, + }, + }) + .collect(); + CheckedParamDefault::Call { + path, + args, + signature: checked_param_default_call_signature(callee, context.checker), + } + } + _ => CheckedParamDefault::Unsupported, + } +} + +/// Capture the checked callable surface for a default-expression helper call. +fn checked_param_default_call_signature( + callee: &Spanned, + checker: &TypeChecker, +) -> Option { + let callee_ty = checker + .type_info() + .expr_type(callee.span) + .cloned() + .or_else(|| checked_param_default_callee_symbol_type(&callee.node, checker))?; + let ResolvedType::Function(params, return_type) = callee_ty else { + return None; + }; + Some(CheckedParamDefaultCallSignature { + params, + return_type: return_type.as_ref().clone(), + }) +} + +/// Fallback callable-surface lookup for default calls whose callee span has no recorded type. +fn checked_param_default_callee_symbol_type(callee: &Expr, checker: &TypeChecker) -> Option { + let path = checked_preset_path(callee); + if path.is_empty() { + return None; + } + let symbol_names = [(path.len() > 1).then(|| path.join(".")), path.last().cloned()]; + for name in symbol_names.into_iter().flatten() { + let Some(symbol) = checker.lookup_symbol(&name) else { + continue; + }; + match &symbol.kind { + SymbolKind::Function(info) => { + return Some(ResolvedType::Function( + info.params.clone(), + Box::new(info.return_type.clone()), + )); + } + SymbolKind::Variable(VariableInfo { + ty: ResolvedType::Function(params, return_type), + .. + }) => return Some(ResolvedType::Function(params.clone(), return_type.clone())), + _ => {} + } + } + None +} + +/// Convert a literal parameter default into checked public metadata. +fn checked_param_default_literal(literal: &Literal) -> CheckedParamDefault { + match literal { + Literal::Int(value) => CheckedParamDefault::Int(value.value), + Literal::Float(value) => CheckedParamDefault::Float(value.value), + Literal::Decimal(value) => CheckedParamDefault::String(value.repr.clone()), + Literal::String(value) => CheckedParamDefault::String(value.clone()), + Literal::Bytes(value) => CheckedParamDefault::Bytes(value.clone()), + Literal::Bool(value) => CheckedParamDefault::Bool(*value), + Literal::None => CheckedParamDefault::None, + } +} + /// Convert a literal preset expression into checked partial export metadata. fn checked_preset_literal(literal: &Literal) -> CheckedPresetValue { match literal { @@ -604,9 +826,21 @@ fn checked_function_export(function: &FunctionDecl, checker: &TypeChecker) -> Op _ => return None, }; + let default_context = DefaultPathContext::for_checker(checker); Some(CheckedFunctionExport { name: function.name.clone(), type_params: checked_type_params(&function.type_params, checker), + param_defaults: function + .params + .iter() + .map(|param| { + param + .node + .default + .as_ref() + .map(|default| checked_param_default(default, default_context)) + }) + .collect(), params, return_type, is_async, @@ -995,6 +1229,10 @@ mod tests { "method".to_string(), ); - assert_eq!(checked_preset_value(&value), CheckedPresetValue::Unsupported); + let checker = TypeChecker::new(); + assert_eq!( + checked_preset_value(&value, DefaultPathContext::for_checker(&checker)), + CheckedPresetValue::Unsupported + ); } } diff --git a/src/frontend/typechecker/collect/stdlib_imports.rs b/src/frontend/typechecker/collect/stdlib_imports.rs index 17655df9b..e9ef88005 100644 --- a/src/frontend/typechecker/collect/stdlib_imports.rs +++ b/src/frontend/typechecker/collect/stdlib_imports.rs @@ -15,9 +15,9 @@ use crate::frontend::typechecker::TypeChecker; use crate::frontend::typechecker::type_info::RustTraitImportInfo; use crate::library_manifest::{ AliasExport, ClassExport, ConstExport, EnumExport, EnumValueExport, EnumValueTypeExport, FieldExport, - FunctionExport, LibraryManifest, MethodExport, ModelExport, NewtypeExport, ParamExport, ParamKindExport, - PartialExport, ReceiverExport, StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, TypeParamExport, - resolved_type_from_manifest_type_ref, + FunctionExport, LibraryManifest, MethodExport, ModelExport, NewtypeExport, ParamDefaultExport, ParamExport, + ParamKindExport, PartialExport, ReceiverExport, StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, + TypeParamExport, resolved_type_from_manifest_type_ref, }; use incan_core::interop::{RustItemKind, RustTraitAssoc, fallback_rust_trait_methods, is_rust_capability_bound}; use incan_core::lang::stdlib::{self, is_typechecker_only_stdlib}; @@ -1526,7 +1526,10 @@ impl TypeChecker { param.name.clone(), resolved_type_from_manifest_type_ref(¶m.ty), param_kind_from_manifest(param.kind), - param.has_default, + param + .default + .as_ref() + .map_or(param.has_default, ParamDefaultExport::is_materializable), ) }) .collect() diff --git a/src/frontend/typechecker/tests.rs b/src/frontend/typechecker/tests.rs index 3b3bd8e93..334f1f239 100644 --- a/src/frontend/typechecker/tests.rs +++ b/src/frontend/typechecker/tests.rs @@ -14,9 +14,9 @@ use crate::frontend::{lexer, parser}; use crate::library_manifest::{ AliasExport, ClassExport, ConstExport, EnumExport, EnumValueExport, EnumValueTypeExport, EnumVariantExport, FunctionExport, LibraryContractMetadata, LibraryExports, LibraryManifest, LibraryRustAbi, MethodExport, - ModelExport, ParamExport, ParamKindExport, PartialExport, PartialPresetExport, PartialTargetKindExport, - PresetValueExport, ReceiverExport, StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, TypeParamExport, - TypeRef, + ModelExport, ParamDefaultCallArgExport, ParamDefaultCallSignatureExport, ParamDefaultExport, ParamExport, + ParamKindExport, PartialExport, PartialPresetExport, PartialTargetKindExport, PresetValueExport, ReceiverExport, + StaticExport, TraitExport, TypeAliasExport, TypeBoundExport, TypeParamExport, TypeRef, }; #[cfg(feature = "rust_inspect")] use crate::rust_inspect::{Inspector, InspectorConfig, write_borrowed_param_probe_crate, write_substrait_probe_crate}; @@ -1411,6 +1411,7 @@ fn library_index_with_mylib_exports() -> LibraryManifestIndex { }, kind: ParamKindExport::Normal, has_default: true, + default: None, }], return_type: TypeRef::Named { name: "Widget".to_string(), @@ -1437,6 +1438,7 @@ fn library_index_with_mylib_exports() -> LibraryManifestIndex { }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "Widget".to_string(), @@ -1555,6 +1557,7 @@ fn library_index_with_callable_alias_export() -> LibraryManifestIndex { }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "int".to_string(), @@ -1839,6 +1842,7 @@ fn library_index_with_pub_boundary_type_fidelity_exports() -> LibraryManifestInd }, kind: ParamKindExport::Normal, has_default: false, + default: None, }, ParamExport { name: "uri".to_string(), @@ -1847,6 +1851,7 @@ fn library_index_with_pub_boundary_type_fidelity_exports() -> LibraryManifestInd }, kind: ParamKindExport::Normal, has_default: false, + default: None, }, ], return_type: TypeRef::Applied { @@ -1877,6 +1882,7 @@ fn library_index_with_pub_boundary_type_fidelity_exports() -> LibraryManifestInd }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Applied { name: "Result".to_string(), @@ -1957,6 +1963,7 @@ fn library_index_with_pub_boundary_type_fidelity_exports() -> LibraryManifestInd }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: none_constructor_name(), @@ -13070,6 +13077,86 @@ pub model Reading with Convert[int], Convert[float]: Ok(()) } +#[test] +fn test_checked_public_exports_qualify_default_expression_provider_paths() -> Result<(), Box> { + let defaults_source = r#" +pub const FALLBACK: str = "fallback" + +pub def make_label(value: str) -> str: + return value +"#; + let source = r#" +from defaults import FALLBACK, make_label + +pub const LOCAL_SENTINEL: str = "local" + +pub def imported_default(label: str = make_label(FALLBACK)) -> str: + return label + +pub def local_default(label: str = LOCAL_SENTINEL) -> str: + return label +"#; + + let defaults_tokens = lexer::lex(defaults_source).map_err(|errs| std::io::Error::other(format!("{errs:?}")))?; + let defaults_ast = parser::parse(&defaults_tokens).map_err(|errs| std::io::Error::other(format!("{errs:?}")))?; + let tokens = lexer::lex(source).map_err(|errs| std::io::Error::other(format!("{errs:?}")))?; + let ast = parser::parse(&tokens).map_err(|errs| std::io::Error::other(format!("{errs:?}")))?; + + let mut checker = TypeChecker::new(); + checker.set_current_module_path(Some(vec!["helpers".to_string()])); + checker + .check_with_imports(&ast, &[("defaults", &defaults_ast)]) + .map_err(|errs| std::io::Error::other(format!("{errs:?}")))?; + + let exports = collect_checked_public_exports(&ast, &checker); + let manifest = LibraryManifest::from_checked_exports("querykit".to_string(), "0.1.0".to_string(), &exports); + let imported = manifest + .exports + .functions + .iter() + .find(|function| function.name == "imported_default") + .ok_or("missing imported_default export")?; + let local = manifest + .exports + .functions + .iter() + .find(|function| function.name == "local_default") + .ok_or("missing local_default export")?; + + assert_eq!( + imported.params[0].default, + Some(ParamDefaultExport::Call { + path: vec!["defaults".to_string(), "make_label".to_string()], + args: vec![ParamDefaultCallArgExport { + name: None, + value: ParamDefaultExport::ConstRef(vec!["defaults".to_string(), "FALLBACK".to_string()]), + }], + signature: Some(ParamDefaultCallSignatureExport { + params: vec![ParamExport { + name: "value".to_string(), + ty: TypeRef::Named { + name: "str".to_string(), + }, + kind: ParamKindExport::Normal, + has_default: false, + default: None, + }], + return_type: TypeRef::Named { + name: "str".to_string(), + }, + }), + }) + ); + assert_eq!( + local.params[0].default, + Some(ParamDefaultExport::ConstRef(vec![ + "helpers".to_string(), + "LOCAL_SENTINEL".to_string(), + ])) + ); + Ok(()) +} + #[test] fn test_pub_import_multi_instantiation_trait_adoptions_check_type_args() { let source = r#" diff --git a/src/frontend/vocab_ast_bridge.rs b/src/frontend/vocab_ast_bridge.rs index 3343595a1..be1e5f094 100644 --- a/src/frontend/vocab_ast_bridge.rs +++ b/src/frontend/vocab_ast_bridge.rs @@ -11,6 +11,48 @@ use crate::frontend::ast; const CURRENT_FIELD_SENTINEL_IDENT: &str = "__incan_vocab_current_row"; +const SYNTHETIC_SPAN_BASE: usize = 1usize << 48; + +/// Allocates unique spans for AST nodes synthesized from desugarer output. +/// +/// The public vocab AST intentionally does not assign source offsets to every helper-produced expression, but later +/// compiler phases use spans as stable keys for typechecker metadata such as call-planning decisions. Giving every +/// generated node a distinct synthetic span avoids collapsing nested helper calls into one `Span::default()` entry. +#[derive(Debug, Clone)] +struct SyntheticSpanAllocator { + next_start: usize, + preserve_default: bool, +} + +impl SyntheticSpanAllocator { + /// Create an allocator whose synthetic span range is anchored by the original vocab surface span. + fn new(anchor: ast::Span) -> Self { + if anchor == ast::Span::default() { + return Self { + next_start: 0, + preserve_default: true, + }; + } + let seed = anchor + .start + .saturating_mul(257) + .saturating_add(anchor.end.saturating_mul(17)); + Self { + next_start: SYNTHETIC_SPAN_BASE.saturating_add(seed.saturating_mul(4)), + preserve_default: false, + } + } + + /// Return the next unique synthetic span for one internal AST node generated from public vocab output. + fn next(&mut self) -> ast::Span { + if self.preserve_default { + return ast::Span::default(); + } + let start = self.next_start; + self.next_start = self.next_start.saturating_add(2); + ast::Span::new(start, start.saturating_add(1)) + } +} /// Mapping failures produced by the AST bridge. /// @@ -375,12 +417,29 @@ pub fn internal_statement_to_public(stmt: &ast::Statement) -> Result Result>, VocabAstBridgeError> { + public_statements_to_internal_with_anchor(stmts, ast::Span::default()) +} + +/// Convert public statements into internal statements while assigning unique synthetic spans under `anchor`. +pub fn public_statements_to_internal_with_anchor( + stmts: &[incan_vocab::IncanStatement], + anchor: ast::Span, +) -> Result>, VocabAstBridgeError> { + let mut spans = SyntheticSpanAllocator::new(anchor); + public_statements_to_internal_with_spans(stmts, &mut spans) +} + +/// Convert public statements while sharing one synthetic span allocator across the whole generated subtree. +fn public_statements_to_internal_with_spans( + stmts: &[incan_vocab::IncanStatement], + spans: &mut SyntheticSpanAllocator, ) -> Result>, VocabAstBridgeError> { stmts .iter() .map(|stmt| { - let internal = public_statement_to_internal(stmt)?; - Ok(ast::Spanned::new(internal, ast::Span::default())) + let internal = public_statement_to_internal_with_spans(stmt, spans)?; + Ok(ast::Spanned::new(internal, spans.next())) }) .collect() } @@ -392,23 +451,34 @@ pub fn public_statements_to_internal( /// Returns [`VocabAstBridgeError`] when the public statement (or any contained expression) does not /// currently have a supported internal mapping. pub fn public_statement_to_internal(stmt: &incan_vocab::IncanStatement) -> Result { + let mut spans = SyntheticSpanAllocator::new(ast::Span::default()); + public_statement_to_internal_with_spans(stmt, &mut spans) +} + +/// Convert one public statement with a caller-owned synthetic span allocator. +fn public_statement_to_internal_with_spans( + stmt: &incan_vocab::IncanStatement, + spans: &mut SyntheticSpanAllocator, +) -> Result { match stmt { incan_vocab::IncanStatement::Pass => Ok(ast::Statement::Pass), incan_vocab::IncanStatement::Expr(expr) => Ok(ast::Statement::Expr(ast::Spanned::new( - public_expr_to_internal(expr)?, - ast::Span::default(), + public_expr_to_internal_with_spans(expr, spans)?, + spans.next(), ))), incan_vocab::IncanStatement::Return(value) => Ok(ast::Statement::Return( value .as_ref() - .map(|expr| public_expr_to_internal(expr).map(|node| ast::Spanned::new(node, ast::Span::default()))) + .map(|expr| { + public_expr_to_internal_with_spans(expr, spans).map(|node| ast::Spanned::new(node, spans.next())) + }) .transpose()?, )), incan_vocab::IncanStatement::Assign { target, value } => Ok(ast::Statement::Assignment(ast::AssignmentStmt { binding: ast::BindingKind::Reassign, name: target.clone(), ty: None, - value: ast::Spanned::new(public_expr_to_internal(value)?, ast::Span::default()), + value: ast::Spanned::new(public_expr_to_internal_with_spans(value, spans)?, spans.next()), })), incan_vocab::IncanStatement::Let { name, mutable, value } => { Ok(ast::Statement::Assignment(ast::AssignmentStmt { @@ -419,7 +489,7 @@ pub fn public_statement_to_internal(stmt: &incan_vocab::IncanStatement) -> Resul }, name: name.clone(), ty: None, - value: ast::Spanned::new(public_expr_to_internal(value)?, ast::Span::default()), + value: ast::Spanned::new(public_expr_to_internal_with_spans(value, spans)?, spans.next()), })) } incan_vocab::IncanStatement::If { @@ -428,28 +498,28 @@ pub fn public_statement_to_internal(stmt: &incan_vocab::IncanStatement) -> Resul else_body, } => Ok(ast::Statement::If(ast::IfStmt { condition: ast::Condition::Expr(ast::Spanned::new( - public_expr_to_internal(condition)?, - ast::Span::default(), + public_expr_to_internal_with_spans(condition, spans)?, + spans.next(), )), - then_body: public_statements_to_internal(then_body)?, + then_body: public_statements_to_internal_with_spans(then_body, spans)?, elif_branches: Vec::new(), else_body: if else_body.is_empty() { None } else { - Some(public_statements_to_internal(else_body)?) + Some(public_statements_to_internal_with_spans(else_body, spans)?) }, })), incan_vocab::IncanStatement::While { condition, body } => Ok(ast::Statement::While(ast::WhileStmt { condition: ast::Condition::Expr(ast::Spanned::new( - public_expr_to_internal(condition)?, - ast::Span::default(), + public_expr_to_internal_with_spans(condition, spans)?, + spans.next(), )), - body: public_statements_to_internal(body)?, + body: public_statements_to_internal_with_spans(body, spans)?, })), incan_vocab::IncanStatement::For { binding, iter, body } => Ok(ast::Statement::For(ast::ForStmt { - pattern: ast::Spanned::new(ast::Pattern::Binding(binding.clone()), ast::Span::default()), - iter: ast::Spanned::new(public_expr_to_internal(iter)?, ast::Span::default()), - body: public_statements_to_internal(body)?, + pattern: ast::Spanned::new(ast::Pattern::Binding(binding.clone()), spans.next()), + iter: ast::Spanned::new(public_expr_to_internal_with_spans(iter, spans)?, spans.next()), + body: public_statements_to_internal_with_spans(body, spans)?, })), _ => Err(VocabAstBridgeError::UnsupportedPublicStatement( "statement form is not yet supported by internal AST bridge", @@ -716,6 +786,24 @@ fn internal_race_for_to_public(race: &ast::RaceForExpr) -> Result Result { + let mut spans = SyntheticSpanAllocator::new(ast::Span::default()); + public_expr_to_internal_with_spans(expr, &mut spans) +} + +/// Convert one public expression into internal AST while assigning synthetic spans rooted at `anchor`. +pub fn public_expr_to_internal_with_anchor( + expr: &incan_vocab::IncanExpr, + anchor: ast::Span, +) -> Result { + let mut spans = SyntheticSpanAllocator::new(anchor); + public_expr_to_internal_with_spans(expr, &mut spans) +} + +/// Convert one public expression with a caller-owned synthetic span allocator. +fn public_expr_to_internal_with_spans( + expr: &incan_vocab::IncanExpr, + spans: &mut SyntheticSpanAllocator, +) -> Result { match expr { incan_vocab::IncanExpr::Name(name) => Ok(ast::Expr::Ident(name.clone())), incan_vocab::IncanExpr::Str(value) => Ok(ast::Expr::Literal(ast::Literal::String(value.clone()))), @@ -726,27 +814,26 @@ pub fn public_expr_to_internal(expr: &incan_vocab::IncanExpr) -> Result Ok(ast::Expr::Field( Box::new(ast::Spanned::new( ast::Expr::Ident(CURRENT_FIELD_SENTINEL_IDENT.to_string()), - ast::Span::default(), + spans.next(), )), field.clone(), )), incan_vocab::IncanExpr::RelationField { relation, field } => Ok(ast::Expr::Field( - Box::new(ast::Spanned::new( - ast::Expr::Ident(relation.clone()), - ast::Span::default(), - )), + Box::new(ast::Spanned::new(ast::Expr::Ident(relation.clone()), spans.next())), field.clone(), )), incan_vocab::IncanExpr::Tuple(values) => values .iter() - .map(|value| public_expr_to_internal(value).map(|node| ast::Spanned::new(node, ast::Span::default()))) + .map(|value| { + public_expr_to_internal_with_spans(value, spans).map(|node| ast::Spanned::new(node, spans.next())) + }) .collect::, _>>() .map(ast::Expr::Tuple), incan_vocab::IncanExpr::List(values) => values .iter() .map(|value| { - public_expr_to_internal(value) - .map(|node| ast::ListEntry::Element(ast::Spanned::new(node, ast::Span::default()))) + public_expr_to_internal_with_spans(value, spans) + .map(|node| ast::ListEntry::Element(ast::Spanned::new(node, spans.next()))) }) .collect::, _>>() .map(ast::Expr::List), @@ -754,8 +841,8 @@ pub fn public_expr_to_internal(expr: &incan_vocab::IncanExpr) -> Result Result Ok(ast::Expr::Binary( - Box::new(ast::Spanned::new(public_expr_to_internal(left)?, ast::Span::default())), + Box::new(ast::Spanned::new( + public_expr_to_internal_with_spans(left, spans)?, + spans.next(), + )), map_public_binary_op(*op)?, - Box::new(ast::Spanned::new(public_expr_to_internal(right)?, ast::Span::default())), + Box::new(ast::Spanned::new( + public_expr_to_internal_with_spans(right, spans)?, + spans.next(), + )), )), incan_vocab::IncanExpr::Call { callee, args } => { let mapped = args .iter() .map(|arg| { - public_expr_to_internal(arg) - .map(|node| ast::CallArg::Positional(ast::Spanned::new(node, ast::Span::default()))) + public_expr_to_internal_with_spans(arg, spans) + .map(|node| ast::CallArg::Positional(ast::Spanned::new(node, spans.next()))) }) .collect::, _>>()?; if let incan_vocab::IncanExpr::Field { object, field } = callee.as_ref() { return Ok(ast::Expr::MethodCall( Box::new(ast::Spanned::new( - public_expr_to_internal(object)?, - ast::Span::default(), + public_expr_to_internal_with_spans(object, spans)?, + spans.next(), )), field.clone(), Vec::new(), @@ -799,8 +895,8 @@ pub fn public_expr_to_internal(expr: &incan_vocab::IncanExpr) -> Result Result Ok(ast::Expr::Field( Box::new(ast::Spanned::new( - public_expr_to_internal(object)?, - ast::Span::default(), + public_expr_to_internal_with_spans(object, spans)?, + spans.next(), )), field.clone(), )), - incan_vocab::IncanExpr::RaceFor(race) => public_race_for_to_internal(race), - incan_vocab::IncanExpr::ScopedSurface(surface) => public_scoped_surface_expr_to_internal(surface), - incan_vocab::IncanExpr::ScopedSymbolCall(call) => public_scoped_symbol_call_to_internal(call), + incan_vocab::IncanExpr::RaceFor(race) => public_race_for_to_internal(race, spans), + incan_vocab::IncanExpr::ScopedSurface(surface) => public_scoped_surface_expr_to_internal(surface, spans), + incan_vocab::IncanExpr::ScopedSymbolCall(call) => public_scoped_symbol_call_to_internal(call, spans), _ => Err(VocabAstBridgeError::UnsupportedPublicExpression( "expression form is not yet supported by internal AST bridge", )), @@ -823,17 +919,21 @@ pub fn public_expr_to_internal(expr: &incan_vocab::IncanExpr) -> Result Result { +fn public_race_for_to_internal( + race: &incan_vocab::IncanRaceForExpr, + spans: &mut SyntheticSpanAllocator, +) -> Result { let arms = race .arms .iter() .map(|arm| { let body = match &arm.body { - incan_vocab::IncanRaceForBody::Expr(expr) => { - ast::RaceForBody::Expr(ast::Spanned::new(public_expr_to_internal(expr)?, ast::Span::default())) - } + incan_vocab::IncanRaceForBody::Expr(expr) => ast::RaceForBody::Expr(ast::Spanned::new( + public_expr_to_internal_with_spans(expr, spans)?, + spans.next(), + )), incan_vocab::IncanRaceForBody::Block(statements) => { - ast::RaceForBody::Block(public_statements_to_internal(statements)?) + ast::RaceForBody::Block(public_statements_to_internal_with_spans(statements, spans)?) } _ => { return Err(VocabAstBridgeError::UnsupportedPublicExpression( @@ -842,7 +942,7 @@ fn public_race_for_to_internal(race: &incan_vocab::IncanRaceForExpr) -> Result Result Result { let key = incan_semantics_core::SurfaceFeatureKey::ScopedDslSurface { dependency_key: surface.dependency_key.clone(), @@ -888,8 +989,14 @@ fn public_scoped_surface_expr_to_internal( owner, } => ast::SurfaceExprPayload::ScopedGlyph { glyph: glyph.clone(), - left: Box::new(ast::Spanned::new(public_expr_to_internal(left)?, ast::Span::default())), - right: Box::new(ast::Spanned::new(public_expr_to_internal(right)?, ast::Span::default())), + left: Box::new(ast::Spanned::new( + public_expr_to_internal_with_spans(left, spans)?, + spans.next(), + )), + right: Box::new(ast::Spanned::new( + public_expr_to_internal_with_spans(right, spans)?, + spans.next(), + )), owner: ast::ScopedSurfaceOwner { declaration: owner.declaration.clone(), clause: owner.clause.clone(), @@ -908,6 +1015,7 @@ fn public_scoped_surface_expr_to_internal( /// Convert a public scoped-symbol call back into the compiler AST. fn public_scoped_symbol_call_to_internal( call: &incan_vocab::IncanScopedSymbolCall, + spans: &mut SyntheticSpanAllocator, ) -> Result { let key = incan_semantics_core::SurfaceFeatureKey::ScopedDslSurface { dependency_key: call.dependency_key.clone(), @@ -917,8 +1025,8 @@ fn public_scoped_symbol_call_to_internal( .args .iter() .map(|arg| { - public_expr_to_internal(arg) - .map(|node| ast::CallArg::Positional(ast::Spanned::new(node, ast::Span::default()))) + public_expr_to_internal_with_spans(arg, spans) + .map(|node| ast::CallArg::Positional(ast::Spanned::new(node, spans.next()))) }) .collect::, _>>()?; let payload = ast::SurfaceExprPayload::ScopedSymbolCall { @@ -1182,6 +1290,36 @@ mod tests { Ok(()) } + #[test] + fn public_expression_anchor_assigns_distinct_spans_to_nested_calls() -> Result<(), Box> { + let expr = incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Name("outer".to_string())), + args: vec![incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Name("inner".to_string())), + args: vec![incan_vocab::IncanExpr::Int(5)], + }], + }; + + let internal = public_expr_to_internal_with_anchor(&expr, ast::Span::new(10, 20))?; + let ast::Expr::Call(_, _, args) = internal else { + return Err(format!("expected outer call, got {internal:?}").into()); + }; + let ast::CallArg::Positional(inner) = &args[0] else { + return Err(format!("expected positional inner call, got {:?}", args[0]).into()); + }; + let ast::Expr::Call(_, _, inner_args) = &inner.node else { + return Err(format!("expected nested call, got {:?}", inner.node).into()); + }; + let ast::CallArg::Positional(value) = &inner_args[0] else { + return Err(format!("expected positional literal, got {:?}", inner_args[0]).into()); + }; + + assert_ne!(inner.span, value.span); + assert_ne!(inner.span, ast::Span::default()); + assert_ne!(value.span, ast::Span::default()); + Ok(()) + } + #[test] fn bridges_expression_list_clause_alias_items() -> Result<(), Box> { let clause_block = ast::VocabBlockStmt { diff --git a/src/frontend/vocab_desugar_pass/rewrite.rs b/src/frontend/vocab_desugar_pass/rewrite.rs index 67d4ef531..3e067e6f9 100644 --- a/src/frontend/vocab_desugar_pass/rewrite.rs +++ b/src/frontend/vocab_desugar_pass/rewrite.rs @@ -2,7 +2,7 @@ use crate::frontend::ast; use crate::frontend::diagnostics::CompileError; use crate::frontend::library_manifest_index::LibraryManifestIndex; use crate::frontend::vocab_ast_bridge::{ - internal_vocab_block_to_public, public_expr_to_internal, public_statements_to_internal, + internal_vocab_block_to_public, public_expr_to_internal_with_anchor, public_statements_to_internal_with_anchor, }; use super::helper_bindings::{ @@ -407,7 +407,7 @@ fn rewrite_statement_list( continue; } - let mut lowered = match public_statements_to_internal(&public_statements) { + let mut lowered = match public_statements_to_internal_with_anchor(&public_statements, span) { Ok(stmts) => stmts, Err(source) => { errors.push(error_from_pass_error( @@ -1467,7 +1467,7 @@ fn desugar_vocab_block_to_expression( ) })?; - public_expr_to_internal(expression).map_err(|source| { + public_expr_to_internal_with_anchor(expression, span).map_err(|source| { error_from_pass_error( VocabDesugarPassError::Bridge { keyword: bridged_keyword, diff --git a/src/library_manifest/model.rs b/src/library_manifest/model.rs index c5df2b7c5..5d983f534 100644 --- a/src/library_manifest/model.rs +++ b/src/library_manifest/model.rs @@ -13,9 +13,9 @@ use crate::frontend::api_metadata::CheckedApiMetadataPackage; use crate::frontend::contract_metadata::ContractMetadataPackage as ModelContractMetadataPackage; use crate::frontend::library_exports::{ CheckedAliasExport, CheckedClassExport, CheckedConstExport, CheckedEnumExport, CheckedExportKind, - CheckedFunctionExport, CheckedModelExport, CheckedNamedExport, CheckedNewtypeExport, CheckedPartialExport, - CheckedPartialTargetKind, CheckedPresetValue, CheckedStaticExport, CheckedTraitExport, CheckedTypeAliasExport, - CheckedTypeBound, CheckedTypeParam, + CheckedFunctionExport, CheckedModelExport, CheckedNamedExport, CheckedNewtypeExport, CheckedParamDefault, + CheckedParamDefaultCallSignature, CheckedPartialExport, CheckedPartialTargetKind, CheckedPresetValue, + CheckedStaticExport, CheckedTraitExport, CheckedTypeAliasExport, CheckedTypeBound, CheckedTypeParam, }; use crate::frontend::symbols::{CallableParam, ValueEnumBacking, ValueEnumValue}; use incan_core::interop::RustItemMetadata; @@ -359,6 +359,67 @@ pub struct ParamExport { pub kind: ParamKindExport, #[serde(default)] pub has_default: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +/// Metadata-safe callable parameter default expression. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum ParamDefaultExport { + Int(i64), + Float(String), + Bool(bool), + String(String), + Bytes(Vec), + None, + List(Vec), + Dict(Vec), + ConstRef(Vec), + Call { + path: Vec, + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + }, + Unsupported, +} + +impl ParamDefaultExport { + /// Return whether a consumer can materialize this exported default expression at its own call site. + pub fn is_materializable(&self) -> bool { + match self { + Self::Int(_) | Self::Float(_) | Self::Bool(_) | Self::String(_) | Self::Bytes(_) | Self::None => true, + Self::ConstRef(path) => !path.is_empty(), + Self::List(values) => values.iter().all(Self::is_materializable), + Self::Dict(entries) => entries + .iter() + .all(|entry| entry.key.is_materializable() && entry.value.is_materializable()), + Self::Call { path, args, .. } => !path.is_empty() && args.iter().all(|arg| arg.value.is_materializable()), + Self::Unsupported => false, + } + } +} + +/// One metadata-safe dict default entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParamDefaultDictEntryExport { + pub key: ParamDefaultExport, + pub value: ParamDefaultExport, +} + +/// One metadata-safe call default argument. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParamDefaultCallArgExport { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub value: ParamDefaultExport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ParamDefaultCallSignatureExport { + pub params: Vec, + pub return_type: TypeRef, } /// Exported callable parameter kind. @@ -704,7 +765,7 @@ fn partial_export_from_checked(export: &CheckedPartialExport) -> PartialExport { }) .collect(), type_params: export.type_params.iter().map(type_param_from_checked).collect(), - params: params_from_checked(&export.params), + params: params_from_checked(&export.params, &[]), return_type: type_ref_from_resolved(&export.return_type), is_async: export.is_async, } @@ -758,6 +819,60 @@ fn preset_value_from_checked(value: &CheckedPresetValue) -> PresetValueExport { } } +/// Convert checked parameter defaults into the manifest default-expression vocabulary when consumers can materialize +/// them. +fn param_default_from_checked(value: &CheckedParamDefault) -> Option { + match value { + CheckedParamDefault::Int(value) => Some(ParamDefaultExport::Int(*value)), + CheckedParamDefault::Float(value) => Some(ParamDefaultExport::Float(value.to_string())), + CheckedParamDefault::Bool(value) => Some(ParamDefaultExport::Bool(*value)), + CheckedParamDefault::String(value) => Some(ParamDefaultExport::String(value.clone())), + CheckedParamDefault::Bytes(value) => Some(ParamDefaultExport::Bytes(value.clone())), + CheckedParamDefault::None => Some(ParamDefaultExport::None), + CheckedParamDefault::List(values) => values + .iter() + .map(param_default_from_checked) + .collect::>>() + .map(ParamDefaultExport::List), + CheckedParamDefault::Dict(entries) => entries + .iter() + .map(|(key, value)| { + Some(ParamDefaultDictEntryExport { + key: param_default_from_checked(key)?, + value: param_default_from_checked(value)?, + }) + }) + .collect::>>() + .map(ParamDefaultExport::Dict), + CheckedParamDefault::ConstRef(path) => Some(ParamDefaultExport::ConstRef(path.clone())), + CheckedParamDefault::Call { path, args, signature } => args + .iter() + .map(|arg| { + Some(ParamDefaultCallArgExport { + name: arg.name.clone(), + value: param_default_from_checked(&arg.value)?, + }) + }) + .collect::>>() + .map(|args| ParamDefaultExport::Call { + path: path.clone(), + args, + signature: signature.as_ref().map(param_default_call_signature_from_checked), + }), + CheckedParamDefault::Unsupported => None, + } +} + +/// Convert a checked default-helper callable surface into manifest metadata. +fn param_default_call_signature_from_checked( + signature: &CheckedParamDefaultCallSignature, +) -> ParamDefaultCallSignatureExport { + ParamDefaultCallSignatureExport { + params: params_from_checked(&signature.params, &[]), + return_type: type_ref_from_resolved(&signature.return_type), + } +} + fn type_param_from_checked(type_param: &CheckedTypeParam) -> TypeParamExport { TypeParamExport { name: type_param.name.clone(), @@ -776,15 +891,27 @@ fn type_bound_from_checked(bound: &CheckedTypeBound) -> TypeBoundExport { } /// Convert checked callable parameters into library-manifest parameter records. -fn params_from_checked(params: &[CallableParam]) -> Vec { +fn params_from_checked(params: &[CallableParam], defaults: &[Option]) -> Vec { params .iter() + .enumerate() .filter_map(|param| { + let (idx, param) = param; + let default = defaults + .get(idx) + .and_then(|default| default.as_ref()) + .and_then(param_default_from_checked); + let has_default = if defaults.is_empty() { + param.has_default + } else { + default.is_some() + }; Some(ParamExport { name: param.name.clone()?, ty: type_ref_from_resolved(¶m.ty), kind: param_kind_from_ast(param.kind), - has_default: param.has_default, + has_default, + default, }) }) .collect() @@ -813,7 +940,7 @@ fn method_from_checked(method: &crate::frontend::library_exports::CheckedMethod) alias_of: method.alias_of.clone(), type_params: method.type_params.iter().map(type_param_from_checked).collect(), receiver: receiver_from_checked(method.receiver), - params: params_from_checked(&method.params), + params: params_from_checked(&method.params, &[]), return_type: type_ref_from_resolved(&method.return_type), is_async: method.is_async, has_body: method.has_body, @@ -830,11 +957,12 @@ fn field_from_checked(field: &crate::frontend::library_exports::CheckedField) -> } } -fn function_export_from_checked(export: &CheckedFunctionExport) -> FunctionExport { +/// Convert a checked source function export into manifest metadata, including the materializable default subset. +pub(super) fn function_export_from_checked(export: &CheckedFunctionExport) -> FunctionExport { FunctionExport { name: export.name.clone(), type_params: export.type_params.iter().map(type_param_from_checked).collect(), - params: params_from_checked(&export.params), + params: params_from_checked(&export.params, &export.param_defaults), return_type: type_ref_from_resolved(&export.return_type), is_async: export.is_async, } diff --git a/src/library_manifest/tests.rs b/src/library_manifest/tests.rs index 659660347..adbb78415 100644 --- a/src/library_manifest/tests.rs +++ b/src/library_manifest/tests.rs @@ -30,6 +30,7 @@ fn manifest_io_round_trip_preserves_recursive_types_and_bounds() -> Result<(), B }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Function { params: vec![TypeRef::Tuple { @@ -79,6 +80,7 @@ fn manifest_io_round_trip_preserves_partial_exports() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { + let mut manifest = LibraryManifest::new("mylib", "0.1.0"); + manifest.exports.functions.push(FunctionExport { + name: "with_default".to_string(), + type_params: Vec::new(), + params: vec![ParamExport { + name: "value".to_string(), + ty: TypeRef::Named { + name: "int".to_string(), + }, + kind: ParamKindExport::Normal, + has_default: true, + default: Some(ParamDefaultExport::Call { + path: vec!["fallback".to_string()], + args: vec![ParamDefaultCallArgExport { + name: None, + value: ParamDefaultExport::Int(0), + }], + signature: None, + }), + }], + return_type: TypeRef::Named { + name: "int".to_string(), + }, + is_async: false, + }); + + let tmp = tempfile::tempdir()?; + let path = tmp.path().join("defaults.incnlib"); + manifest.write_to_path(&path)?; + let loaded = LibraryManifest::read_from_path(&path)?; + + assert_eq!(loaded, manifest); + Ok(()) +} + +#[test] +fn function_export_from_checked_marks_only_materializable_defaults_as_omittable() { + let export = super::model::function_export_from_checked(&crate::frontend::library_exports::CheckedFunctionExport { + name: "with_default".to_string(), + type_params: Vec::new(), + params: vec![ + crate::frontend::symbols::CallableParam::named_with_default( + "ok", + crate::frontend::symbols::ResolvedType::Int, + crate::frontend::ast::ParamKind::Normal, + true, + ), + crate::frontend::symbols::CallableParam::named_with_default( + "not_exportable", + crate::frontend::symbols::ResolvedType::Int, + crate::frontend::ast::ParamKind::Normal, + true, + ), + ], + param_defaults: vec![ + Some(crate::frontend::library_exports::CheckedParamDefault::Int(1)), + Some(crate::frontend::library_exports::CheckedParamDefault::Unsupported), + ], + return_type: crate::frontend::symbols::ResolvedType::Unit, + is_async: false, + }); + + assert!(export.params[0].has_default); + assert_eq!(export.params[0].default, Some(ParamDefaultExport::Int(1))); + assert!(!export.params[1].has_default); + assert_eq!(export.params[1].default, None); +} + +#[test] +fn parameter_default_materializability_is_all_or_nothing() { + let empty_call = ParamDefaultExport::Call { + path: Vec::new(), + args: Vec::new(), + signature: None, + }; + let partially_unsupported_list = + ParamDefaultExport::List(vec![ParamDefaultExport::Int(1), ParamDefaultExport::Unsupported]); + let partially_unsupported_dict = ParamDefaultExport::Dict(vec![ParamDefaultDictEntryExport { + key: ParamDefaultExport::String("key".to_string()), + value: ParamDefaultExport::Unsupported, + }]); + let partially_unsupported_call = ParamDefaultExport::Call { + path: vec!["fallback".to_string()], + args: vec![ParamDefaultCallArgExport { + name: None, + value: ParamDefaultExport::Unsupported, + }], + signature: None, + }; + + assert!(!empty_call.is_materializable()); + assert!(!partially_unsupported_list.is_materializable()); + assert!(!partially_unsupported_dict.is_materializable()); + assert!(!partially_unsupported_call.is_materializable()); +} + #[test] fn manifest_io_round_trip_preserves_rust_abi_metadata() -> Result<(), Box> { use incan_core::interop::{RustFunctionSig, RustItemKind, RustItemMetadata, RustParam, RustVisibility}; @@ -315,6 +416,7 @@ fn manifest_io_round_trip_preserves_rest_parameter_metadata() -> Result<(), Box< }, kind: ParamKindExport::RestPositional, has_default: false, + default: None, }, ParamExport { name: "labels".to_string(), @@ -323,6 +425,7 @@ fn manifest_io_round_trip_preserves_rest_parameter_metadata() -> Result<(), Box< }, kind: ParamKindExport::RestKeyword, has_default: false, + default: None, }, ], return_type: TypeRef::Named { @@ -350,6 +453,7 @@ fn manifest_io_round_trip_preserves_rest_parameter_metadata() -> Result<(), Box< }, kind: ParamKindExport::RestPositional, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "int".to_string(), @@ -382,6 +486,7 @@ fn manifest_validation_rejects_invalid_rest_parameter_metadata() -> Result<(), B }, kind: ParamKindExport::RestKeyword, has_default: false, + default: None, }, ParamExport { name: "value".to_string(), @@ -390,6 +495,7 @@ fn manifest_validation_rejects_invalid_rest_parameter_metadata() -> Result<(), B }, kind: ParamKindExport::Normal, has_default: false, + default: None, }, ], return_type: TypeRef::Named { @@ -663,6 +769,7 @@ fn manifest_io_round_trip_preserves_generic_method_type_params() -> Result<(), B ty: TypeRef::TypeParam { name: "T".to_string() }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::TypeParam { name: "T".to_string() }, is_async: false, diff --git a/tests/codegen_snapshot_tests.rs b/tests/codegen_snapshot_tests.rs index 5a93d443b..95c185a16 100644 --- a/tests/codegen_snapshot_tests.rs +++ b/tests/codegen_snapshot_tests.rs @@ -72,6 +72,7 @@ fn generate_rust_with_widgets_manifest(source: &str) -> String { }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "Widget".to_string(), @@ -498,6 +499,7 @@ fn generate_rust_with_helper_backed_vocab_wasm_desugaring(source: &str) -> Strin }, kind: ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "int".to_string(), @@ -1771,6 +1773,13 @@ fn test_generic_methods_codegen() { insta::assert_snapshot!("generic_methods", rust_code); } +#[test] +fn test_issue731_generic_method_defaults_codegen() { + let source = load_test_file("issue731_generic_method_defaults"); + let rust_code = generate_rust(&source); + insta::assert_snapshot!("issue731_generic_method_defaults", rust_code); +} + #[test] fn test_explicit_call_site_generics_codegen() { let source = load_test_file("explicit_call_site_generics"); diff --git a/tests/codegen_snapshots/issue731_generic_method_defaults.incn b/tests/codegen_snapshots/issue731_generic_method_defaults.incn new file mode 100644 index 000000000..3ae6cc4a8 --- /dev/null +++ b/tests/codegen_snapshots/issue731_generic_method_defaults.incn @@ -0,0 +1,24 @@ +"""Issue #731: generic receiver methods should fill default arguments at call sites.""" + +class Box[T with Clone]: + value: T + + def join(self, other: Box[T], suffix: str = "") -> Box[T]: + return other + + +model Shelf[T]: + item: T + + def relabel(self, suffix: str = "") -> Shelf[T]: + return self + + +def main() -> None: + left = Box(value=1) + right = Box(value=2) + joined: Box[int] = left.join(right) + joined_named: Box[int] = left.join(other=right) + + shelf: Shelf[int] = Shelf(item=1) + relabeled: Shelf[int] = shelf.relabel() diff --git a/tests/fixtures/generated_rust_artifacts/consumer_main_rs.fragments b/tests/fixtures/generated_rust_artifacts/consumer_main_rs.fragments index 4349028f9..1eaac11f9 100644 --- a/tests/fixtures/generated_rust_artifacts/consumer_main_rs.fragments +++ b/tests/fixtures/generated_rust_artifacts/consumer_main_rs.fragments @@ -2,6 +2,6 @@ use widgets::Widget as PublicWidget; --- use widgets::make_widget; --- -let w: PublicWidget = make_widget("ok".to_string()); +let w: PublicWidget = widgets::make_widget("ok".to_string()); --- println!("{}", w.name); diff --git a/tests/fixtures/vocab_guardrails/semantic_string_audit.json b/tests/fixtures/vocab_guardrails/semantic_string_audit.json index a84f6fd10..82543ed6d 100644 --- a/tests/fixtures/vocab_guardrails/semantic_string_audit.json +++ b/tests/fixtures/vocab_guardrails/semantic_string_audit.json @@ -93,8 +93,8 @@ { "path": "src/backend/ir/emit/expressions/calls.rs", "category": "testing helper and public-module emission compatibility", - "expected_count": 2, - "expected_fingerprint": "0x823a39e4d5d80958" + "expected_count": 4, + "expected_fingerprint": "0x15874c8f43b4e099" }, { "path": "src/backend/ir/emit/expressions/comprehensions.rs", @@ -182,9 +182,9 @@ }, { "path": "src/backend/ir/lower/expr/calls.rs", - "category": "testing assert-raises lowering policy", - "expected_count": 2, - "expected_fingerprint": "0x88708e083b004857" + "category": "public dependency call/default lowering and assert-raises policy", + "expected_count": 5, + "expected_fingerprint": "0x2a9295afdba175d8" }, { "path": "src/backend/ir/lower/expr/mod.rs", diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 2a7ea7e60..f8207b292 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -11451,6 +11451,7 @@ pub def display[T](data: DataSet[T]) -> None: }, kind: incan::library_manifest::ParamKindExport::Normal, has_default: false, + default: None, }], return_type: TypeRef::Named { name: "int".to_string(), @@ -11497,6 +11498,231 @@ pub def display[T](data: DataSet[T]) -> None: Ok(()) } + fn write_pub_library_with_vocab_desugarer_and_string_helper( + root: &Path, + desugarer_bytes: &[u8], + ) -> Result<(), Box> { + let dependency_key = "helperkit"; + let manifest_name = "helperkit_core"; + let artifact_root = root.join("deps").join(dependency_key).join("target").join("lib"); + + // ---- Context: helperkit Rust artifact and desugarer asset ---- + std::fs::create_dir_all(artifact_root.join("desugarers"))?; + write_library_crate_with_source( + &artifact_root, + manifest_name, + "pub fn lit(value: i64) -> i64 {\n value\n}\n\npub fn aggregate_as(_value: i64, label: String) -> String {\n label\n}\n", + )?; + let desugarer_path = artifact_root.join("desugarers").join("helperkit_desugarer.wasm"); + std::fs::write(&desugarer_path, desugarer_bytes)?; + + // ---- Context: public helper manifest surface ---- + let mut manifest = LibraryManifest::new(manifest_name, "0.1.0"); + manifest.exports.functions.push(FunctionExport { + name: "lit".to_string(), + type_params: Vec::new(), + params: vec![ParamExport { + name: "value".to_string(), + ty: TypeRef::Named { + name: "int".to_string(), + }, + kind: incan::library_manifest::ParamKindExport::Normal, + has_default: false, + default: None, + }], + return_type: TypeRef::Named { + name: "int".to_string(), + }, + is_async: false, + }); + manifest.exports.functions.push(FunctionExport { + name: "aggregate_as".to_string(), + type_params: Vec::new(), + params: vec![ + ParamExport { + name: "value".to_string(), + ty: TypeRef::Named { + name: "int".to_string(), + }, + kind: incan::library_manifest::ParamKindExport::Normal, + has_default: false, + default: None, + }, + ParamExport { + name: "label".to_string(), + ty: TypeRef::Named { + name: "str".to_string(), + }, + kind: incan::library_manifest::ParamKindExport::Normal, + has_default: false, + default: None, + }, + ], + return_type: TypeRef::Named { + name: "str".to_string(), + }, + is_async: false, + }); + + // ---- Context: vocab activation and helper bindings ---- + manifest.vocab = Some(incan::library_manifest::VocabExports { + crate_path: "vocab_companion".to_string(), + package_name: "vocab_companion".to_string(), + keyword_registrations: vec![incan_vocab::KeywordRegistration { + activation: incan_vocab::KeywordActivation::OnImport { + namespace: "helperkit.dsl".to_string(), + }, + keywords: vec![incan_vocab::KeywordSpec { + name: "where".to_string(), + surface_kind: incan_vocab::KeywordSurfaceKind::BlockDeclaration, + compound_tokens: Vec::new(), + placement: incan_vocab::KeywordPlacement::TopLevel, + }], + valid_decorators: Vec::new(), + }], + dsl_surfaces: Vec::new(), + provider_manifest: incan_vocab::LibraryManifest { + helper_bindings: vec![ + incan_vocab::HelperBinding { + key: "lit".to_string(), + exported_name: "lit".to_string(), + }, + incan_vocab::HelperBinding { + key: "aggregate_as".to_string(), + exported_name: "aggregate_as".to_string(), + }, + ], + ..incan_vocab::LibraryManifest::default() + }, + desugarer_artifact: Some(incan::library_manifest::VocabDesugarerArtifact { + artifact_kind: incan_vocab::DesugarerArtifactKind::WasmModule, + abi_version: incan_vocab::WASM_DESUGAR_ABI_VERSION, + relative_path: "desugarers/helperkit_desugarer.wasm".to_string(), + target: "wasm32-wasip1".to_string(), + profile: "release".to_string(), + entrypoint: "desugar_block".to_string(), + sha256: hex::encode(Sha256::digest(desugarer_bytes)), + }), + }); + manifest.write_to_path(&artifact_root.join(format!("{manifest_name}.incnlib")))?; + Ok(()) + } + + fn write_source_pub_library_with_vocab_desugarer_and_query_helpers( + root: &Path, + desugarer_bytes: &[u8], + ) -> Result<(), Box> { + let producer_root = root.join("deps").join("querykit"); + + // ---- Context: source-backed helper library ---- + std::fs::create_dir_all(producer_root.join("src"))?; + std::fs::write( + producer_root.join("incan.toml"), + "[project]\nname = \"querykit\"\nversion = \"0.1.0\"\n", + )?; + std::fs::write( + producer_root.join("src/helpers.incn"), + r#"pub model IntLiteralExpr: + value: int + +pub model StringLiteralExpr: + value: str + +pub type LiteralValue = Union[int, str] +pub type ColumnExpr = Union[IntLiteralExpr, StringLiteralExpr] + +pub model AggregateMeasure: + expr: ColumnExpr + label: str + +pub const DEFAULT_LABEL: str = "orders" +pub const COUNT_SENTINEL: str = "__querykit_count_no_argument__" + +pub def lit(value: LiteralValue) -> ColumnExpr: + match value: + int(number) => return IntLiteralExpr(value=number) + str(text) => return StringLiteralExpr(value=text) + +pub def col(name: str) -> ColumnExpr: + return StringLiteralExpr(value=name) + +pub def count(expr: ColumnExpr = col(COUNT_SENTINEL)) -> ColumnExpr: + return expr + +pub def aggregate_as(expr: ColumnExpr, output_name: str) -> AggregateMeasure: + return AggregateMeasure(expr=expr, label=output_name) + +pub def aggregate_default(expr: ColumnExpr, output_name: str = DEFAULT_LABEL) -> AggregateMeasure: + return AggregateMeasure(expr=expr, label=output_name) +"#, + )?; + std::fs::write( + producer_root.join("src/lib.incn"), + "pub from helpers import IntLiteralExpr, StringLiteralExpr, LiteralValue, ColumnExpr, AggregateMeasure, DEFAULT_LABEL, lit, count, aggregate_as, aggregate_default\n", + )?; + + let producer_build = run_build_lib(&producer_root)?; + assert!( + producer_build.status.success(), + "expected querykit producer build to succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&producer_build.stdout), + String::from_utf8_lossy(&producer_build.stderr) + ); + + // ---- Context: vocab activation attached to the built library manifest ---- + let artifact_root = producer_root.join("target").join("lib"); + std::fs::create_dir_all(artifact_root.join("desugarers"))?; + let desugarer_path = artifact_root.join("desugarers").join("querykit_desugarer.wasm"); + std::fs::write(&desugarer_path, desugarer_bytes)?; + let manifest_path = artifact_root.join("querykit.incnlib"); + let mut manifest = LibraryManifest::read_from_path(&manifest_path)?; + manifest.vocab = Some(incan::library_manifest::VocabExports { + crate_path: "vocab_companion".to_string(), + package_name: "vocab_companion".to_string(), + keyword_registrations: vec![incan_vocab::KeywordRegistration { + activation: incan_vocab::KeywordActivation::OnImport { + namespace: "querykit.dsl".to_string(), + }, + keywords: vec![incan_vocab::KeywordSpec { + name: "where".to_string(), + surface_kind: incan_vocab::KeywordSurfaceKind::BlockDeclaration, + compound_tokens: Vec::new(), + placement: incan_vocab::KeywordPlacement::TopLevel, + }], + valid_decorators: Vec::new(), + }], + dsl_surfaces: Vec::new(), + provider_manifest: incan_vocab::LibraryManifest { + helper_bindings: vec![ + incan_vocab::HelperBinding { + key: "lit".to_string(), + exported_name: "lit".to_string(), + }, + incan_vocab::HelperBinding { + key: "count".to_string(), + exported_name: "count".to_string(), + }, + incan_vocab::HelperBinding { + key: "aggregate_as".to_string(), + exported_name: "aggregate_as".to_string(), + }, + ], + ..incan_vocab::LibraryManifest::default() + }, + desugarer_artifact: Some(incan::library_manifest::VocabDesugarerArtifact { + artifact_kind: incan_vocab::DesugarerArtifactKind::WasmModule, + abi_version: incan_vocab::WASM_DESUGAR_ABI_VERSION, + relative_path: "desugarers/querykit_desugarer.wasm".to_string(), + target: "wasm32-wasip1".to_string(), + profile: "release".to_string(), + entrypoint: "desugar_block".to_string(), + sha256: hex::encode(Sha256::digest(desugarer_bytes)), + }), + }); + manifest.write_to_path(&manifest_path)?; + Ok(()) + } + fn write_pub_library_with_provider_requirements( root: &Path, dependency_key: &str, @@ -12646,6 +12872,190 @@ def main() -> Result[None, SessionError]: Ok(()) } + #[test] + fn consumer_build_plans_vocab_helper_calls_like_ordinary_calls_issue729() -> Result<(), Box> + { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Helper("aggregate_as".to_string())), + args: vec![ + incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Helper("lit".to_string())), + args: vec![incan_vocab::IncanExpr::Int(5)], + }, + incan_vocab::IncanExpr::Str("total".to_string()), + ], + }); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm(0, &output_payload, "")?; + write_pub_library_with_vocab_desugarer_and_string_helper(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nhelperkit = { path = \"deps/helperkit\" }\n", + r#"import pub::helperkit + +def main() -> None: + where true: + pass +"#, + )?; + + let out_dir = tmp.path().join("out"); + let output = run_build(&main_path, &out_dir)?; + assert!( + output.status.success(), + "expected helper-backed desugared calls to use normal call planning.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let generated_main_rs = std::fs::read_to_string(out_dir.join("src/main.rs"))?; + let normalized: String = generated_main_rs.chars().filter(|ch| !ch.is_whitespace()).collect(); + assert!( + normalized.contains("helperkit::aggregate_as(helperkit::lit(5),\"total\".to_string()") + || normalized.contains( + "__incan_vocab_helper_helperkit_aggregate_as(__incan_vocab_helper_helperkit_lit(5),\"total\".to_string()" + ), + "expected nested helper calls to keep independent call planning, got:\n{generated_main_rs}" + ); + Ok(()) + } + + #[test] + fn consumer_build_plans_source_backed_vocab_helper_calls_with_defaults_and_unions_issue729() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Tuple(vec![ + incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Helper("aggregate_as".to_string())), + args: vec![ + incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Helper("lit".to_string())), + args: vec![incan_vocab::IncanExpr::Int(5)], + }, + incan_vocab::IncanExpr::Str("adjusted".to_string()), + ], + }, + incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Helper("aggregate_as".to_string())), + args: vec![ + incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Helper("count".to_string())), + args: Vec::new(), + }, + incan_vocab::IncanExpr::Str("order_count".to_string()), + ], + }, + ])); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm(0, &output_payload, "")?; + write_source_pub_library_with_vocab_desugarer_and_query_helpers(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +def main() -> None: + where true: + pass +"#, + )?; + + let out_dir = tmp.path().join("out"); + let output = run_build(&main_path, &out_dir)?; + let generated_main_rs = std::fs::read_to_string(out_dir.join("src/main.rs")).unwrap_or_default(); + assert!( + output.status.success(), + "expected source-backed helper calls to keep defaults, union wrapping, and string planning.\ngenerated main.rs:\n{}\nstdout:\n{}\nstderr:\n{}", + generated_main_rs, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + generated_main_rs.contains("querykit::count(") + || generated_main_rs.contains("__incan_vocab_helper_querykit_count("), + "expected omitted count() argument to be filled from the helper's default expression, got:\n{generated_main_rs}" + ); + assert!( + !generated_main_rs.contains("__incan_vocab_helper_querykit_count()"), + "helper default planning must not emit a zero-argument Rust count call, got:\n{generated_main_rs}" + ); + assert!( + generated_main_rs.contains("querykit::helpers::COUNT_SENTINEL"), + "dependency-owned const defaults must keep the defining provider module path, got:\n{generated_main_rs}" + ); + assert!( + !generated_main_rs.contains("pub enum __IncanUnion"), + "public dependency helper unions must stay owned by the dependency crate, got:\n{generated_main_rs}" + ); + assert!( + generated_main_rs.contains(".to_string()"), + "expected helper string arguments to use normal owned-string conversion, got:\n{generated_main_rs}" + ); + Ok(()) + } + + #[test] + fn consumer_build_plans_source_backed_pub_helper_calls_with_defaults_and_unions_issue729() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let wasm = compile_desugarer_wasm(0, "[]", "")?; + write_source_pub_library_with_vocab_desugarer_and_query_helpers(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"from pub::querykit import aggregate_as, aggregate_default, count, lit + +def main() -> None: + aggregate_as(lit(5), "adjusted") + aggregate_as(count(), "order_count") + aggregate_default(lit(7)) +"#, + )?; + + let out_dir = tmp.path().join("out"); + let output = run_build(&main_path, &out_dir)?; + let generated_main_rs = std::fs::read_to_string(out_dir.join("src/main.rs")).unwrap_or_default(); + assert!( + output.status.success(), + "expected ordinary pub helper calls to share exported default, union, and string planning.\ngenerated main.rs:\n{}\nstdout:\n{}\nstderr:\n{}", + generated_main_rs, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + generated_main_rs.contains("querykit::count(") + || generated_main_rs.contains("__incan_vocab_helper_querykit_count("), + "expected omitted count() argument to be filled from the helper's default expression, got:\n{generated_main_rs}" + ); + assert!( + !generated_main_rs.contains("querykit::count()") + && !generated_main_rs.contains("__incan_vocab_helper_querykit_count()"), + "ordinary pub helper default planning must not emit a zero-argument Rust count call, got:\n{generated_main_rs}" + ); + assert!( + generated_main_rs.contains("querykit::helpers::COUNT_SENTINEL"), + "ordinary public dependency const defaults must keep the defining provider module path, got:\n{generated_main_rs}" + ); + assert!( + !generated_main_rs.contains("pub enum __IncanUnion"), + "ordinary public dependency calls must not re-own dependency anonymous unions, got:\n{generated_main_rs}" + ); + assert!( + generated_main_rs.contains(".to_string()"), + "expected ordinary pub helper string arguments to use normal owned-string conversion, got:\n{generated_main_rs}" + ); + assert!( + generated_main_rs.contains("querykit::helpers::DEFAULT_LABEL"), + "expected public const defaults to emit through their provider module path, got:\n{generated_main_rs}" + ); + Ok(()) + } + #[test] fn consumer_check_passes_scoped_query_surface_artifacts_to_desugarer() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/snapshots/codegen_snapshot_tests__issue731_generic_method_defaults.snap b/tests/snapshots/codegen_snapshot_tests__issue731_generic_method_defaults.snap new file mode 100644 index 000000000..1acdcbb6a --- /dev/null +++ b/tests/snapshots/codegen_snapshot_tests__issue731_generic_method_defaults.snap @@ -0,0 +1,118 @@ +--- +source: tests/codegen_snapshot_tests.rs +expression: rust_code +--- +// Generated by the Incan compiler v + +// __INCAN_INSERT_MODS__ + +incan_stdlib::__incan_stdlib_version_check!(""); +#[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] +struct Box { + #[expect(dead_code, reason = "retained for Incan private field semantics")] + value: T, +} +impl incan_stdlib::reflection::HasClassName for Box { + fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Box { + fn __class_name__() -> &'static str { + "Box" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Box { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Box { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("value"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("value"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} +impl Box { + pub fn join(&self, other: Box, _: String) -> Box { + return other; + } +} +#[derive(Debug, Clone, incan_derive::FieldInfo, incan_derive::IncanClass)] +struct Shelf { + #[expect(dead_code, reason = "retained for Incan private field semantics")] + item: T, +} +impl incan_stdlib::reflection::HasClassName for Shelf { + fn __class_name__(&self) -> &'static str { + ::__class_name__() + } +} +impl incan_stdlib::reflection::HasTypeClassName for Shelf { + fn __class_name__() -> &'static str { + "Shelf" + } +} +impl incan_stdlib::reflection::HasFieldMetadata for Shelf { + fn __fields__( + &self, + ) -> incan_stdlib::frozen::FrozenList { + ::__fields__() + } +} +impl incan_stdlib::reflection::HasTypeFieldMetadata for Shelf { + fn __fields__() -> incan_stdlib::frozen::FrozenList< + incan_stdlib::reflection::FieldInfo, + > { + static __INCAN_FIELDS: [incan_stdlib::reflection::FieldInfo; 1] = [ + incan_stdlib::reflection::FieldInfo { + name: incan_stdlib::frozen::FrozenStr::new("item"), + alias: None, + description: None, + wire_name: incan_stdlib::frozen::FrozenStr::new("item"), + type_name: incan_stdlib::frozen::FrozenStr::new("T"), + has_default: false, + extra: incan_stdlib::frozen::FrozenDict::new(&[]), + }, + ]; + incan_stdlib::frozen::FrozenList::new(&__INCAN_FIELDS) + } +} +impl Shelf { + pub fn relabel(&self, _: String) -> Shelf { + return self.clone(); + } +} +fn main() { + std::panic::set_hook( + std::boxed::Box::new(|panic_info| { + if let Some(message) = panic_info.payload().downcast_ref::<&str>() { + eprintln!("{message}"); + } else if let Some(message) = panic_info.payload().downcast_ref::() { + eprintln!("{message}"); + } else { + eprintln!("generated program panicked"); + } + }), + ); + let left = Box { value: 1 }; + let right = Box { value: 2 }; + let _joined: Box = left.join(right.clone(), "".to_string()); + let _joined_named: Box = left.join(right, "".to_string()); + let shelf: Shelf = Shelf { item: 1 }; + let _relabeled: Shelf = shelf.relabel("".to_string()); +} diff --git a/tests/snapshots/codegen_snapshot_tests__pub_import_expressions.snap b/tests/snapshots/codegen_snapshot_tests__pub_import_expressions.snap index ee5c5f9fb..3f6245ab9 100644 --- a/tests/snapshots/codegen_snapshot_tests__pub_import_expressions.snap +++ b/tests/snapshots/codegen_snapshot_tests__pub_import_expressions.snap @@ -22,6 +22,6 @@ fn main() { } }), ); - let _w: Widget = make_widget(DEFAULT_NAME.clone()); - widgets::make_widget(DEFAULT_NAME); + let _w: Widget = widgets::make_widget(DEFAULT_NAME.to_string()); + widgets::make_widget(DEFAULT_NAME.to_string()); } diff --git a/tests/snapshots/codegen_snapshot_tests__pub_import_module_alias.snap b/tests/snapshots/codegen_snapshot_tests__pub_import_module_alias.snap index 61d755437..2fd1f56bc 100644 --- a/tests/snapshots/codegen_snapshot_tests__pub_import_module_alias.snap +++ b/tests/snapshots/codegen_snapshot_tests__pub_import_module_alias.snap @@ -21,5 +21,5 @@ fn main() { } }), ); - w::make_widget(DEFAULT_NAME); + w::make_widget(DEFAULT_NAME.to_string()); } diff --git a/tests/snapshots/codegen_snapshot_tests__vocab_helper_backed_desugaring.snap b/tests/snapshots/codegen_snapshot_tests__vocab_helper_backed_desugaring.snap index 6c9356c70..388b4388e 100644 --- a/tests/snapshots/codegen_snapshot_tests__vocab_helper_backed_desugaring.snap +++ b/tests/snapshots/codegen_snapshot_tests__vocab_helper_backed_desugaring.snap @@ -20,5 +20,5 @@ fn main() { } }), ); - __incan_vocab_helper_query_filter(1); + query::filter(1); } diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index b186e8d25..d355ec9a6 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -94,6 +94,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Duckborrowing covers more real use sites**: Generated Rust handles arguments, returns, assignments, match scrutinees, aggregate elements, lookups, comprehensions, mutable aggregate parameters, Rust calls, and generated `Clone` bounds with fewer user-authored workarounds (#121, #241, #364, #366, #367, #372, #383, #391, #602). - **Release smoke paths are less fragile**: InQL and release smoke testing fixed loop-item fields, union call arguments, storage-rooted match scrutinees, static list index assignment, typed `assert false` lowering, and const model metadata constructors (#620, #621, #622, #627, #630, #644, #671, #674). - **Generic and trait flow keeps more type information**: Instantiated receivers, generic fields, generic methods, `list[Self]`, trait/supertrait upcasts, imported prost oneofs, explicit generic cycles, and static factory locals now survive typechecking and lowering more consistently (#237, #231, #253, #230, #184, #218, #279, #252, #255). +- **Generic receiver methods inherit defaults**: Calls on instantiated generic classes and models use the same source default arguments as non-generic receiver calls instead of emitting too few Rust arguments (#731). - **Runtime-boundary errors are clearer**: `std.regex` text borrowing, collection f-string formatting, and collection/string conversion diagnostics fail closer to the Incan source instead of surfacing as obscure Rust/runtime errors (#624, #625, #71, #81). - **Reflection is capability-backed**: Generic value reflection and explicit type-argument reflection infer the right generated Rust bounds, while bare model names in value position now fail at the Incan diagnostic layer instead of leaking Rust type paths (#712, #714, #715). - **Enum patterns use enum-owned metadata**: Qualified enum patterns now read variant payloads from the enum's semantic metadata instead of ambient variant symbols, keeping imported stdlib enums stable when project enums reuse variant names (#710). @@ -122,6 +123,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Stdlib implementation modules stay internal**: Generated stdlib source dependencies no longer leak unimported helper classes into project modules, so explicit sibling imports keep precedence over unrelated `std.*` imports (#710). - **Vocab expression-list clauses preserve item metadata**: `ClauseSurface::expr_list(...)` accepts `expr as alias` entries and declared trailing item modifiers such as `expr for target with context`, exposing structured metadata to desugarers instead of forcing SQL-shaped projections through field-set syntax (#724). - **Expression-desugaring vocab declarations work as values**: `DeclarationSurface::desugars_to_expression()` blocks can now appear where expressions are valid, including assignment values and return values; colon and brace forms desugar before typechecking while preserving inline clause bodies, expression-list item metadata, compound clause tokens such as `GROUP BY`, and public vocab method-call output (#727). +- **Vocab helper calls use ordinary public call planning**: `IncanExpr::Helper(...)` output now follows the same exported-default, union-wrapping, owned-string, and dependency-owned type identity rules as direct `pub::library` calls, so DSL desugarers can call source-backed helpers without hand-authored workarounds (#729). - **Rust raw identifier fields emit correctly**: Rust-backed fields whose real Rust name is a keyword can be accessed with the keyword spelling, such as `value.type` and `TypeName(type=value)`, while generated Rust emits the actual raw identifier access such as `value.r#type` (#725). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). From 2ae5044d2e7122f7d38dff6d67181c6521474409 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 1 Jun 2026 02:39:40 +0200 Subject: [PATCH 55/58] bugfix - stabilize Rust callback alias closure context (#733) Closes #733 --- Cargo.lock | 18 +-- Cargo.toml | 2 +- src/frontend/typechecker/check_expr/access.rs | 5 +- tests/cli_integration.rs | 144 ++++++++++++++++++ .../docs-site/docs/release_notes/0_3.md | 2 +- 5 files changed, 159 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f4acb3fd..f66835b82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc37" +version = "0.3.0-rc38" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 97f643ce3..38a00001b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc37" +version = "0.3.0-rc38" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index ed24b7c84..28680b42e 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -103,6 +103,9 @@ impl TypeChecker { /// This is intentionally metadata-driven rather than crate-specific. DataFusion's /// `ScalarFunctionImplementation -> Arc` chain is one motivating surface, but the compiler must not /// special-case DataFusion or require regression tests to compile that heavyweight crate. + /// + /// Use blocking metadata reads here so contextual closure typing does not depend on whether a transitive alias was + /// already imported elsewhere or happened to be warmed by an earlier arm in the same expression. fn rust_callable_alias_target_display_for_path( &self, path: &str, @@ -112,7 +115,7 @@ impl TypeChecker { if !seen.insert(canonical_path.clone()) { return None; } - if let Some(metadata) = self.rust_item_metadata_for_path(path) + if let Some(metadata) = self.rust_item_metadata_for_path_blocking(path) && let RustItemKind::Type(type_info) = &metadata.kind && let Some(target) = type_info.alias_target.as_ref() { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index a40e27244..5b5ea1d12 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1034,6 +1034,150 @@ impl Expr { Ok(()) } +#[test] +fn run_types_rust_callback_closures_in_every_match_arm_issue733() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let main_path = write_minimal_project( + tmp.path(), + "cli_rust_match_arm_callback_context", + r#" + +[rust-dependencies] +arc_match_callback = { path = "rust/arc_match_callback" } +"#, + )?; + fs::write( + &main_path, + r#"from rust::arc_match_callback import CallbackError, ColumnarValue, DataType, ScalarUDF, Volatility, create_udf +from rust::std::sync import Arc + + +@derive(Clone) +enum ReproFunction(str): + First = "first" + Second = "second" + + +def callback(args: list[ColumnarValue]) -> Result[ColumnarValue, CallbackError]: + return Ok(args[0].clone()) + + +def make_udf(function: ReproFunction) -> ScalarUDF: + match function: + ReproFunction.First => + return create_udf( + name=function.value(), + input_types=[DataType.Utf8], + return_type=DataType.Utf8, + volatility=Volatility.Immutable, + fun=Arc.from((args) => callback(args.to_vec())), + ) + ReproFunction.Second => + return create_udf( + name=function.value(), + input_types=[DataType.Utf8], + return_type=DataType.Utf8, + volatility=Volatility.Immutable, + fun=Arc.from((args) => callback(args.to_vec())), + ) + + +def main() -> None: + first = make_udf(ReproFunction.First) + second = make_udf(ReproFunction.Second) + println(f"match-callback:{first.value()}:{second.value()}") +"#, + )?; + + // Keep the regression DataFusion-shaped without compiling DataFusion. The issue is the metadata contract for a + // transitive callback alias used by an inspected Rust function parameter. + let helper_src = tmp.path().join("rust").join("arc_match_callback").join("src"); + fs::create_dir_all(&helper_src)?; + fs::write( + helper_src + .parent() + .ok_or("arc_match_callback src has no parent")? + .join("Cargo.toml"), + r#"[package] +name = "arc_match_callback" +version = "0.1.0" +edition = "2021" +"#, + )?; + fs::write( + helper_src.join("lib.rs"), + r#"use std::sync::Arc; + +#[derive(Clone)] +pub struct ColumnarValue { + value: i64, +} + +impl ColumnarValue { + pub fn new(value: i64) -> Self { + Self { value } + } + + pub fn value(&self) -> i64 { + self.value + } +} + +pub struct CallbackError; + +pub type SliceCallback = Arc Result + Send + Sync>; +pub type ScalarFunctionImplementation = crate::SliceCallback; + +#[derive(Clone)] +pub struct ScalarUDF { + value: i64, +} + +impl ScalarUDF { + pub fn value(&self) -> i64 { + self.value + } +} + +#[derive(Clone)] +pub enum DataType { + Utf8, +} + +#[derive(Clone)] +pub enum Volatility { + Immutable, +} + +pub fn create_udf( + name: &str, + input_types: Vec, + return_type: DataType, + volatility: Volatility, + fun: crate::ScalarFunctionImplementation, +) -> ScalarUDF { + let _ = name; + let _ = input_types; + let _ = return_type; + let _ = volatility; + let args = vec![ColumnarValue::new(13)]; + let value = fun(&args).map(|value| value.value()).unwrap_or(-1); + ScalarUDF { value } +} +"#, + )?; + + let output = run_incan(tmp.path(), &["run"])?; + assert_success(&output, "rust callback closure context inside match arms"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout.trim(), + "match-callback:13:13", + "unexpected callback output:\n{stdout}" + ); + Ok(()) +} + #[test] fn test_runner_prefers_project_sibling_import_over_unimported_stdlib_stub_type() -> Result<(), Box> { diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index d355ec9a6..59805b281 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -104,7 +104,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Borrowing decisions are metadata-backed**: Borrowed `str`/bytes calls, method fallback borrowing, and retained generated imports now follow the same decisions across typechecking, lowering, and emission. - **Rust bridge identity is preserved**: Inspected methods with unknown generic or lifetime placeholders and re-exported Rust argument displays keep stable bridge identity, including nested generic wrappers such as `Arc` (#645, #630, #705). -- **Rust callable aliases can accept Incan closures**: Rust `type` aliases such as `Arc Result + Send + Sync>` now preserve their alias target metadata, contextually type Incan closure parameters, follow alias chains such as `ScalarFunctionImplementation -> SliceCallback -> Arc`, and emit the required Rust closure parameter annotations without pulling heavyweight downstream crates into compiler regression tests (#708). +- **Rust callable aliases can accept Incan closures**: Rust `type` aliases such as `Arc Result + Send + Sync>` now preserve their alias target metadata, contextually type Incan closure parameters, follow alias chains such as `ScalarFunctionImplementation -> SliceCallback -> Arc`, and emit the required Rust closure parameter annotations without pulling heavyweight downstream crates into compiler regression tests (#708, #733). - **Collection adaptation is less manual**: Owned Incan values can flow to shared borrowed generic Rust parameters, and `list[T]` can adapt to `Vec` where metadata proves the boundary (#506, #128). - **Protobuf-style APIs need fewer workarounds**: Prost-style inherent and trait-provided `decode(buf: T)` calls lower correctly (#609, #612). - **Generated Rust pruning is safer**: Enum-pattern imports and metadata-derived extension-trait imports survive pruning, while unused generated Rust is pruned without broad `allow` attributes (#459, #447, #214). From e9e851518c5e6e6d56914cd35f6c1a30864ca638 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 1 Jun 2026 16:45:57 +0200 Subject: [PATCH 56/58] bugfix - preserve expected types for vocab generic calls (#735) (#737) --- Cargo.lock | 18 ++-- Cargo.toml | 2 +- src/frontend/typechecker/check_expr/access.rs | 71 +++++++++++++--- src/frontend/typechecker/check_expr/calls.rs | 54 ++++++++++-- .../typechecker/check_expr/calls/builtins.rs | 4 +- .../check_expr/calls/constructors.rs | 2 +- .../check_expr/calls/generic_bounds.rs | 40 ++++----- src/frontend/typechecker/check_expr/mod.rs | 3 + tests/integration_tests.rs | 83 +++++++++++++++++++ .../docs-site/docs/release_notes/0_3.md | 1 + 10 files changed, 226 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f66835b82..60d71b60c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc38" +version = "0.3.0-rc39" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 38a00001b..2d0104a86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc38" +version = "0.3.0-rc39" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/frontend/typechecker/check_expr/access.rs b/src/frontend/typechecker/check_expr/access.rs index 28680b42e..a9ae05631 100644 --- a/src/frontend/typechecker/check_expr/access.rs +++ b/src/frontend/typechecker/check_expr/access.rs @@ -2104,6 +2104,7 @@ impl TypeChecker { arg_types, call_site_span, receiver_ty, + expected_return_ty, )); } if let Some(trait_adoptions) = trait_adoptions { @@ -2185,6 +2186,7 @@ impl TypeChecker { arg_types, call_site_span, receiver_ty, + expected_return_ty, ) }); } @@ -2213,6 +2215,7 @@ impl TypeChecker { arg_types, call_site_span, receiver_ty, + expected_return_ty, )); } @@ -2234,6 +2237,7 @@ impl TypeChecker { type_args: &[Spanned], args: &[CallArg], span: Span, + expected_return_ty: Option<&ResolvedType>, ) -> Option { let type_name = match base_ty { ResolvedType::Named(name) | ResolvedType::Generic(name, _) => name, @@ -2254,7 +2258,16 @@ impl TypeChecker { Some(_) => return None, None => model.methods.get(method)?.clone(), }; - Some(self.check_generic_method_call(method, method_info, type_args, args, &[], span, base_ty)) + Some(self.check_generic_method_call( + method, + method_info, + type_args, + args, + &[], + span, + base_ty, + expected_return_ty, + )) } TypeInfo::Class(class) => { let method_info = match class.method_overloads.get(method) { @@ -2262,7 +2275,16 @@ impl TypeChecker { Some(_) => return None, None => class.methods.get(method)?.clone(), }; - Some(self.check_generic_method_call(method, method_info, type_args, args, &[], span, base_ty)) + Some(self.check_generic_method_call( + method, + method_info, + type_args, + args, + &[], + span, + base_ty, + expected_return_ty, + )) } TypeInfo::Enum(en) => { let method_info = match en.method_overloads.get(method) { @@ -2270,7 +2292,16 @@ impl TypeChecker { Some(_) => return None, None => en.methods.get(method)?.clone(), }; - Some(self.check_generic_method_call(method, method_info, type_args, args, &[], span, base_ty)) + Some(self.check_generic_method_call( + method, + method_info, + type_args, + args, + &[], + span, + base_ty, + expected_return_ty, + )) } TypeInfo::Newtype(nt) => { let resolved_method = self.resolve_newtype_method_name(&nt, method); @@ -2279,8 +2310,16 @@ impl TypeChecker { Some(_) => return None, None => nt.methods.get(resolved_method)?.clone(), }; - let ret = - self.check_generic_method_call(resolved_method, method_info, type_args, args, &[], span, base_ty); + let ret = self.check_generic_method_call( + resolved_method, + method_info, + type_args, + args, + &[], + span, + base_ty, + expected_return_ty, + ); if nt.is_rusttype { self.maybe_record_rusttype_return_coercion(&nt, resolved_method, &ret, span); } @@ -3065,16 +3104,28 @@ impl TypeChecker { if let Some((module_name, module_path)) = self.imported_module_for_expr(base) { if let Some(info) = self.resolve_imported_module_function_member(&module_path, method) { let callable = format!("{module_name}.{method}"); - return self.validate_stdlib_module_function_call(callable.as_str(), &info, type_args, args, span); + return self.validate_stdlib_module_function_call( + callable.as_str(), + &info, + type_args, + args, + span, + expected_return_ty, + ); } self.errors .push(errors::missing_method(module_name.as_str(), method, span)); return ResolvedType::Unknown; } - if let Some(ret) = - self.resolve_unambiguous_source_method_without_arg_prepass(&base_ty, method, type_args, args, span) - { + if let Some(ret) = self.resolve_unambiguous_source_method_without_arg_prepass( + &base_ty, + method, + type_args, + args, + span, + expected_return_ty, + ) { return ret; } @@ -3786,7 +3837,7 @@ impl TypeChecker { )); return Some(ResolvedType::Unknown); } - Some(self.check_generic_method_call(method, method_info, type_args, args, arg_types, span, receiver_ty)) + Some(self.check_generic_method_call(method, method_info, type_args, args, arg_types, span, receiver_ty, None)) } /// Return known method result types for Rust imports when rust-inspect metadata is not specific enough. diff --git a/src/frontend/typechecker/check_expr/calls.rs b/src/frontend/typechecker/check_expr/calls.rs index 9772b1bec..fb912633c 100644 --- a/src/frontend/typechecker/check_expr/calls.rs +++ b/src/frontend/typechecker/check_expr/calls.rs @@ -48,6 +48,22 @@ impl TypeChecker { type_args: &[Spanned], args: &[CallArg], span: Span, + ) -> ResolvedType { + self.check_call_with_expected(callee, type_args, args, span, None) + } + + /// Type-check a call expression with an optional expected result type. + /// + /// Contextual return hints are part of the generic call plan, not a desugaring special case. They let direct + /// source, vocab-produced AST, and nested call arguments all use the same inference path when a destination + /// type is known. + pub(in crate::frontend::typechecker::check_expr) fn check_call_with_expected( + &mut self, + callee: &Spanned, + type_args: &[Spanned], + args: &[CallArg], + span: Span, + expected_return_ty: Option<&ResolvedType>, ) -> ResolvedType { if let Some(name) = Self::explicit_builtin_member_name(callee) { let result = self.check_explicit_builtin_call(name, args, span); @@ -105,7 +121,14 @@ impl TypeChecker { let _ = self.check_ident(module_name.as_str(), base.span); if let Some(func_info) = self.resolve_imported_module_function_member(&module_path, method.as_str()) { let callable = format!("{module_name}.{method}"); - return self.validate_stdlib_module_function_call(callable.as_str(), &func_info, type_args, args, span); + return self.validate_stdlib_module_function_call( + callable.as_str(), + &func_info, + type_args, + args, + span, + expected_return_ty, + ); } } @@ -238,7 +261,14 @@ impl TypeChecker { return explicit_constructor_ty.unwrap_or(constructor_ty); } SymbolKind::Function(func_info) => { - return self.validate_function_call(name, &func_info, type_args, args, span); + return self.validate_function_call( + name, + &func_info, + type_args, + args, + span, + expected_return_ty, + ); } SymbolKind::RustItem(info) => { if !type_args.is_empty() { @@ -395,7 +425,7 @@ impl TypeChecker { type_param_bounds: binding.type_param_bounds, type_param_bound_details: binding.type_param_bound_details, }; - return self.validate_function_call(name, &info, type_args, args, span); + return self.validate_function_call(name, &info, type_args, args, span, expected_return_ty); } if !type_args.is_empty() { @@ -406,10 +436,22 @@ impl TypeChecker { match callee_ty { ResolvedType::Function(params, ret) => { - let arg_types = self.check_call_arg_types_for_params(args, ¶ms); let mut type_bindings = std::collections::HashMap::new(); - self.validate_callable_arg_bindings("", ¶ms, args, &arg_types, &mut type_bindings, span); - self.type_info.record_call_site_callable_params(span, ¶ms); + if let Some(expected) = expected_return_ty { + self.infer_type_param_bindings(&ret, expected, &mut type_bindings); + } + let resolved_params = Self::substitute_callable_params(¶ms, &type_bindings); + let arg_types = self.check_call_arg_types_for_params(args, &resolved_params); + self.validate_callable_arg_bindings( + "", + &resolved_params, + args, + &arg_types, + &mut type_bindings, + span, + ); + let final_params = Self::substitute_callable_params(&resolved_params, &type_bindings); + self.type_info.record_call_site_callable_params(span, &final_params); substitute_resolved_type(&ret, &type_bindings) } ty if self.is_user_operator_receiver(&ty) diff --git a/src/frontend/typechecker/check_expr/calls/builtins.rs b/src/frontend/typechecker/check_expr/calls/builtins.rs index b09e0bee5..703a32889 100644 --- a/src/frontend/typechecker/check_expr/calls/builtins.rs +++ b/src/frontend/typechecker/check_expr/calls/builtins.rs @@ -93,9 +93,11 @@ impl TypeChecker { explicit_type_args: &[Spanned], args: &[CallArg], call_span: Span, + expected_return_ty: Option<&ResolvedType>, ) -> ResolvedType { let arity_ok = self.validate_stdlib_module_call_arity(callable, &info.params, args, call_span); - let resolved = self.validate_function_call(callable, info, explicit_type_args, args, call_span); + let resolved = + self.validate_function_call(callable, info, explicit_type_args, args, call_span, expected_return_ty); if arity_ok { resolved } else { ResolvedType::Unknown } } diff --git a/src/frontend/typechecker/check_expr/calls/constructors.rs b/src/frontend/typechecker/check_expr/calls/constructors.rs index 70a8930ce..1f75ace6d 100644 --- a/src/frontend/typechecker/check_expr/calls/constructors.rs +++ b/src/frontend/typechecker/check_expr/calls/constructors.rs @@ -452,7 +452,7 @@ impl TypeChecker { } else { ResolvedType::Generic(type_name.to_string(), resolved_type_args) }; - Some(self.check_generic_method_call(TYPE_CONSTRUCTOR_HOOK, hook, &[], args, &[], span, &receiver_ty)) + Some(self.check_generic_method_call(TYPE_CONSTRUCTOR_HOOK, hook, &[], args, &[], span, &receiver_ty, None)) } /// Return whether a call's named arguments exactly describe normal model/class field construction. diff --git a/src/frontend/typechecker/check_expr/calls/generic_bounds.rs b/src/frontend/typechecker/check_expr/calls/generic_bounds.rs index cf96f9c71..7deac9b50 100644 --- a/src/frontend/typechecker/check_expr/calls/generic_bounds.rs +++ b/src/frontend/typechecker/check_expr/calls/generic_bounds.rs @@ -7,7 +7,8 @@ use crate::frontend::resolved_type_subst::{substitute_resolved_type, type_param_ use crate::frontend::symbols::{CallableParam, FunctionInfo, MethodInfo, ResolvedType, TypeInfo}; impl TypeChecker { - /// Validate generic function call type arguments, value arguments, and explicit type-parameter bounds. + /// Validate generic function call type arguments, contextual return bindings, value arguments, and explicit + /// type-parameter bounds. pub(in crate::frontend::typechecker::check_expr::calls) fn validate_function_call( &mut self, func_name: &str, @@ -15,6 +16,7 @@ impl TypeChecker { explicit_type_args: &[Spanned], args: &[CallArg], call_span: Span, + expected_return_ty: Option<&ResolvedType>, ) -> ResolvedType { let mut seeded_type_bindings: std::collections::HashMap = std::collections::HashMap::new(); @@ -34,16 +36,10 @@ impl TypeChecker { seeded_type_bindings = type_param_subst_map_call_site(&info.type_params, &resolved_explicit); } } - let params_with_explicit: Vec = info - .params - .iter() - .map(|param| CallableParam { - name: param.name.clone(), - ty: substitute_resolved_type(¶m.ty, &seeded_type_bindings), - kind: param.kind, - has_default: param.has_default, - }) - .collect(); + if let Some(expected) = expected_return_ty { + self.infer_type_param_bindings(&info.return_type, expected, &mut seeded_type_bindings); + } + let params_with_explicit = Self::substitute_callable_params(&info.params, &seeded_type_bindings); let arg_types = self.check_call_arg_types_for_params(args, ¶ms_with_explicit); let mut type_bindings = seeded_type_bindings; self.validate_callable_arg_bindings( @@ -54,15 +50,7 @@ impl TypeChecker { &mut type_bindings, call_span, ); - let resolved_params: Vec = params_with_explicit - .iter() - .map(|param| CallableParam { - name: param.name.clone(), - ty: substitute_resolved_type(¶m.ty, &type_bindings), - kind: param.kind, - has_default: param.has_default, - }) - .collect(); + let resolved_params = Self::substitute_callable_params(¶ms_with_explicit, &type_bindings); self.type_info .record_call_site_callable_params(call_span, &resolved_params); self.emit_explicit_bound_errors( @@ -158,7 +146,7 @@ impl TypeChecker { } /// Apply type bindings to callable parameters while preserving names, default markers, and parameter kind. - fn substitute_callable_params( + pub(in crate::frontend::typechecker::check_expr) fn substitute_callable_params( params: &[CallableParam], bindings: &std::collections::HashMap, ) -> Vec { @@ -181,9 +169,9 @@ impl TypeChecker { /// /// This runs the full generic call-site path for methods: /// - Validates arity when `explicit_type_args` is nonempty. - /// - Builds a partial substitution map (skipping [`ResolvedType::CallSiteInfer`] for `_` slots), applies it to the - /// method’s declared parameter and return types, then substitutes call-site `Self` via - /// [`TypeChecker::method_types_substituting_call_site_self`]. + /// - Builds a partial substitution map (skipping [`ResolvedType::CallSiteInfer`] for `_` slots), substitutes + /// call-site `Self` via [`TypeChecker::method_types_substituting_call_site_self`], then uses the optional + /// expected return type to bind still-open method type parameters before argument checking. /// - Validates value arguments against the specialized formals, then runs [`Self::infer_type_param_bindings`] so /// remaining type parameters are filled from argument types. /// - Enforces explicit `with` bounds, requires every method type parameter to be concretely bound when brackets @@ -214,6 +202,7 @@ impl TypeChecker { _arg_types: &[ResolvedType], call_site_span: Span, receiver_ty: &ResolvedType, + expected_return_ty: Option<&ResolvedType>, ) -> ResolvedType { let mut type_bindings = self.receiver_type_param_bindings(receiver_ty); let explicit_arity_ok = @@ -239,6 +228,9 @@ impl TypeChecker { // ---- Call-site `Self`, value-arg compatibility ---- let (params, return_type) = self.method_types_substituting_call_site_self(&method_info, receiver_ty); + if let Some(expected) = expected_return_ty { + self.infer_type_param_bindings(&return_type, expected, &mut type_bindings); + } let params = Self::substitute_callable_params(¶ms, &type_bindings); let return_type = substitute_resolved_type(&return_type, &type_bindings); let arg_types = self.check_call_arg_types_for_params(args, ¶ms); diff --git a/src/frontend/typechecker/check_expr/mod.rs b/src/frontend/typechecker/check_expr/mod.rs index 6056b0c20..cd57b4eb0 100644 --- a/src/frontend/typechecker/check_expr/mod.rs +++ b/src/frontend/typechecker/check_expr/mod.rs @@ -307,6 +307,9 @@ impl TypeChecker { self.check_unary_with_expected(*op, operand, expr.span, Some(expected_ty)) } (Expr::Try(inner), Some(expected_ty)) => self.check_try_with_expected(inner, expr.span, Some(expected_ty)), + (Expr::Call(callee, type_args, args), Some(expected_ty)) => { + self.check_call_with_expected(callee, type_args, args, expr.span, Some(expected_ty)) + } (Expr::MethodCall(base, method, type_args, args), Some(expected_ty)) => { self.check_method_call_with_expected(base, method, type_args, args, expr.span, Some(expected_ty)) } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index f8207b292..a375eb12c 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -13338,6 +13338,89 @@ def main() -> None: Ok(()) } + #[test] + fn consumer_check_desugared_generic_method_call_uses_expected_return_type_issue735() + -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let response = incan_vocab::DesugarResponse::expression(incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Field { + object: Box::new(incan_vocab::IncanExpr::Name("orders".to_string())), + field: "select".to_string(), + }), + args: vec![incan_vocab::IncanExpr::List(vec![incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Name("with_column_assignment".to_string())), + args: vec![ + incan_vocab::IncanExpr::Str("customer".to_string()), + incan_vocab::IncanExpr::Call { + callee: Box::new(incan_vocab::IncanExpr::Name("current_field".to_string())), + args: vec![ + incan_vocab::IncanExpr::Name("orders".to_string()), + incan_vocab::IncanExpr::Str("customer_id".to_string()), + ], + }, + ], + }])], + }); + let output_payload = serde_json::to_string(&response)?; + let wasm = compile_desugarer_wasm_requiring_request_substring( + &output_payload, + "missing SELECT clause payload", + r#""keyword":"SELECT""#, + )?; + write_pub_library_with_querykit_expression_clause_desugarer(tmp.path(), &wasm)?; + + let main_path = write_project_files( + tmp.path(), + "[project]\nname = \"consumer\"\n\n[dependencies]\nquerykit = { path = \"deps/querykit\" }\n", + r#"import pub::querykit + +@derive(Clone) +model Order: + customer_id: str + +@derive(Clone) +model Selected: + customer: str + +@derive(Clone) +model ColumnExpr: + source: str + +@derive(Clone) +model ColumnAssignment[T with Clone]: + name: str + +def current_field[T with Clone](_frame: LazyFrame[T], source: str) -> ColumnExpr: + return ColumnExpr(source=source) + +def with_column_assignment[T with Clone](name: str, _expr: ColumnExpr) -> ColumnAssignment[T]: + return ColumnAssignment[T](name=name) + +@derive(Clone) +class LazyFrame[T with Clone]: + _type_witness: list[T] + + def select[U with Clone](self, columns: list[ColumnAssignment[U]]) -> LazyFrame[U]: + return LazyFrame[U](_type_witness=[]) + +def direct_method_call(orders: LazyFrame[Order]) -> LazyFrame[Selected]: + return orders.select([with_column_assignment("customer", current_field(orders, "customer_id"))]) + +def query_block_call(orders: LazyFrame[Order]) -> LazyFrame[Selected]: + return query { FROM orders SELECT customer_id as customer } +"#, + )?; + + let output = run_check(&main_path)?; + assert!( + output.status.success(), + "expected desugared generic method call to use the same contextual return type as direct source.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + } + #[test] fn equivalent_helper_backed_keywords_typecheck() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 59805b281..f6575cfdb 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -124,6 +124,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Vocab expression-list clauses preserve item metadata**: `ClauseSurface::expr_list(...)` accepts `expr as alias` entries and declared trailing item modifiers such as `expr for target with context`, exposing structured metadata to desugarers instead of forcing SQL-shaped projections through field-set syntax (#724). - **Expression-desugaring vocab declarations work as values**: `DeclarationSurface::desugars_to_expression()` blocks can now appear where expressions are valid, including assignment values and return values; colon and brace forms desugar before typechecking while preserving inline clause bodies, expression-list item metadata, compound clause tokens such as `GROUP BY`, and public vocab method-call output (#727). - **Vocab helper calls use ordinary public call planning**: `IncanExpr::Helper(...)` output now follows the same exported-default, union-wrapping, owned-string, and dependency-owned type identity rules as direct `pub::library` calls, so DSL desugarers can call source-backed helpers without hand-authored workarounds (#729). +- **Vocab-generated generic calls keep context**: Expression-position desugar output now threads expected return types through generic function, callable, and method calls, so DSL-generated method calls infer the same output types as equivalent source calls (#735). - **Rust raw identifier fields emit correctly**: Rust-backed fields whose real Rust name is a keyword can be accessed with the keyword spelling, such as `value.type` and `TypeName(type=value)`, while generated Rust emits the actual raw identifier access such as `value.r#type` (#725). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). From 7b654ca4cbcf68dbdc5fe3049c5d1312697f9630 Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 1 Jun 2026 18:21:58 +0200 Subject: [PATCH 57/58] bugfix - make rust metadata prewarm observable and cheaper (#736) (#738) --- Cargo.lock | 18 +- Cargo.toml | 2 +- crates/rust_inspect/README.md | 6 +- crates/rust_inspect/src/cache.rs | 198 +++++++++++------- crates/rust_inspect/src/cache_tests.rs | 19 +- crates/rust_inspect/src/lib.rs | 100 ++++++++- src/cli/commands/common.rs | 22 +- .../docs-site/docs/release_notes/0_3.md | 1 + 8 files changed, 269 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60d71b60c..8b0380106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc39" +version = "0.3.0-rc40" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 2d0104a86..22c3acdb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc39" +version = "0.3.0-rc40" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/crates/rust_inspect/README.md b/crates/rust_inspect/README.md index 95c29cd84..3893d684f 100644 --- a/crates/rust_inspect/README.md +++ b/crates/rust_inspect/README.md @@ -32,7 +32,7 @@ let inspector = Inspector::new(InspectorConfig::new("target/incan_lock")); inspector.prewarm( ["demo::consumer::consume".to_string()], - &|path| eprintln!("warming {path}"), + &|message| eprintln!("{message}"), )?; let hit = inspector.get("demo::consumer::consume")?; @@ -41,6 +41,8 @@ let hit = inspector.get("demo::consumer::consume")?; The intended contract is: - `prewarm(...)` may perform expensive extraction +- `prewarm(...)` reports explicit start, per-item, and completion progress through its callback so CLI callers can keep long Rust metadata preparation observable without requiring users to run separate probes +- `prewarm(...)` flushes disk-cache changes once per batch instead of rewriting the complete cache after every item - `get(...)` should be cache-only - workspace loading is owned by explicit preparation/cache code, not by semantic checks as a side effect - published-library consumers should prefer shipped `.incnlib` Rust ABI metadata over workspace inspection @@ -88,6 +90,8 @@ The stable architectural rule is the phase boundary: extraction happens before h - `cache_resolve.rs`: dependency/source-root resolution helpers - `cache_timing.rs`: optional timing instrumentation (still uses `eprintln!` when `INCAN_RUST_INSPECT_TIMING` is set) +The in-memory cache stores a definition-path alias index alongside exact item keys. Re-export-heavy crates can then resolve `definition_path` hits directly instead of scanning every cached item and recomputing Rust spelling aliases for each lookup. + Structured logging for durable diagnostics uses `tracing` (for example disk-cache parse failures and failed persists). The on-disk cache filename is `.incan_rust_inspect_cache.json`. The cache loader still reads the older `.incan_rust_metadata_cache.json` filename for backward compatibility. diff --git a/crates/rust_inspect/src/cache.rs b/crates/rust_inspect/src/cache.rs index de5d651bc..aaaea449d 100644 --- a/crates/rust_inspect/src/cache.rs +++ b/crates/rust_inspect/src/cache.rs @@ -43,6 +43,7 @@ pub struct RustMetadataCache { struct CacheInner { workspaces: HashMap<(PathBuf, bool), RustWorkspace>, items: HashMap<(PathBuf, String), Arc>, + definition_aliases: HashMap<(PathBuf, String), String>, failed_items: HashMap<(PathBuf, String), NegativeLookup>, disk_cache_state: HashMap, } @@ -188,9 +189,9 @@ fn load_disk_cache_into_memory(inner: &mut CacheInner, root: &Path) -> Result Result<(), R Ok(()) } -/// Persist one extracted/canonicalized item into the workspace-local disk cache snapshot. -fn persist_item_to_disk_cache( - inner: &CacheInner, - root: &Path, - metadata: &RustItemMetadata, -) -> Result<(), RustMetadataError> { +/// Build the current workspace-local disk cache snapshot. +fn disk_cache_envelope(inner: &CacheInner, root: &Path) -> Result { let fingerprint = inner .disk_cache_state .get(root) @@ -233,52 +230,31 @@ fn persist_item_to_disk_cache( misses.insert(canonical_path.clone(), miss.clone()); } } - items.insert(metadata.canonical_path.clone(), metadata.clone()); - let envelope = DiskCacheEnvelope { + Ok(DiskCacheEnvelope { cache_format: DISK_CACHE_FORMAT, inspector_version: INSPECTOR_VERSION.to_string(), workspace_fingerprint: fingerprint, items, misses, - }; - write_disk_cache(root, &envelope) + }) } -/// Persist one stable miss into workspace-local disk cache. -fn persist_negative_to_disk_cache( - inner: &CacheInner, - root: &Path, - canonical_path: &str, - negative: &NegativeLookup, -) -> Result<(), RustMetadataError> { - let fingerprint = inner - .disk_cache_state - .get(root) - .and_then(|state| state.workspace_fingerprint.clone()) - .unwrap_or(workspace_fingerprint(root)?); - let mut items = HashMap::new(); - let mut misses = HashMap::new(); - for ((item_root, path), cached) in &inner.items { - if item_root == root { - items.insert(path.clone(), (*cached.as_ref()).clone()); - } - } - for ((item_root, path), miss) in &inner.failed_items { - if item_root == root { - misses.insert(path.clone(), miss.clone()); - } - } - misses.insert(canonical_path.to_owned(), negative.clone()); - let envelope = DiskCacheEnvelope { - cache_format: DISK_CACHE_FORMAT, - inspector_version: INSPECTOR_VERSION.to_string(), - workspace_fingerprint: fingerprint, - items, - misses, - }; +/// Persist the complete workspace-local disk cache snapshot. +fn persist_manifest_dir_to_disk_cache(inner: &CacheInner, root: &Path) -> Result<(), RustMetadataError> { + let envelope = disk_cache_envelope(inner, root)?; write_disk_cache(root, &envelope) } +/// Persist the workspace-local disk cache snapshot after an item update. +fn persist_item_to_disk_cache(inner: &CacheInner, root: &Path) -> Result<(), RustMetadataError> { + persist_manifest_dir_to_disk_cache(inner, root) +} + +/// Persist the workspace-local disk cache snapshot after a stable miss. +fn persist_negative_to_disk_cache(inner: &CacheInner, root: &Path) -> Result<(), RustMetadataError> { + persist_manifest_dir_to_disk_cache(inner, root) +} + #[derive(Debug, Clone)] pub struct CacheLookupHit { pub metadata: Arc, @@ -341,14 +317,46 @@ fn canonical_path_candidates(canonical_path: &str) -> Vec { } } -/// Return whether two Rust paths may name the same item after cache-supported spelling aliases. -fn cache_path_aliases_match(left: &str, right: &str) -> bool { - let right_candidates = canonical_path_candidates(right); - canonical_path_candidates(left).iter().any(|left_candidate| { - right_candidates - .iter() - .any(|right_candidate| left_candidate == right_candidate) - }) +/// Remove definition-path aliases owned by the currently cached item at `canonical_path`. +fn remove_cached_item_definition_aliases(inner: &mut CacheInner, root: &Path, canonical_path: &str) { + let key_item = (root.to_path_buf(), canonical_path.to_owned()); + let Some(existing) = inner.items.get(&key_item) else { + return; + }; + let Some(definition_path) = existing.definition_path.as_deref() else { + return; + }; + for candidate in canonical_path_candidates(definition_path) { + let key = (root.to_path_buf(), candidate); + if inner + .definition_aliases + .get(&key) + .is_some_and(|indexed_path| indexed_path == canonical_path) + { + inner.definition_aliases.remove(&key); + } + } +} + +/// Index one cached item by its resolved Rust definition path and supported spelling aliases. +fn index_cached_item_definition_aliases(inner: &mut CacheInner, root: &Path, metadata: &RustItemMetadata) { + let Some(definition_path) = metadata.definition_path.as_deref() else { + return; + }; + for candidate in canonical_path_candidates(definition_path) { + inner + .definition_aliases + .insert((root.to_path_buf(), candidate), metadata.canonical_path.clone()); + } +} + +/// Insert or replace cached metadata while keeping the definition-path alias index in sync. +fn insert_cached_item(inner: &mut CacheInner, root: &Path, metadata: Arc) { + remove_cached_item_definition_aliases(inner, root, metadata.canonical_path.as_str()); + index_cached_item_definition_aliases(inner, root, metadata.as_ref()); + inner + .items + .insert((root.to_path_buf(), metadata.canonical_path.clone()), metadata); } /// Re-key a cached item for a query path while preserving the extracted Rust metadata. @@ -363,19 +371,22 @@ fn insert_aliased_item( let arc = Arc::new(aliased); let key_item = (root.to_path_buf(), canonical_path.to_owned()); inner.failed_items.remove(&key_item); - inner.items.insert(key_item, Arc::clone(&arc)); + insert_cached_item(inner, root, Arc::clone(&arc)); arc } /// Look up cached public aliases whose recorded definition path matches the requested path. fn cached_definition_alias(inner: &CacheInner, root: &Path, canonical_path: &str) -> Option> { - inner.items.iter().find_map(|((item_root, _), cached)| { - if item_root != root { - return None; + for candidate in canonical_path_candidates(canonical_path) { + let alias_key = (root.to_path_buf(), candidate); + if let Some(canonical_path) = inner.definition_aliases.get(&alias_key) { + let item_key = (root.to_path_buf(), canonical_path.clone()); + if let Some(cached) = inner.items.get(&item_key) { + return Some(Arc::clone(cached)); + } } - let definition = cached.definition_path.as_deref()?; - cache_path_aliases_match(definition, canonical_path).then(|| Arc::clone(cached)) - }) + } + None } /// Attempt extraction through primary workspace, out-dirs workspace, then resolved dependency workspace. @@ -631,6 +642,7 @@ impl RustMetadataCache { canonical_path: &str, registry_src_roots: Option<&[PathBuf]>, progress: &(dyn Fn(String) + Sync), + persist_immediately: bool, ) -> Result, RustMetadataError> { let root = manifest_dir.canonicalize()?; let timing_enabled = rust_inspect_timing_enabled(); @@ -660,7 +672,8 @@ impl RustMetadataCache { if let Some(hit) = cached_definition_alias(&inner, &root, canonical_path) { let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); let persist_started = Instant::now(); - if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) + if persist_immediately + && let Err(err) = persist_item_to_disk_cache(&inner, &root) && timing_enabled { eprintln!( @@ -675,7 +688,7 @@ impl RustMetadataCache { canonical_path, "disk_cache.persist.definition_alias_hit", persist_started.elapsed(), - "", + if persist_immediately { "" } else { "deferred=true" }, ); trace.set_outcome("hit.memory.definition_alias"); return Ok(arc); @@ -692,7 +705,8 @@ impl RustMetadataCache { if let Some(hit) = inner.items.get(&candidate_key).cloned() { let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); let persist_started = Instant::now(); - if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) + if persist_immediately + && let Err(err) = persist_item_to_disk_cache(&inner, &root) && timing_enabled { eprintln!( @@ -707,7 +721,7 @@ impl RustMetadataCache { canonical_path, "disk_cache.persist.alias_hit", persist_started.elapsed(), - "", + if persist_immediately { "" } else { "deferred=true" }, ); trace.set_outcome("hit.memory.alias"); return Ok(arc); @@ -746,7 +760,8 @@ impl RustMetadataCache { inner .failed_items .insert((root.clone(), canonical_path.to_owned()), negative.clone()); - if let Err(persist_err) = persist_negative_to_disk_cache(&inner, &root, canonical_path, &negative) + if persist_immediately + && let Err(persist_err) = persist_negative_to_disk_cache(&inner, &root) && timing_enabled { eprintln!( @@ -763,9 +778,10 @@ impl RustMetadataCache { inner.failed_items.remove(&(root.clone(), canonical_path.to_owned())); meta.canonical_path = canonical_path.to_owned(); let arc = Arc::new(meta); - inner.items.insert(key_item, Arc::clone(&arc)); + insert_cached_item(&mut inner, &root, Arc::clone(&arc)); let persist_started = Instant::now(); - if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) + if persist_immediately + && let Err(err) = persist_item_to_disk_cache(&inner, &root) && timing_enabled { eprintln!( @@ -780,19 +796,45 @@ impl RustMetadataCache { canonical_path, "disk_cache.persist.extracted", persist_started.elapsed(), - "", + if persist_immediately { "" } else { "deferred=true" }, ); trace.set_outcome("hit.extracted"); Ok(arc) } + /// Return metadata for a canonical Rust path, extracting from the workspace and persisting cache misses. pub fn get_or_extract( &self, manifest_dir: &Path, canonical_path: &str, progress: &(dyn Fn(String) + Sync), ) -> Result, RustMetadataError> { - self.get_or_extract_inner(manifest_dir, canonical_path, None, progress) + self.get_or_extract_inner(manifest_dir, canonical_path, None, progress, true) + } + + /// Return metadata for a canonical Rust path while deferring disk-cache persistence to the caller. + /// + /// Prewarm batches extract many items and flush the manifest cache once instead of rewriting it after every item. + pub(crate) fn get_or_extract_deferred_persist( + &self, + manifest_dir: &Path, + canonical_path: &str, + progress: &(dyn Fn(String) + Sync), + ) -> Result, RustMetadataError> { + self.get_or_extract_inner(manifest_dir, canonical_path, None, progress, false) + } + + /// Persist the in-memory cache snapshot for one manifest root. + /// + /// Prewarm uses deferred extraction so callers can batch writes until every requested item has been visited. + pub(crate) fn persist_manifest_dir(&self, manifest_dir: &Path) -> Result<(), RustMetadataError> { + let root = manifest_dir.canonicalize()?; + let mut inner = self.inner.lock().map_err(|e| RustMetadataError::LoadWorkspace { + path: root.clone(), + message: format!("metadata cache lock poisoned: {e}"), + })?; + ensure_disk_cache_loaded(&mut inner, &root)?; + persist_manifest_dir_to_disk_cache(&inner, &root) } /// Return metadata from memory/disk cache only. @@ -820,7 +862,7 @@ impl RustMetadataCache { if let Some(hit) = cached_definition_alias(&inner, &root, canonical_path) { let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); - if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) { + if let Err(err) = persist_item_to_disk_cache(&inner, &root) { tracing::warn!( root = %root.display(), query = %canonical_path, @@ -845,7 +887,7 @@ impl RustMetadataCache { let candidate_key = (root.clone(), candidate.clone()); if let Some(hit) = inner.items.get(&candidate_key).cloned() { let arc = insert_aliased_item(&mut inner, &root, canonical_path, &hit); - if let Err(err) = persist_item_to_disk_cache(&inner, &root, arc.as_ref()) { + if let Err(err) = persist_item_to_disk_cache(&inner, &root) { tracing::warn!( root = %root.display(), query = %canonical_path, @@ -869,6 +911,9 @@ impl RustMetadataCache { Ok(None) } + /// Drop all in-memory and disk-cache bookkeeping for one manifest root. + /// + /// Use this after filesystem or dependency changes so the next lookup rebuilds fresh alias indexes. pub fn invalidate_manifest_dir(&self, manifest_dir: &Path) -> Result<(), RustMetadataError> { let root = manifest_dir.canonicalize()?; let mut inner = self.inner.lock().map_err(|e| RustMetadataError::LoadWorkspace { @@ -879,6 +924,9 @@ impl RustMetadataCache { .workspaces .retain(|(workspace_root, _), _| workspace_root != &root); inner.items.retain(|(workspace_root, _), _| workspace_root != &root); + inner + .definition_aliases + .retain(|(workspace_root, _), _| workspace_root != &root); inner .failed_items .retain(|(workspace_root, _), _| workspace_root != &root); @@ -886,6 +934,9 @@ impl RustMetadataCache { Ok(()) } + /// Return metadata for tests that need custom registry source roots. + /// + /// Production callers should use `get_or_extract`; this hook lets tests use synthetic cargo registry directories. #[doc(hidden)] pub fn get_or_extract_with_registry_src_roots( &self, @@ -894,14 +945,13 @@ impl RustMetadataCache { registry_src_roots: &[PathBuf], progress: &(dyn Fn(String) + Sync), ) -> Result, RustMetadataError> { - self.get_or_extract_inner(manifest_dir, canonical_path, Some(registry_src_roots), progress) + self.get_or_extract_inner(manifest_dir, canonical_path, Some(registry_src_roots), progress, true) } /// Seed metadata directly for tests without invoking rust-analyzer extraction. #[doc(hidden)] pub fn insert_test_item(&self, manifest_dir: &Path, metadata: RustItemMetadata) -> Result<(), RustMetadataError> { let root = manifest_dir.canonicalize()?; - let key_item = (root.clone(), metadata.canonical_path.clone()); let mut inner = self.inner.lock().map_err(|e| RustMetadataError::LoadWorkspace { path: manifest_dir.to_path_buf(), message: format!("metadata cache lock poisoned: {e}"), @@ -909,7 +959,7 @@ impl RustMetadataCache { inner .failed_items .remove(&(root.clone(), metadata.canonical_path.clone())); - inner.items.insert(key_item, Arc::new(metadata)); + insert_cached_item(&mut inner, &root, Arc::new(metadata)); Ok(()) } } diff --git a/crates/rust_inspect/src/cache_tests.rs b/crates/rust_inspect/src/cache_tests.rs index 0b3ae8c1b..2b701dd0d 100644 --- a/crates/rust_inspect/src/cache_tests.rs +++ b/crates/rust_inspect/src/cache_tests.rs @@ -77,6 +77,7 @@ name = "foo_bar" Ok(()) } +/// Inserted metadata should survive a disk-cache round trip through a fresh cache instance. #[test] fn disk_cache_round_trips_inserted_items() -> Result<(), Box> { let tmp = tempfile::tempdir()?; @@ -91,7 +92,7 @@ fn disk_cache_round_trips_inserted_items() -> Result<(), Box Result<(), Box Ok(()) } -#[test] /// Malformed on-disk cache payloads are ignored instead of poisoning later lookups. +#[test] fn malformed_disk_cache_is_treated_as_miss() -> Result<(), Box> { let tmp = tempfile::tempdir()?; fs::write( @@ -197,6 +198,20 @@ fn definition_path_alias_hits_existing_cached_reexport() -> Result<(), Box { warmed += 1; if debug { @@ -150,6 +165,11 @@ impl Inspector { Err(err) => return Err(err.into()), } } + self.cache.persist_manifest_dir(self.config.manifest_dir())?; + progress(format!( + "rust-inspect prewarm complete: warmed={warmed} skipped={skipped} elapsed_ms={:.0}", + started_all.elapsed().as_secs_f64() * 1000.0 + )); if debug { tracing::debug!( warmed, @@ -254,3 +274,75 @@ fn metadata_has_unknowns(metadata: &RustItemMetadata) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::Mutex; + + use incan_core::interop::{RustItemKind, RustItemMetadata, RustTypeInfo, RustVisibility}; + + use super::*; + + fn dummy_type_metadata(path: &str) -> RustItemMetadata { + RustItemMetadata { + canonical_path: path.to_string(), + definition_path: None, + visibility: RustVisibility::Public, + kind: RustItemKind::Type(RustTypeInfo { + alias_target: None, + methods: Vec::new(), + implemented_traits: Vec::new(), + fields: Vec::new(), + variants: Vec::new(), + }), + } + } + + #[test] + fn prewarm_reports_deduped_progress_without_forcing_callers_to_probe() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + )?; + let inspector = Inspector::new(InspectorConfig::new(tmp.path())); + inspector + .cache() + .insert_test_item(tmp.path(), dummy_type_metadata("demo::Thing"))?; + let messages = Mutex::new(Vec::new()); + + inspector.prewarm(vec!["demo::Thing".to_string(), "demo::Thing".to_string()], &|message| { + if let Ok(mut messages) = messages.lock() { + messages.push(message); + } + })?; + + let messages = messages + .into_inner() + .map_err(|_| std::io::Error::other("progress message lock poisoned"))?; + assert!( + messages + .iter() + .any(|message| message == "rust-inspect prewarm start: 1 item(s)"), + "expected observable prewarm start message, got {messages:?}" + ); + assert!( + messages + .iter() + .any(|message| message == "rust-inspect prewarm item 1/1: demo::Thing"), + "expected observable prewarm item message, got {messages:?}" + ); + assert!( + messages + .iter() + .any(|message| message.starts_with("rust-inspect prewarm complete:")), + "expected observable prewarm completion message, got {messages:?}" + ); + assert!( + messages.iter().all(|message| !message.contains("item 2/")), + "prewarm progress should report deduped work, got {messages:?}" + ); + Ok(()) + } +} diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs index 07d5c3748..c79352f63 100644 --- a/src/cli/commands/common.rs +++ b/src/cli/commands/common.rs @@ -933,6 +933,14 @@ fn rust_inspect_prewarm_enabled() -> bool { parse_rust_inspect_prewarm_env(std::env::var("INCAN_RUST_INSPECT_PREWARM").ok().as_deref()) } +/// Surface rust-inspect preparation progress from explicit CLI prewarm phases. +#[cfg(feature = "rust_inspect")] +fn print_rust_inspect_prewarm_progress(message: String) { + if message.starts_with("rust-inspect prewarm") { + eprintln!("{message}"); + } +} + /// Eagerly load rust-inspect metadata before typechecking/codegen hot paths. /// /// Prewarm defaults to enabled because lazy rust-analyzer extraction can dominate warm CLI runs. @@ -946,12 +954,14 @@ pub(crate) fn prewarm_rust_inspect_workspace(manifest_dir: &Path, query_paths: & return Ok(()); } let inspector = Inspector::new(InspectorConfig::new(manifest_dir.to_path_buf())); - inspector.prewarm(query_paths.iter().cloned(), &|_| ()).map_err(|err| { - CliError::failure(format!( - "failed to prewarm rust-inspect cache from {}: {err}", - manifest_dir.display() - )) - }) + inspector + .prewarm(query_paths.iter().cloned(), &print_rust_inspect_prewarm_progress) + .map_err(|err| { + CliError::failure(format!( + "failed to prewarm rust-inspect cache from {}: {err}", + manifest_dir.display() + )) + }) } /// Resolve the source path for a stdlib module path (e.g. `["std", "testing"]`). diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index f6575cfdb..5bf0abcad 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -125,6 +125,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Expression-desugaring vocab declarations work as values**: `DeclarationSurface::desugars_to_expression()` blocks can now appear where expressions are valid, including assignment values and return values; colon and brace forms desugar before typechecking while preserving inline clause bodies, expression-list item metadata, compound clause tokens such as `GROUP BY`, and public vocab method-call output (#727). - **Vocab helper calls use ordinary public call planning**: `IncanExpr::Helper(...)` output now follows the same exported-default, union-wrapping, owned-string, and dependency-owned type identity rules as direct `pub::library` calls, so DSL desugarers can call source-backed helpers without hand-authored workarounds (#729). - **Vocab-generated generic calls keep context**: Expression-position desugar output now threads expected return types through generic function, callable, and method calls, so DSL-generated method calls infer the same output types as equivalent source calls (#735). +- **Rust metadata prewarm is observable and less scan-heavy**: Rust inspection prewarm now reports explicit progress during long library builds, indexes definition-path aliases instead of scanning every cached item for re-export lookups, and flushes disk-cache updates once per batch (#736). - **Rust raw identifier fields emit correctly**: Rust-backed fields whose real Rust name is a keyword can be accessed with the keyword spelling, such as `value.type` and `TypeName(type=value)`, while generated Rust emits the actual raw identifier access such as `value.r#type` (#725). - **Script and test manifests are scoped**: Generated Cargo manifests include only reachable dependencies instead of blindly inheriting package-level heavy dependencies (#665). From 4629640a57a6b7816a8036d55fb08c0a082fdffb Mon Sep 17 00:00:00 2001 From: Danny Meijer Date: Mon, 1 Jun 2026 20:23:35 +0200 Subject: [PATCH 58/58] bugfix - route assert comparisons through binary emission (#739) (#740) --- Cargo.lock | 18 ++++----- Cargo.toml | 2 +- src/backend/ir/emit/expressions/calls.rs | 27 ++++++++++--- .../emit/expressions/calls/testing_asserts.rs | 29 ++++++-------- tests/cli_integration.rs | 34 ++++++++++++++++ ...tests__imported_stdlib_value_fragment.snap | 2 +- ...degen_snapshot_tests__std_uuid_import.snap | 39 +++++++++++-------- .../docs-site/docs/release_notes/0_3.md | 1 + 8 files changed, 101 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b0380106..b7c182788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ [[package]] name = "incan" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "blake2", "blake3", @@ -1527,14 +1527,14 @@ dependencies = [ [[package]] name = "incan_core" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "serde", ] [[package]] name = "incan_derive" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "proc-macro2", "quote", @@ -1543,14 +1543,14 @@ dependencies = [ [[package]] name = "incan_semantics_core" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "incan_core", ] [[package]] name = "incan_semantics_stdlib" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "incan_core", "incan_semantics_core", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "incan_stdlib" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "axum", "incan_core", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "incan_syntax" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "incan_core", "incan_semantics_core", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "incan_web_macros" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "proc-macro2", "quote", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "rust_inspect" -version = "0.3.0-rc40" +version = "0.3.0-rc41" dependencies = [ "hex", "incan_core", diff --git a/Cargo.toml b/Cargo.toml index 22c3acdb2..5e38a1ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ resolver = "2" ra_ap_proc_macro_api = { path = "crates/third_party/ra_ap_proc_macro_api" } [workspace.package] -version = "0.3.0-rc40" +version = "0.3.0-rc41" description = "The Incan programming language compiler" edition = "2024" rust-version = "1.92" diff --git a/src/backend/ir/emit/expressions/calls.rs b/src/backend/ir/emit/expressions/calls.rs index 2179e478f..8a154eb7c 100644 --- a/src/backend/ir/emit/expressions/calls.rs +++ b/src/backend/ir/emit/expressions/calls.rs @@ -1362,10 +1362,7 @@ mod tests { let tokens = emitter .emit_call_expr(&func, &[], &[pos_arg(left), pos_arg(right)], None, Some(&path)) .map_err(|err| std::io::Error::other(format!("canonical assert_eq should emit: {err:?}")))?; - assert_eq!( - render(tokens), - "if(left)!=(right){panic!(\"AssertionError:left!=right\");}" - ); + assert_eq!(render(tokens), "ifleft!=right{panic!(\"AssertionError:left!=right\");}"); Ok(()) } @@ -1389,7 +1386,7 @@ mod tests { .map_err(|err| std::io::Error::other(format!("canonical assert_eq with message should emit: {err:?}")))?; assert_eq!( render(tokens), - "if(left)!=(right){{let__incan_assert_msg=msg;if__incan_assert_msg.is_empty(){panic!(\"AssertionError:left!=right\");}else{panic!(\"AssertionError:{};{}\",__incan_assert_msg,\"left!=right\");}}}" + "ifleft!=right{{let__incan_assert_msg=msg;if__incan_assert_msg.is_empty(){panic!(\"AssertionError:left!=right\");}else{panic!(\"AssertionError:{};{}\",__incan_assert_msg,\"left!=right\");}}}" ); Ok(()) } @@ -1426,7 +1423,25 @@ mod tests { })?; assert_eq!( render(tokens), - "if(encoded>0)!=(true){panic!(\"AssertionError:left!=right\");}" + "if(encoded>0)!=true{panic!(\"AssertionError:left!=right\");}" + ); + Ok(()) + } + + #[test] + fn emit_canonical_assert_ne_reuses_string_binop_plan() -> Result<(), Box> { + let registry = FunctionRegistry::new(); + let emitter = IrEmitter::new(®istry); + let func = rust_call_target("assert_ne"); + let left = local_arg("value", IrType::Ref(Box::new(IrType::String))); + let right = local_arg("target", IrType::String); + let path = canonical_testing_path("assert_ne"); + let tokens = emitter + .emit_call_expr(&func, &[], &[pos_arg(left), pos_arg(right)], None, Some(&path)) + .map_err(|err| std::io::Error::other(format!("canonical assert_ne should emit: {err:?}")))?; + assert_eq!( + render(tokens), + "ifincan_stdlib::strings::str_eq(&value,&target){panic!(\"AssertionError:left==right\");}" ); Ok(()) } diff --git a/src/backend/ir/emit/expressions/calls/testing_asserts.rs b/src/backend/ir/emit/expressions/calls/testing_asserts.rs index a86c2e3d9..23313ed5a 100644 --- a/src/backend/ir/emit/expressions/calls/testing_asserts.rs +++ b/src/backend/ir/emit/expressions/calls/testing_asserts.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream; use quote::quote; use crate::backend::ir::emit::{EmitError, IrEmitter}; -use crate::backend::ir::expr::{IrCallArg, IrExprKind, TypedExpr}; +use crate::backend::ir::expr::{BinOp, IrCallArg, IrExprKind, TypedExpr}; use crate::backend::ir::types::IrType; use incan_core::lang::surface::constructors::{self, ConstructorId}; use incan_core::lang::testing::{self, TestingAssertHelperId}; @@ -195,27 +195,22 @@ impl<'a> IrEmitter<'a> { let name = testing::assert_helper_as_str(helper_id); let left = Self::canonical_assert_arg(helper_id, args, 0)?; let right = Self::canonical_assert_arg(helper_id, args, 1)?; - let left_tokens = self.emit_expr(left)?; - let right_tokens = self.emit_expr(right)?; let message = args.get(2).map(|arg| &arg.expr); let failure_kind = testing::assert_comparison_failure_kind(helper_id).ok_or_else(|| { EmitError::Unsupported(format!("std.testing.{name} is not a comparison assertion helper")) })?; - if helper_id == TestingAssertHelperId::AssertEq { - let failure = self.emit_assert_comparison_failure(failure_kind, message)?; - Ok(quote! { - if (#left_tokens) != (#right_tokens) { - #failure - } - }) + let failure_op = if helper_id == TestingAssertHelperId::AssertEq { + BinOp::Ne } else { - let failure = self.emit_assert_comparison_failure(failure_kind, message)?; - Ok(quote! { - if (#left_tokens) == (#right_tokens) { - #failure - } - }) - } + BinOp::Eq + }; + let failure_condition = self.emit_binop_expr(&failure_op, left, right)?; + let failure = self.emit_assert_comparison_failure(failure_kind, message)?; + Ok(quote! { + if #failure_condition { + #failure + } + }) } /// Emit an assertion that an option is `Some`. diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 5b5ea1d12..a45b1cef5 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -401,6 +401,40 @@ fn build_reuses_stale_lockfile_without_rewriting_by_default() -> Result<(), Box< Ok(()) } +#[test] +fn build_assert_string_inequality_in_list_loop_issue739() -> Result<(), Box> { + let tmp = tempfile::tempdir()?; + let src_dir = tmp.path().join("src"); + fs::create_dir_all(&src_dir)?; + fs::write( + tmp.path().join("incan.toml"), + r#"[project] +name = "list_str_loop_assert_compare" +version = "0.1.0" +"#, + )?; + let main_path = src_dir.join("main.incn"); + fs::write( + &main_path, + r#" +def validate(values: list[str], target: str) -> None: + for value in values: + assert value != target, "duplicate" + + +def main() -> None: + validate(["a"], "b") +"#, + )?; + + let build_output = run_incan( + tmp.path(), + &["build", main_path.to_str().ok_or("main path was not valid UTF-8")?], + )?; + assert_success(&build_output, "incan build for assert string inequality in list loop"); + Ok(()) +} + #[test] fn test_reuses_stale_lockfile_without_rewriting_by_default() -> Result<(), Box> { let tmp = tempfile::tempdir()?; diff --git a/tests/snapshots/codegen_snapshot_tests__imported_stdlib_value_fragment.snap b/tests/snapshots/codegen_snapshot_tests__imported_stdlib_value_fragment.snap index 7b29e7fd7..822c13fb4 100644 --- a/tests/snapshots/codegen_snapshot_tests__imported_stdlib_value_fragment.snap +++ b/tests/snapshots/codegen_snapshot_tests__imported_stdlib_value_fragment.snap @@ -21,7 +21,7 @@ fn main() { }), ); let out = crate::__incan_std::datetime::civil::naive::ordinal_key_byte(7); - if (::std::convert::identity(out.len() as i64)) != (1) { + if ::std::convert::identity(out.len() as i64) != 1 { panic!("AssertionError: left != right"); } } diff --git a/tests/snapshots/codegen_snapshot_tests__std_uuid_import.snap b/tests/snapshots/codegen_snapshot_tests__std_uuid_import.snap index 3e3219d90..d8ab2e71e 100644 --- a/tests/snapshots/codegen_snapshot_tests__std_uuid_import.snap +++ b/tests/snapshots/codegen_snapshot_tests__std_uuid_import.snap @@ -1,6 +1,5 @@ --- source: tests/codegen_snapshot_tests.rs -assertion_line: 2773 expression: rust_code --- // Generated by the Incan compiler v @@ -21,56 +20,62 @@ fn exercise_uuid() -> Result<(), UuidError> { let same = UUID::from_int(parsed.to_int()); let raw = parsed.to_bytes()?; let roundtrip = UUID::from_bytes(raw)?; - if (same) != (parsed) { + if same != parsed { panic!("AssertionError: left != right"); } - if (roundtrip) != (parsed) { + if roundtrip != parsed { panic!("AssertionError: left != right"); } - if (NIL) != (UUID::nil()) { + if NIL != UUID::nil() { panic!("AssertionError: left != right"); } - if (MAX) != (UUID::max()) { + if MAX != UUID::max() { panic!("AssertionError: left != right"); } - if (parsed.version()?) != (UuidVersion::V4) { + if parsed.version()? != UuidVersion::V4 { panic!("AssertionError: left != right"); } - if (parsed.variant()?) != (UuidVariant::Rfc9562) { + if parsed.variant()? != UuidVariant::Rfc9562 { panic!("AssertionError: left != right"); } - if (UUID::from_int(parsed.to_int())) != (parsed) { + if UUID::from_int(parsed.to_int()) != parsed { panic!("AssertionError: left != right"); } - if (NAMESPACE_DNS.canonical()?) != ("6ba7b810-9dad-11d1-80b4-00c04fd430c8") { + if incan_stdlib::strings::str_ne( + &NAMESPACE_DNS.canonical()?, + &"6ba7b810-9dad-11d1-80b4-00c04fd430c8", + ) { panic!("AssertionError: left != right"); } - if (NAMESPACE_URL.canonical()?) != ("6ba7b811-9dad-11d1-80b4-00c04fd430c8") { + if incan_stdlib::strings::str_ne( + &NAMESPACE_URL.canonical()?, + &"6ba7b811-9dad-11d1-80b4-00c04fd430c8", + ) { panic!("AssertionError: left != right"); } - if (UUID::v3( + if UUID::v3( NAMESPACE_DNS.clone(), __IncanUniond83007a429c21b6f::V0("www.example.com".to_string()), )? - .version()?) != (UuidVersion::V3) + .version()? != UuidVersion::V3 { panic!("AssertionError: left != right"); } - if (UUID::v4()?.version()?) != (UuidVersion::V4) { + if UUID::v4()?.version()? != UuidVersion::V4 { panic!("AssertionError: left != right"); } - if (UUID::v5( + if UUID::v5( NAMESPACE_DNS, __IncanUniond83007a429c21b6f::V0("www.example.com".to_string()), )? - .version()?) != (UuidVersion::V5) + .version()? != UuidVersion::V5 { panic!("AssertionError: left != right"); } - if (UUID::v7()?.variant()?) != (UuidVariant::Rfc9562) { + if UUID::v7()?.variant()? != UuidVariant::Rfc9562 { panic!("AssertionError: left != right"); } - if (UUID::v8(b"abcdefghijklmnop".to_vec())?.version()?) != (UuidVersion::V8) { + if UUID::v8(b"abcdefghijklmnop".to_vec())?.version()? != UuidVersion::V8 { panic!("AssertionError: left != right"); } return Ok::<(), UuidError>(()); diff --git a/workspaces/docs-site/docs/release_notes/0_3.md b/workspaces/docs-site/docs/release_notes/0_3.md index 5bf0abcad..254943adb 100644 --- a/workspaces/docs-site/docs/release_notes/0_3.md +++ b/workspaces/docs-site/docs/release_notes/0_3.md @@ -95,6 +95,7 @@ This section is grouped by outcome rather than by every minimized repro. Issue n - **Release smoke paths are less fragile**: InQL and release smoke testing fixed loop-item fields, union call arguments, storage-rooted match scrutinees, static list index assignment, typed `assert false` lowering, and const model metadata constructors (#620, #621, #622, #627, #630, #644, #671, #674). - **Generic and trait flow keeps more type information**: Instantiated receivers, generic fields, generic methods, `list[Self]`, trait/supertrait upcasts, imported prost oneofs, explicit generic cycles, and static factory locals now survive typechecking and lowering more consistently (#237, #231, #253, #230, #184, #218, #279, #252, #255). - **Generic receiver methods inherit defaults**: Calls on instantiated generic classes and models use the same source default arguments as non-generic receiver calls instead of emitting too few Rust arguments (#731). +- **Assert comparisons share expression emission**: `assert_eq`, `assert_ne`, and assert-statement comparison desugaring now use the ordinary binary-operation plan, so string comparisons in loops follow the same borrowed/owned behavior as `if` expressions (#739). - **Runtime-boundary errors are clearer**: `std.regex` text borrowing, collection f-string formatting, and collection/string conversion diagnostics fail closer to the Incan source instead of surfacing as obscure Rust/runtime errors (#624, #625, #71, #81). - **Reflection is capability-backed**: Generic value reflection and explicit type-argument reflection infer the right generated Rust bounds, while bare model names in value position now fail at the Incan diagnostic layer instead of leaking Rust type paths (#712, #714, #715). - **Enum patterns use enum-owned metadata**: Qualified enum patterns now read variant payloads from the enum's semantic metadata instead of ambient variant symbols, keeping imported stdlib enums stable when project enums reuse variant names (#710).