Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
83 changes: 64 additions & 19 deletions src/backend/ir/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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) =>
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions src/backend/ir/emit/expressions/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<str>`
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,
Expand All @@ -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<str>>::as_ref(&#emitted) }
} else {
emitted
};
Expand Down
4 changes: 2 additions & 2 deletions src/backend/ir/emit/expressions/methods/fast_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<str>>::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<str>>::as_ref(#emitted) },
_ => quote! { <_ as AsRef<str>>::as_ref(#emitted) },
},
_ => quote! { <_ as AsRef<str>>::as_ref(&#emitted) },
Expand Down
58 changes: 58 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
let dir = write_test_project(
Expand Down
2 changes: 1 addition & 1 deletion workspaces/docs-site/docs/release_notes/0_3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading