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).