diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 16c3fc0..6699284 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ - + # NTNT Language - GitHub Copilot Instructions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef68a93..21df2a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: name: Test runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: @@ -39,6 +40,10 @@ jobs: - name: Run tests run: cargo test --locked + env: + # macOS aarch64 has smaller default thread stack (512KB vs 8MB on Linux). + # Increase to 8MB to prevent stack overflow in recursive interpreter tests. + RUST_MIN_STACK: 8388608 lint: name: Lint diff --git a/CLAUDE.md b/CLAUDE.md index b60064d..02d7d48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ - + # NTNT Language - Claude Code Instructions diff --git a/Cargo.lock b/Cargo.lock index acfc1bc..92be40e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "ntnt" -version = "0.4.0" +version = "0.4.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 17ecbae..ad91ff3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntnt" -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["NTNT Language Team"] description = "NTNT (Intent) - A programming language designed for AI-driven development" diff --git a/docs/IAL_REFERENCE.md b/docs/IAL_REFERENCE.md index 4ca37d8..9a02c2b 100644 --- a/docs/IAL_REFERENCE.md +++ b/docs/IAL_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [ial.toml](ial.toml)** - Do not edit directly. > -> Last updated: v0.4.0 +> Last updated: v0.4.1 IAL is a term rewriting engine that translates natural language assertions into executable tests diff --git a/docs/RUNTIME_REFERENCE.md b/docs/RUNTIME_REFERENCE.md index c7b4516..98799b3 100644 --- a/docs/RUNTIME_REFERENCE.md +++ b/docs/RUNTIME_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [runtime.toml](runtime.toml)** - Do not edit directly. > -> Last updated: v0.4.0 +> Last updated: v0.4.1 Runtime configuration, environment variables, and CLI commands for NTNT diff --git a/docs/STDLIB_REFERENCE.md b/docs/STDLIB_REFERENCE.md index 38e3a7f..c165b31 100644 --- a/docs/STDLIB_REFERENCE.md +++ b/docs/STDLIB_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from source code doc comments** - Do not edit directly. > -> Last updated: v0.4.0 +> Last updated: v0.4.1 ## Table of Contents diff --git a/docs/SYNTAX_REFERENCE.md b/docs/SYNTAX_REFERENCE.md index 42448ff..b42e624 100644 --- a/docs/SYNTAX_REFERENCE.md +++ b/docs/SYNTAX_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [syntax.toml](syntax.toml)** - Do not edit directly. > -> Last updated: v0.4.0 +> Last updated: v0.4.1 ## Table of Contents diff --git a/src/config.rs b/src/config.rs index 633f2b1..04af45b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,6 +47,7 @@ pub enum LintMode { Strict, } +#[cfg_attr(test, allow(dead_code))] fn read_type_mode_from_env() -> TypeMode { match std::env::var("NTNT_TYPE_MODE").as_deref().unwrap_or("warn") { "strict" => TypeMode::Strict, @@ -55,11 +56,13 @@ fn read_type_mode_from_env() -> TypeMode { } } -/// Get the current runtime type mode from the `NTNT_TYPE_MODE` env var. +/// Get the current runtime type mode. /// -/// Default is [`TypeMode::Warn`]. In production builds the result is cached on -/// first call; in test builds it reads fresh from the environment every call -/// so tests can manipulate `NTNT_TYPE_MODE` with per-test isolation. +/// Default is [`TypeMode::Warn`]. In production builds the result is read from +/// `NTNT_TYPE_MODE` env var and cached via `OnceLock`. In test builds, a +/// thread-local override is used instead of env vars (since `std::env::set_var` +/// is unsafe in multi-threaded contexts on Rust 1.83+). Use +/// [`set_test_type_mode`] to override in tests. #[cfg(not(test))] pub fn get_type_mode() -> TypeMode { use std::sync::OnceLock; @@ -69,9 +72,38 @@ pub fn get_type_mode() -> TypeMode { #[cfg(test)] pub fn get_type_mode() -> TypeMode { - read_type_mode_from_env() + // In tests, use thread-local override only (no env var reads). + // std::env::var is unsafe to call concurrently with set_var on macOS + // (Rust 1.83+ / POSIX getenv is not thread-safe on all platforms). + TYPE_MODE_OVERRIDE.with(|cell| (*cell.borrow()).unwrap_or(TypeMode::Warn)) } +thread_local! { + /// Thread-local override for TypeMode in tests. Avoids `std::env::set_var` + /// which is unsafe in multi-threaded contexts (Rust 1.83+). + static TYPE_MODE_OVERRIDE: RefCell> = const { RefCell::new(None) }; +} + +/// Set a thread-local TypeMode override for the current test. Returns a guard +/// that restores `None` on drop. Use instead of `std::env::set_var("NTNT_TYPE_MODE", ...)`. +#[cfg(test)] +pub fn set_test_type_mode(mode: TypeMode) -> TestTypeModeGuard { + TYPE_MODE_OVERRIDE.with(|cell| *cell.borrow_mut() = Some(mode)); + TestTypeModeGuard +} + +/// RAII guard that clears the thread-local TypeMode override on drop. +#[cfg(test)] +pub struct TestTypeModeGuard; + +#[cfg(test)] +impl Drop for TestTypeModeGuard { + fn drop(&mut self) { + TYPE_MODE_OVERRIDE.with(|cell| *cell.borrow_mut() = None); + } +} + +#[cfg_attr(test, allow(dead_code))] fn read_lint_mode_from_env() -> LintMode { match std::env::var("NTNT_LINT_MODE") .as_deref() @@ -83,11 +115,13 @@ fn read_lint_mode_from_env() -> LintMode { } } -/// Get the current lint mode from the `NTNT_LINT_MODE` env var. +/// Get the current lint mode. /// -/// Default is [`LintMode::Default`]. CLI flags take precedence over this value -/// (caller is responsible for applying the override). In production builds the -/// result is cached; in test builds it reads fresh each call. +/// Default is [`LintMode::Default`]. In production builds the result is read +/// from `NTNT_LINT_MODE` env var and cached via `OnceLock`. CLI flags take +/// precedence (caller applies the override). In test builds, always returns +/// `LintMode::Default` for thread safety (no env var reads). Add a thread-local +/// override similar to `TypeMode` if lint-mode testing is needed. #[cfg(not(test))] pub fn get_lint_mode() -> LintMode { use std::sync::OnceLock; @@ -97,7 +131,8 @@ pub fn get_lint_mode() -> LintMode { #[cfg(test)] pub fn get_lint_mode() -> LintMode { - read_lint_mode_from_env() + // In tests, always return Default (no env var reads for thread safety). + LintMode::Default } use std::cell::RefCell; diff --git a/src/interpreter.rs b/src/interpreter.rs index b3b3286..f948f55 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -11,7 +11,7 @@ //! - `result` to reference the return value in postconditions use crate::ast::*; -use crate::config::{get_type_mode, TypeMode}; +use crate::config::{get_type_mode, type_warn_dedup, TypeMode}; use crate::contracts::{ContractChecker, OldValues, StoredValue}; use crate::error::{IntentError, Result, TypeContext}; use std::cell::RefCell; @@ -507,6 +507,14 @@ fn is_production_mode() -> bool { /// - Field access on non-struct/map /// - Template expression/filter/for-loop errors /// +/// ## TypeMode-Aware (DD-009 Phase 4 — implicit coercion controls) +/// These are **implicit type coercions** that are gated by TypeMode: +/// - Mixed Int↔Float arithmetic (`3 + 2.5`) — Strict rejects, Warn logs, Forgiving silently promotes +/// - Non-String + String concatenation (`"hi" + 42`) — same three-tier behavior +/// - Non-Bool condition in `if`/`while` (`if 1 { ... }`) — same three-tier behavior +/// - Non-Bool operand for `!`, `&&`, `||` — same three-tier behavior +/// Note: mixed Int↔Float **comparisons** (`3 == 3.0`) are always allowed in all modes. +/// /// ## Hard Errors — Code Bugs (always crash, TypeMode does NOT apply) /// These indicate a bug in the source code, not a data mismatch: /// - **Arity**: wrong number of arguments to a function @@ -3204,7 +3212,8 @@ impl Interpreter { type_params: _, target, } => { - // Store type alias for later resolution + // Store the raw TypeExpr. The interpreter doesn't resolve type + // aliases at runtime — they're used by the type checker only. self.type_aliases.insert(name.clone(), target.clone()); Ok(Value::Unit) } @@ -3263,6 +3272,28 @@ impl Interpreter { else_branch, } => { let cond = self.eval_expression(condition)?; + // TypeMode gate (DD-009 Phase 4): Strict requires explicit Bool in if conditions. + // Warn logs a warning and continues; Forgiving uses is_truthy() silently. + if !matches!(cond, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean condition in if/while. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + cond.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_cond:{}", cond.type_name()), + &format!( + "Non-boolean condition in if/while. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + cond.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } if cond.is_truthy() { self.eval_block(then_branch) } else if let Some(else_b) = else_branch { @@ -3275,6 +3306,27 @@ impl Interpreter { Statement::While { condition, body } => { loop { let cond = self.eval_expression(condition)?; + // TypeMode gate (DD-009 Phase 4): Strict requires explicit Bool in while conditions. + if !matches!(cond, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean condition in if/while. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + cond.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_while:{}", cond.type_name()), + &format!( + "Non-boolean condition in if/while. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + cond.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } if !cond.is_truthy() { break; } @@ -3617,19 +3669,100 @@ impl Interpreter { let lhs = self.eval_expression(left)?; // Short-circuit evaluation for logical operators + // TypeMode gate (DD-009 Phase 4): Strict requires Bool operands for && and ||. match operator { BinaryOp::And => { + if !matches!(lhs, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean operand for &&. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + lhs.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_and_lhs:{}", lhs.type_name()), + &format!( + "Non-boolean operand for &&. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + lhs.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } if !lhs.is_truthy() { return Ok(Value::Bool(false)); } let rhs = self.eval_expression(right)?; + if !matches!(rhs, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean operand for &&. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + rhs.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_and_rhs:{}", rhs.type_name()), + &format!( + "Non-boolean operand for &&. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + rhs.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } return Ok(Value::Bool(rhs.is_truthy())); } BinaryOp::Or => { + if !matches!(lhs, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean operand for ||. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + lhs.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_or_lhs:{}", lhs.type_name()), + &format!( + "Non-boolean operand for ||. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + lhs.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } if lhs.is_truthy() { return Ok(Value::Bool(true)); } let rhs = self.eval_expression(right)?; + if !matches!(rhs, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean operand for ||. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + rhs.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_or_rhs:{}", rhs.type_name()), + &format!( + "Non-boolean operand for ||. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + rhs.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } return Ok(Value::Bool(rhs.is_truthy())); } BinaryOp::NullCoalesce => { @@ -3669,7 +3802,30 @@ impl Interpreter { "Cannot negate non-numeric value".to_string(), )), }, - UnaryOp::Not => Ok(Value::Bool(!val.is_truthy())), + UnaryOp::Not => { + // TypeMode gate (DD-009 Phase 4): Strict requires Bool operand for !. + if !matches!(val, Value::Bool(_)) { + match get_type_mode() { + TypeMode::Strict => { + return Err(IntentError::type_error(format!( + "Non-boolean operand for !. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + val.type_name() + ))); + } + TypeMode::Warn => { + type_warn_dedup( + &format!("non_bool_not:{}", val.type_name()), + &format!( + "Non-boolean operand for !. Got {}. Use explicit comparison (e.g., value != None, len(arr) > 0).", + val.type_name() + ), + ); + } + TypeMode::Forgiving => {} + } + } + Ok(Value::Bool(!val.is_truthy())) + } } } @@ -7366,22 +7522,132 @@ impl Interpreter { (BinaryOp::Mod, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a % b)), (BinaryOp::Pow, Value::Float(a), Value::Float(b)) => Ok(Value::Float(a.powf(b))), - // Mixed numeric - (BinaryOp::Add, Value::Int(a), Value::Float(b)) => Ok(Value::Float(a as f64 + b)), - (BinaryOp::Add, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a + b as f64)), - (BinaryOp::Sub, Value::Int(a), Value::Float(b)) => Ok(Value::Float(a as f64 - b)), - (BinaryOp::Sub, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a - b as f64)), - (BinaryOp::Mul, Value::Int(a), Value::Float(b)) => Ok(Value::Float(a as f64 * b)), - (BinaryOp::Mul, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a * b as f64)), - (BinaryOp::Div, Value::Int(a), Value::Float(b)) => Ok(Value::Float(a as f64 / b)), - (BinaryOp::Div, Value::Float(a), Value::Int(b)) => Ok(Value::Float(a / b as f64)), + // Mixed numeric arithmetic (Int ↔ Float implicit promotion) + // TypeMode gate (DD-009 Phase 4): Strict rejects implicit promotion, Warn logs it, + // Forgiving silently promotes (legacy behaviour). Note: mixed COMPARISONS (Eq, Ne, + // Lt, Le, Gt, Ge) are NOT gated — comparing 3 == 3.0 is always valid. + (BinaryOp::Add, Value::Int(a), Value::Float(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:add", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a as f64 + b)) + } + TypeMode::Forgiving => Ok(Value::Float(a as f64 + b)), + }, + (BinaryOp::Add, Value::Float(a), Value::Int(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:add_r", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a + b as f64)) + } + TypeMode::Forgiving => Ok(Value::Float(a + b as f64)), + }, + (BinaryOp::Sub, Value::Int(a), Value::Float(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:sub", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a as f64 - b)) + } + TypeMode::Forgiving => Ok(Value::Float(a as f64 - b)), + }, + (BinaryOp::Sub, Value::Float(a), Value::Int(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:sub_r", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a - b as f64)) + } + TypeMode::Forgiving => Ok(Value::Float(a - b as f64)), + }, + (BinaryOp::Mul, Value::Int(a), Value::Float(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:mul", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a as f64 * b)) + } + TypeMode::Forgiving => Ok(Value::Float(a as f64 * b)), + }, + (BinaryOp::Mul, Value::Float(a), Value::Int(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:mul_r", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a * b as f64)) + } + TypeMode::Forgiving => Ok(Value::Float(a * b as f64)), + }, + (BinaryOp::Div, Value::Int(a), Value::Float(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:div", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a as f64 / b)) + } + TypeMode::Forgiving => Ok(Value::Float(a as f64 / b)), + }, + (BinaryOp::Div, Value::Float(a), Value::Int(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error( + "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion.".to_string(), + )), + TypeMode::Warn => { + type_warn_dedup("implicit_int_float:div_r", "Implicit Int\u{2192}Float promotion in arithmetic. Use float(intVal) for explicit conversion."); + Ok(Value::Float(a / b as f64)) + } + TypeMode::Forgiving => Ok(Value::Float(a / b as f64)), + }, // String concatenation + // String+String always works in all modes — this is unambiguous. + // Non-String + String or String + Non-String is gated by TypeMode (DD-009 Phase 4): + // Strict rejects implicit coercion, Warn logs it, Forgiving silently coerces. (BinaryOp::Add, Value::String(a), Value::String(b)) => { Ok(Value::String(format!("{}{}", a, b))) } - (BinaryOp::Add, Value::String(a), b) => Ok(Value::String(format!("{}{}", a, b))), - (BinaryOp::Add, a, Value::String(b)) => Ok(Value::String(format!("{}{}", a, b))), + (BinaryOp::Add, Value::String(a), b) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error(format!( + "Implicit conversion of {} to String in concatenation. Use str(value) explicitly.", + b.type_name() + ))), + TypeMode::Warn => { + type_warn_dedup( + &format!("implicit_str_concat:rhs:{}", b.type_name()), + &format!( + "Implicit conversion of {} to String in concatenation. Use str(value) explicitly.", + b.type_name() + ), + ); + Ok(Value::String(format!("{}{}", a, b))) + } + TypeMode::Forgiving => Ok(Value::String(format!("{}{}", a, b))), + }, + (BinaryOp::Add, a, Value::String(b)) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::type_error(format!( + "Implicit conversion of {} to String in concatenation. Use str(value) explicitly.", + a.type_name() + ))), + TypeMode::Warn => { + type_warn_dedup( + &format!("implicit_str_concat:lhs:{}", a.type_name()), + &format!( + "Implicit conversion of {} to String in concatenation. Use str(value) explicitly.", + a.type_name() + ), + ); + Ok(Value::String(format!("{}{}", a, b))) + } + TypeMode::Forgiving => Ok(Value::String(format!("{}{}", a, b))), + }, // Array concatenation (BinaryOp::Add, Value::Array(mut a), Value::Array(b)) => { @@ -9485,8 +9751,8 @@ c") #[test] fn test_for_in_string_skips() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // for..in on a string now yields zero iterations (use chars() instead) let result = eval( r#" @@ -10841,8 +11107,8 @@ c") #[test] fn test_for_in_int_skips() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // for..in on an int should yield zero iterations, not crash let result = eval( r#" @@ -10859,8 +11125,8 @@ c") #[test] fn test_for_in_none_skips() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // for..in on None should yield zero iterations, not crash let result = eval( r#" @@ -10877,8 +11143,8 @@ c") #[test] fn test_for_in_bool_skips() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // for..in on a bool should yield zero iterations let result = eval( r#" @@ -10968,8 +11234,8 @@ c") #[test] fn test_index_string_with_string_key_returns_none() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // string["key"] should return None, not TypeError let result = eval(r#"let s = "hello"; s["key"]"#).unwrap(); assert!(matches!( @@ -10983,8 +11249,8 @@ c") #[test] fn test_index_int_with_string_key_returns_none() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // 42["key"] should return None, not TypeError let result = eval(r#"42["key"]"#).unwrap(); assert!(matches!( @@ -10998,8 +11264,8 @@ c") #[test] fn test_index_none_with_string_key_returns_none() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // None["key"] should return None, not TypeError let result = eval(r#"let x = None; x["key"]"#).unwrap(); assert!(matches!( @@ -11013,8 +11279,8 @@ c") #[test] fn test_index_array_out_of_bounds_returns_none() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // [1,2,3][99] should return None, not IndexOutOfBounds let result = eval(r#"[1, 2, 3][99]"#).unwrap(); assert!(matches!( @@ -11028,8 +11294,8 @@ c") #[test] fn test_index_array_negative_out_of_bounds_returns_none() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // [1,2,3][-99] should return None, not IndexOutOfBounds let result = eval(r#"[1, 2, 3][-99]"#).unwrap(); assert!(matches!( @@ -11043,8 +11309,8 @@ c") #[test] fn test_index_string_char_out_of_bounds_returns_none() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // "hi"[99] should return None, not IndexOutOfBounds let result = eval(r#""hi"[99]"#).unwrap(); assert!(matches!( @@ -11058,8 +11324,8 @@ c") #[test] fn test_index_type_mismatch_with_null_coalescing() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // string["key"] ?? "fallback" should return "fallback" let result = eval(r#"let s = "hello"; s["key"] ?? "fallback""#).unwrap(); assert!(matches!(result, Value::String(ref s) if s == "fallback")); @@ -11087,6 +11353,9 @@ c") fn test_template_error_boundary_expr_no_crash() { // Template with an expression that would error should not crash // undefined_fn() doesn't exist, but template should render gracefully + // Must hold TYPE_MODE_MUTEX — strict mode would crash instead of degrade. + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); let code = r#####" let page = """before{{undefined_fn()}}after""" page @@ -11105,6 +11374,9 @@ page #[test] fn test_template_error_boundary_if_treats_error_as_false() { // {{#if bad_expr}} should treat error as false, not crash + // Must hold TYPE_MODE_MUTEX — strict mode would crash instead of degrade. + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); let code = r#####" let page = """{{#if undefined_fn()}}shown{{#else}}hidden{{/if}}""" page @@ -11131,6 +11403,10 @@ page #[test] fn test_template_error_boundary_for_treats_error_as_empty() { // {{#for x in bad_expr}} should iterate zero times, not crash + // Must hold TYPE_MODE_MUTEX because this test depends on non-strict + // runtime behaviour; concurrent strict-mode tests would make it fail. + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); let code = r#####" let page = """before{{#for x in undefined_fn()}}item{{/for}}after""" page @@ -11220,10 +11496,12 @@ page #[test] fn test_recursion_depth_resets() { - // After a deep call returns, depth resets so another deep call works + // After a deep call returns, depth resets so another deep call works. + // Keep depth small (3) to avoid real stack overflow on platforms with + // small default thread stacks (macOS aarch64 CI). let result = eval_with_recursion_limit( - "fn deep(n) { if n <= 0 { return 0 } return deep(n - 1) } deep(8); deep(8)", - 10, + "fn deep(n) { if n <= 0 { return 0 } return deep(n - 1) } deep(3); deep(3)", + 5, ); assert!(result.is_ok(), "Depth should reset between calls"); } @@ -11443,30 +11721,14 @@ page use std::sync::Mutex; static TYPE_MODE_MUTEX: Mutex<()> = Mutex::new(()); - struct EnvGuard { - key: &'static str, - prev: Option, - } - impl EnvGuard { - fn set(key: &'static str, value: &str) -> Self { - let prev = std::env::var(key).ok(); - std::env::set_var(key, value); - Self { key, prev } - } - } - impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.prev { - Some(v) => std::env::set_var(self.key, v), - None => std::env::remove_var(self.key), - } - } - } + // EnvGuard removed — replaced by crate::config::set_test_type_mode() + // which uses a thread-local override instead of std::env::set_var + // (unsafe in multi-threaded contexts since Rust 1.83). #[test] fn test_strict_mode_crashes_on_type_mismatch() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "strict"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); // Indexing an Int with a String key — strict mode should return RuntimeError let result = eval(r#"let x = 42; x["key"]"#); assert!( @@ -11486,8 +11748,8 @@ page #[test] fn test_warn_mode_logs_and_continues() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "warn"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); // Indexing an Int with a String key — warn mode returns None, no crash let result = eval(r#"let x = 42; x["key"]"#); assert!( @@ -11506,8 +11768,8 @@ page #[test] fn test_forgiving_mode_silent() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); // Forgiving mode: same None result, no warnings (we can't capture stderr here // but we verify no panic and correct return value) let result = eval(r#"let x = 42; x["key"]"#); @@ -11526,8 +11788,8 @@ page #[test] fn test_for_in_strict_crashes() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "strict"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); // for..in on an Int — strict mode should return RuntimeError let result = eval( r#" @@ -11553,8 +11815,8 @@ page #[test] fn test_for_in_warn_skips() { - let _lock = TYPE_MODE_MUTEX.lock().unwrap(); - let _guard = EnvGuard::set("NTNT_TYPE_MODE", "warn"); + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); // for..in on an Int — warn mode skips the loop body, count stays 0 let result = eval( r#" @@ -11575,4 +11837,184 @@ page "warn mode: for..in on Int should skip loop body (count should be 0)" ); } + + // DD-009 Phase 4: Type coercion control tests + + #[test] + fn test_strict_rejects_implicit_int_float_promotion() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + let result = eval("3 + 2.5"); + assert!( + result.is_err(), + "strict mode: Int + Float should return Err, got {:?}", + result + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Implicit") || msg.contains("promotion") || msg.contains("Float"), + "error should mention implicit promotion, got: {}", + msg + ); + } + + #[test] + fn test_warn_logs_implicit_int_float_promotion() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); + let result = eval("3 + 2.5"); + assert!( + result.is_ok(), + "warn mode: Int + Float should succeed, got {:?}", + result + ); + match result.unwrap() { + Value::Float(f) => assert!((f - 5.5).abs() < 1e-10, "expected 5.5, got {}", f), + other => panic!("expected Float(5.5), got {:?}", other), + } + } + + #[test] + fn test_forgiving_allows_implicit_int_float_promotion() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Forgiving); + let result = eval("3 + 2.5"); + assert!( + result.is_ok(), + "forgiving mode: Int + Float should succeed, got {:?}", + result + ); + match result.unwrap() { + Value::Float(f) => assert!((f - 5.5).abs() < 1e-10, "expected 5.5, got {}", f), + other => panic!("expected Float(5.5), got {:?}", other), + } + } + + #[test] + fn test_strict_rejects_implicit_string_concat() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + let result = eval(r#""hello" + 42"#); + assert!( + result.is_err(), + "strict mode: String + Int should return Err, got {:?}", + result + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Implicit") || msg.contains("conversion") || msg.contains("String"), + "error should mention implicit conversion, got: {}", + msg + ); + } + + #[test] + fn test_warn_allows_implicit_string_concat() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Warn); + let result = eval(r#""hello" + 42"#); + assert!( + result.is_ok(), + "warn mode: String + Int should succeed, got {:?}", + result + ); + match result.unwrap() { + Value::String(s) => assert_eq!(s, "hello42", "expected 'hello42', got '{}'", s), + other => panic!("expected String('hello42'), got {:?}", other), + } + } + + #[test] + fn test_strict_rejects_non_bool_if_condition() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + let result = eval(r#"if 1 { "yes" } else { "no" }"#); + assert!( + result.is_err(), + "strict mode: if with Int condition should return Err, got {:?}", + result + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Non-boolean") || msg.contains("boolean") || msg.contains("Bool"), + "error should mention non-boolean condition, got: {}", + msg + ); + } + + #[test] + fn test_strict_allows_bool_if_condition() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + let result = eval(r#"if true { "yes" } else { "no" }"#); + assert!( + result.is_ok(), + "strict mode: if with Bool condition should succeed, got {:?}", + result + ); + match result.unwrap() { + Value::String(s) => assert_eq!(s, "yes", "expected 'yes', got '{}'", s), + other => panic!("expected String('yes'), got {:?}", other), + } + } + + #[test] + fn test_strict_rejects_non_bool_while() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + let result = eval( + r#" + let mut x = 1 + while x { + break + } + x + "#, + ); + assert!( + result.is_err(), + "strict mode: while with Int condition should return Err, got {:?}", + result + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Non-boolean") || msg.contains("boolean") || msg.contains("Bool"), + "error should mention non-boolean condition, got: {}", + msg + ); + } + + #[test] + fn test_mixed_numeric_comparison_always_works() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + // Mixed Int↔Float comparisons must always work in all modes + let result = eval("3 == 3.0"); + assert!( + result.is_ok(), + "strict mode: Int == Float comparison should always succeed, got {:?}", + result + ); + match result.unwrap() { + Value::Bool(true) => {} + other => panic!("expected Bool(true), got {:?}", other), + } + } + + #[test] + fn test_string_string_concat_always_works() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::config::set_test_type_mode(crate::config::TypeMode::Strict); + // String + String must always work in all modes + let result = eval(r#""a" + "b""#); + assert!( + result.is_ok(), + "strict mode: String + String should always succeed, got {:?}", + result + ); + match result.unwrap() { + Value::String(s) => assert_eq!(s, "ab", "expected 'ab', got '{}'", s), + other => panic!("expected String('ab'), got {:?}", other), + } + } } diff --git a/src/typechecker.rs b/src/typechecker.rs index 9a2389b..38e4d7c 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -63,6 +63,7 @@ struct FileExports { structs: HashMap>, enums: HashMap>)>>, type_aliases: HashMap, + struct_type_params: HashMap>, } /// Type checking context with scoped variable bindings @@ -77,6 +78,8 @@ pub struct TypeContext { enums: HashMap>)>>, /// Type aliases type_aliases: HashMap, + /// Generic struct type parameters: struct_name -> [param_names] + struct_type_params: HashMap>, /// Builtin and stdlib function signatures builtin_sigs: HashMap, /// Return type of current function being checked @@ -357,6 +360,7 @@ impl TypeContext { structs: HashMap::new(), enums: HashMap::new(), type_aliases: HashMap::new(), + struct_type_params: HashMap::new(), builtin_sigs: HashMap::new(), current_return_type: None, collected_returns: Vec::new(), @@ -667,9 +671,14 @@ impl TypeContext { Statement::Struct { name, fields, - type_params: _, + type_params, .. } => { + // Store type parameter names for generic structs (e.g., struct Pair) + let tp_names: Vec = type_params.iter().map(|t| t.name.clone()).collect(); + if !tp_names.is_empty() { + self.struct_type_params.insert(name.clone(), tp_names); + } let field_types: Vec<(String, Type)> = fields .iter() .map(|f| (f.name.clone(), self.resolve_type_expr(&f.type_annotation))) @@ -699,6 +708,10 @@ impl TypeContext { target, type_params: _, } => { + // Insert a placeholder first so self-references resolve to Type::Named(name) + // rather than Type::Any during resolution (supports recursive type aliases). + self.type_aliases + .insert(name.clone(), Type::Named(name.clone())); let resolved = self.resolve_type_expr(target); self.type_aliases.insert(name.clone(), resolved); } @@ -1624,6 +1637,24 @@ impl TypeContext { } Type::Any } + // Generic struct field access with type param substitution + Type::Generic { name, args } => { + if let Some(struct_fields) = self.structs.get(name).cloned() { + if let Some(tp_names) = self.struct_type_params.get(name).cloned() { + let bindings: HashMap = tp_names + .iter() + .zip(args.iter()) + .map(|(n, t)| (n.clone(), t.clone())) + .collect(); + for (fname, ftype) in &struct_fields { + if fname == field { + return Self::substitute_type_params(ftype, &bindings); + } + } + } + } + Type::Any + } // Map field access: map.field is syntactic sugar for map["field"] Type::Map { value_type, .. } => (**value_type).clone(), _ => Type::Any, @@ -1723,10 +1754,48 @@ impl TypeContext { Expression::StructLiteral { name, fields } => { // Check field types match struct definition if let Some(struct_fields) = self.structs.get(name).cloned() { + let tp_names = self + .struct_type_params + .get(name) + .cloned() + .unwrap_or_default(); + // For generic structs, collect type param bindings from field values + let mut bindings: HashMap = HashMap::new(); for (fname, fexpr) in fields { let actual = self.infer_expression(fexpr); if let Some((_, expected)) = struct_fields.iter().find(|(n, _)| n == fname) { + // If expected type is a generic type param, record/check the binding + if let Type::Named(tp_name) = expected { + if tp_names.contains(tp_name) { + if let Some(bound) = bindings.get(tp_name) { + // A binding for this type param already exists; + // ensure it is compatible (e.g., struct Pair { a: A, b: A } + // with { a: 1, b: "x" } should error) + if !self.compatible(&actual, bound) + && !matches!(actual, Type::Any) + && !matches!(bound, Type::Any) + { + let line = self.find_line_near(name); + self.error( + format!( + "In struct '{}', generic type parameter '{}' has incompatible bindings: {} and {}", + name, + tp_name, + bound.name(), + actual.name() + ), + line, + None, + ); + } + } else { + bindings.insert(tp_name.clone(), actual.clone()); + } + // Skip further field-vs-struct compatibility check + continue; + } + } if !self.compatible(&actual, expected) && !matches!(actual, Type::Any) { let line = self.find_line_near(name); self.error( @@ -1743,6 +1812,17 @@ impl TypeContext { } } } + // Return Generic type with resolved type args so field access can infer types + if !tp_names.is_empty() { + let args: Vec = tp_names + .iter() + .map(|n| bindings.get(n).cloned().unwrap_or(Type::Any)) + .collect(); + return Type::Generic { + name: name.clone(), + args, + }; + } } Type::Named(name.clone()) } @@ -2849,6 +2929,7 @@ impl TypeContext { structs: temp_ctx.structs.clone(), enums: temp_ctx.enums.clone(), type_aliases: temp_ctx.type_aliases.clone(), + struct_type_params: temp_ctx.struct_type_params.clone(), }; temp_ctx .module_cache @@ -2866,6 +2947,7 @@ impl TypeContext { structs: temp_ctx.structs, enums: temp_ctx.enums, type_aliases: temp_ctx.type_aliases, + struct_type_params: temp_ctx.struct_type_params, }; // Update cache with Pass 2 results @@ -2911,6 +2993,11 @@ impl TypeContext { self.builtin_sigs.insert(local_name.clone(), sig.clone()); } else if let Some(fields) = exports.structs.get(&item.name) { self.structs.insert(local_name.clone(), fields.clone()); + // Also import generic type params if the struct has them + if let Some(tp) = exports.struct_type_params.get(&item.name) { + self.struct_type_params + .insert(local_name.clone(), tp.clone()); + } } else if let Some(variants) = exports.enums.get(&item.name) { self.enums.insert(local_name.clone(), variants.clone()); } else if let Some(typ) = exports.type_aliases.get(&item.name) { @@ -6444,4 +6531,122 @@ let n: Int = double(5)"#; errors ); } + + // ── Recursive type aliases (DD-009 Phase 7.2) ─────────────── + + #[test] + fn test_recursive_type_alias_typechecks() { + // A recursive type alias should not produce errors — self-references + // resolve to Type::Named("JsonValue") via the placeholder mechanism. + let errors = check_errors( + r#" + type JsonValue = String | Int | Float | Bool | [JsonValue] | Map + fn process(v: JsonValue) -> String { + return str(v) + } + process("hello") + process(42) + process(3.14) + process(true) + "#, + ); + assert!( + errors.is_empty(), + "Recursive type alias should type-check without errors: {:?}", + errors + ); + } + + #[test] + fn test_recursive_type_alias_no_infinite_loop() { + // Ensure collecting a recursive alias doesn't hang/panic + let diags = check( + r#" + type Tree = String | [Tree] + let x: Tree = "leaf" + "#, + ); + // We only care that this completes (no panic), diagnostics may vary + let _ = diags; + } + + // ── Generic struct support (DD-009 Phase 7.4) ──────────────── + + #[test] + fn test_generic_struct_declaration() { + // Declaring a generic struct should not produce errors + let errors = check_errors( + r#" + struct Pair { + first: A, + second: B, + } + "#, + ); + assert!( + errors.is_empty(), + "Generic struct declaration should not produce errors: {:?}", + errors + ); + } + + #[test] + fn test_generic_struct_construction() { + // Constructing a generic struct with concrete types should not error + let errors = check_errors( + r#" + struct Pair { + first: A, + second: B, + } + let p = Pair { first: 42, second: "hello" } + "#, + ); + assert!( + errors.is_empty(), + "Generic struct construction should not produce errors: {:?}", + errors + ); + } + + #[test] + fn test_generic_struct_field_inference() { + // Field access on a generic struct should return the inferred concrete type + let errors = check_errors( + r#" + struct Pair { + first: A, + second: B, + } + let p = Pair { first: 42, second: "hello" } + let x: Int = p.first + let y: String = p.second + "#, + ); + assert!( + errors.is_empty(), + "Generic struct field access should infer concrete types: {:?}", + errors + ); + } + + #[test] + fn test_generic_struct_field_type_mismatch() { + // Annotating a generic struct field with the wrong concrete type should error + let errors = check_errors( + r#" + struct Pair { + first: A, + second: B, + } + let p = Pair { first: 42, second: "hello" } + let x: String = p.first + "#, + ); + assert!( + !errors.is_empty(), + "Assigning Int field to String variable should produce a type error: {:?}", + errors + ); + } }