diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6699284..7123407 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ - + # NTNT Language - GitHub Copilot Instructions @@ -577,7 +577,7 @@ let x = if true { 1 } // ERROR: If-expressions require an else branch ### 11. Destructuring Assignment -Map, array, and nested destructuring in `let` bindings, `match`, and `for` loops: +Map, array, and nested destructuring in `let` bindings, `match`, `for` loops, and function parameters: ```ntnt // Map destructuring @@ -615,6 +615,20 @@ match data { { name, age } => print("{name} is {age}"), _ => print("no match") } + +// Function parameter destructuring +fn greet({ name, email }) { + print("Hello {name} ({email})") +} + +fn first_two([a, b, ...rest]) { + print("{a}, {b}") +} + +// With type annotation +fn process({ name, email }: Map) { + print("{name}: {email}") +} ``` ### 12. Regex Capture Groups @@ -1050,6 +1064,7 @@ The type checker tracks types through common operations: - **`parse_csv()`** — returns `Array>` - **Match arm narrowing** — `Ok(data)` on `Result` binds `data` as `T`; `Some(x)` on `Option` binds `x` as `T`; struct patterns bind field types - **Cross-file imports** — `import { foo } from "./lib/utils"` resolves function signatures from the imported `.tnt` file +- **Circular import detection** — if files form an import cycle (e.g. `a.tnt → b.tnt → a.tnt`), a warning is emitted showing the exact chain ### What the Type Checker Catches diff --git a/CLAUDE.md b/CLAUDE.md index 02d7d48..e5a9171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ - + # NTNT Language - Claude Code Instructions @@ -597,7 +597,7 @@ let x = if true { 1 } // ERROR: If-expressions require an else branch ### 11. Destructuring Assignment -Map, array, and nested destructuring in `let` bindings, `match`, and `for` loops: +Map, array, and nested destructuring in `let` bindings, `match`, `for` loops, and function parameters: ```ntnt // Map destructuring @@ -635,6 +635,20 @@ match data { { name, age } => print("{name} is {age}"), _ => print("no match") } + +// Function parameter destructuring +fn greet({ name, email }) { + print("Hello {name} ({email})") +} + +fn first_two([a, b, ...rest]) { + print("{a}, {b}") +} + +// With type annotation +fn process({ name, email }: Map) { + print("{name}: {email}") +} ``` ### 12. Regex Capture Groups @@ -1070,6 +1084,7 @@ The type checker tracks types through common operations: - **`parse_csv()`** — returns `Array>` - **Match arm narrowing** — `Ok(data)` on `Result` binds `data` as `T`; `Some(x)` on `Option` binds `x` as `T`; struct patterns bind field types - **Cross-file imports** — `import { foo } from "./lib/utils"` resolves function signatures from the imported `.tnt` file +- **Circular import detection** — if files form an import cycle (e.g. `a.tnt → b.tnt → a.tnt`), a warning is emitted showing the exact chain ### What the Type Checker Catches diff --git a/ROADMAP.md b/ROADMAP.md index fa0d298..c92f685 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -519,7 +519,7 @@ fn divide(a: Int, b: Int) -> Int - [x] Union type soundness: union VALUES require ALL members compatible with target, union TARGETS require ANY member match - [x] Union type flattening and deduplication in `union_type()` computation - [x] Block divergence analysis (`block_diverges`) for otherwise/lambda validation -- [x] `otherwise` block divergence warning (must end with return/break/continue) +- [x] `otherwise` block divergence enforcement (lint error, not just warning — must end with return/break/continue) - [x] Lambda return type inference via `collected_returns` (early returns included in union) - [x] Flow-sensitive type narrowing after guards (`if x == None { return }` narrows `x` to inner type) - [x] Narrowing patterns: `x == None`, `x != None`, `is_some(x)`, `is_none(x)`, `is_ok(x)`, `is_err(x)`, `!cond` @@ -531,6 +531,7 @@ fn divide(a: Int, b: Int) -> Int - [x] Variadic functions check minimum argument count - [x] Cross-file Pass 2 inference: unannotated exported functions get inferred return types - [x] Circular import safety: shared module cache prevents infinite recursion during cross-file analysis +- [x] Circular import diagnostics: cycle chain shown in warnings (e.g. `a.tnt → b.tnt → a.tnt`) - [x] Strict mode: string interpolation warning for complex types (Array, Map, Function) - [x] Strict mode: Float→Int precision loss warning - [ ] Cross-file struct/enum propagation (extend import type resolution to include struct and enum definitions) @@ -639,7 +640,7 @@ for line in lines { - [x] Works with `Option` — unwrap Some or run otherwise on None - [x] Verify otherwise block diverges (return/break/continue) - [x] Type checker unwraps `Result` → `T` and `Option` → `T` for let with otherwise -- [ ] Lint integration: warn if otherwise block doesn't diverge +- [x] Lint integration: error if otherwise block doesn't diverge ### 7.3 Anonymous Functions / Closures ✅ @@ -865,7 +866,7 @@ fn create_user({ name, email }: Map) -> User { - [x] Rest patterns: `let [head, ...tail] = arr`, `let { name, ...other } = map` - [x] Nested destructuring: `let { user: { name } } = data` - [ ] Destructuring with type annotations -- [ ] Destructuring in function parameters +- [x] Destructuring in function parameters - [x] Destructuring in `for` loops: `for { name, email } in users { ... }` - [x] Type checking: destructured fields are type-inferred from the source expression - [x] Map destructuring with rename: `let { name: n } = data` diff --git a/docs/AI_AGENT_GUIDE.md b/docs/AI_AGENT_GUIDE.md index 10a7935..32e87e6 100644 --- a/docs/AI_AGENT_GUIDE.md +++ b/docs/AI_AGENT_GUIDE.md @@ -570,7 +570,7 @@ let x = if true { 1 } // ERROR: If-expressions require an else branch ### 11. Destructuring Assignment -Map, array, and nested destructuring in `let` bindings, `match`, and `for` loops: +Map, array, and nested destructuring in `let` bindings, `match`, `for` loops, and function parameters: ```ntnt // Map destructuring @@ -608,6 +608,20 @@ match data { { name, age } => print("{name} is {age}"), _ => print("no match") } + +// Function parameter destructuring +fn greet({ name, email }) { + print("Hello {name} ({email})") +} + +fn first_two([a, b, ...rest]) { + print("{a}, {b}") +} + +// With type annotation +fn process({ name, email }: Map) { + print("{name}: {email}") +} ``` ### 12. Regex Capture Groups @@ -1043,6 +1057,7 @@ The type checker tracks types through common operations: - **`parse_csv()`** — returns `Array>` - **Match arm narrowing** — `Ok(data)` on `Result` binds `data` as `T`; `Some(x)` on `Option` binds `x` as `T`; struct patterns bind field types - **Cross-file imports** — `import { foo } from "./lib/utils"` resolves function signatures from the imported `.tnt` file +- **Circular import detection** — if files form an import cycle (e.g. `a.tnt → b.tnt → a.tnt`), a warning is emitted showing the exact chain ### What the Type Checker Catches diff --git a/src/ast.rs b/src/ast.rs index 4798609..113707d 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -377,6 +377,8 @@ pub struct Parameter { pub name: String, pub type_annotation: Option, pub default: Option, + #[serde(default)] + pub pattern: Option, } /// Import item for selective imports: `import { foo as bar } from "module"` diff --git a/src/interpreter.rs b/src/interpreter.rs index f948f55..83d5973 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -6339,7 +6339,12 @@ impl Interpreter { // Should not reach here due to arity check above Value::Unit }; - func_env.borrow_mut().define(param.name.clone(), value); + if let Some(ref pat) = param.pattern { + // Destructured param: only bind pattern variables, not the synthetic name + self.bind_pattern(pat, &value)?; + } else { + func_env.borrow_mut().define(param.name.clone(), value); + } } // Environment is already set to func_env for contract checking and body execution @@ -12017,4 +12022,69 @@ page other => panic!("expected String('ab'), got {:?}", other), } } + + #[test] + fn test_destructured_map_param() { + let result = eval( + r#" + fn greet({ name, email }) { + return name + " <" + email + ">" + } + greet(map { "name": "Alice", "email": "a@b.com" }) + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "Alice "), + other => panic!("expected String, got {:?}", other), + } + } + + #[test] + fn test_destructured_map_param_with_type() { + let result = eval( + r#" + fn greet({ name, email }: Map) -> String { + return name + " <" + email + ">" + } + greet(map { "name": "Bob", "email": "b@b.com" }) + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "Bob "), + other => panic!("expected String, got {:?}", other), + } + } + + #[test] + fn test_destructured_array_param() { + let result = eval( + r#" + fn first_two([a, b, ...rest]) { + return a + b + } + first_two([10, 20, 30]) + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Int(30))); + } + + #[test] + fn test_destructured_param_with_regular_params() { + let result = eval( + r#" + fn process(id, { name }) { + return str(id) + ": " + name + } + process(42, map { "name": "Alice" }) + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "42: Alice"), + other => panic!("expected String, got {:?}", other), + } + } } diff --git a/src/parser.rs b/src/parser.rs index 2f3e67b..67b1b31 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -307,7 +307,15 @@ impl Parser { if !self.check(&TokenKind::RightParen) { loop { - let name = self.consume_identifier("Expected parameter name")?; + let (name, pattern) = + if self.check(&TokenKind::LeftBrace) || self.check(&TokenKind::LeftBracket) { + let pat = self.parse_pattern()?; + let synth_name = format!("_destructure_{}", params.len()); + (synth_name, Some(pat)) + } else { + let name = self.consume_identifier("Expected parameter name")?; + (name, None) + }; let type_annotation = if self.match_token(&[TokenKind::Colon]) { Some(self.parse_type()?) @@ -325,6 +333,7 @@ impl Parser { name, type_annotation, default, + pattern, }); if !self.match_token(&[TokenKind::Comma]) { @@ -522,6 +531,7 @@ impl Parser { name: param_name, type_annotation, default: None, + pattern: None, }); } diff --git a/src/typechecker.rs b/src/typechecker.rs index 38e4d7c..ce04bcd 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -100,6 +100,8 @@ pub struct TypeContext { module_cache: HashMap, /// Set of files currently being resolved (for circular import detection) resolving_files: Vec, + /// Detected circular import cycles (accumulated from nested contexts) + detected_cycles: Vec, } /// Returns true if NTNT_STRICT mode is enabled. @@ -233,6 +235,31 @@ fn check_program_with_options( ctx.check_statement(stmt); } + // Emit diagnostics for any circular imports detected during resolution. + // Deduplicate: the same cycle may be detected from multiple nested contexts. + let mut seen_cycles = std::collections::HashSet::new(); + for cycle_msg in std::mem::take(&mut ctx.detected_cycles) { + if seen_cycles.insert(cycle_msg.clone()) { + // Try to find the import line that participates in the cycle + let line = ctx + .source_lines + .iter() + .position(|l| { + let trimmed = l.trim(); + trimmed.starts_with("import ") + && cycle_msg.lines().next().unwrap_or("").contains( + &std::path::Path::new(trimmed.split('"').nth(1).unwrap_or("")) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + ) + }) + .map(|i| i + 1) + .unwrap_or(0); + ctx.warning(cycle_msg, line, None); + } + } + ctx.diagnostics } @@ -371,6 +398,7 @@ impl TypeContext { current_file: None, module_cache: HashMap::new(), resolving_files: Vec::new(), + detected_cycles: Vec::new(), } } @@ -744,10 +772,14 @@ impl TypeContext { // When otherwise is present, unwrap Result -> T or Option -> T let inferred = if let Some(otherwise_block) = otherwise { - // Check that the otherwise block diverges + // Check that the otherwise block diverges. + // This is an error, not a warning: non-diverging otherwise blocks + // always crash at runtime ("otherwise block must diverge"), so + // catching this at lint time prevents production outages. + // See Finding #76: production outage from silent runtime error. if !self.block_diverges(otherwise_block) { let line = self.find_line_near("otherwise"); - self.warning( + self.error( "otherwise block does not diverge — it must end with return, break, or continue".to_string(), line, Some("Add a return, break, or continue statement".to_string()), @@ -838,7 +870,7 @@ impl TypeContext { if self.strict_lint { let fn_line = self.find_line_near(&format!("fn {}", name)); for param in params { - if param.type_annotation.is_none() { + if param.type_annotation.is_none() && param.pattern.is_none() { self.emit_with_kind( Severity::Warning, DiagnosticKind::MissingParamAnnotation, @@ -869,7 +901,12 @@ impl TypeContext { .as_ref() .map(|t| self.resolve_type_expr(t)) .unwrap_or(Type::Any); - self.bind(¶m.name, typ); + if let Some(ref pat) = param.pattern { + // Destructured param: only bind pattern variables, not the synthetic name + self.bind_pattern(pat, &typ); + } else { + self.bind(¶m.name, typ); + } } // Set expected return type @@ -1941,7 +1978,7 @@ impl TypeContext { // Strict lint: warn about untyped lambda parameters if self.strict_lint { for param in params { - if param.type_annotation.is_none() { + if param.type_annotation.is_none() && param.pattern.is_none() { let line = self.find_line_near(¶m.name); self.emit_with_kind( Severity::Warning, @@ -1966,6 +2003,9 @@ impl TypeContext { .map(|t| self.resolve_type_expr(t)) .unwrap_or(Type::Any); self.bind(&p.name, typ.clone()); + if let Some(ref pat) = p.pattern { + self.bind_pattern(pat, &typ); + } typ }) .collect(); @@ -2883,16 +2923,45 @@ impl TypeContext { let path_str = file_path.to_string_lossy().to_string(); - // Check cache - if let Some(cached) = self.module_cache.get(&path_str) { - return cached.clone(); - } - - // Check for circular imports + // Check for circular imports — must come before the cache check because + // Pass 1 exports are cached early to break infinite recursion, but we still + // want to warn the user about the cycle. if self.resolving_files.contains(&path_str) { + // Build cycle chain for diagnostic + if let Some(start) = self.resolving_files.iter().position(|f| f == &path_str) { + let mut chain: Vec = self.resolving_files[start..] + .iter() + .map(|f| { + std::path::Path::new(f) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| f.clone()) + }) + .collect(); + chain.push( + std::path::Path::new(&path_str) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path_str.clone()), + ); + self.detected_cycles.push(format!( + "Circular import detected: {}\n \ + Hint: break one of these imports to resolve the cycle", + chain.join(" → ") + )); + } + // Return cached Pass 1 exports if available, otherwise empty + if let Some(cached) = self.module_cache.get(&path_str) { + return cached.clone(); + } return FileExports::default(); } + // Check cache (non-circular — fully resolved from a previous import) + if let Some(cached) = self.module_cache.get(&path_str) { + return cached.clone(); + } + // Read and parse let source_code = match std::fs::read_to_string(file_path) { Ok(s) => s, @@ -2914,9 +2983,10 @@ impl TypeContext { let mut temp_ctx = TypeContext::new(&source_code); temp_ctx.current_file = Some(path_str.clone()); temp_ctx.register_builtins(); - // Share module cache and resolving files to prevent infinite recursion + // Share module cache, resolving files, and cycle detector to prevent infinite recursion temp_ctx.module_cache = std::mem::take(&mut self.module_cache); temp_ctx.resolving_files = std::mem::take(&mut self.resolving_files); + temp_ctx.detected_cycles = std::mem::take(&mut self.detected_cycles); // Run Pass 1 on the imported file to collect declarations for stmt in &ast.statements { @@ -2959,6 +3029,7 @@ impl TypeContext { self.module_cache = temp_ctx.module_cache; self.resolving_files = temp_ctx.resolving_files; self.resolving_files.retain(|f| f != &path_str); + self.detected_cycles = temp_ctx.detected_cycles; exports } @@ -5120,22 +5191,31 @@ mod tests { // ── Phase 2: Block divergence analysis ────────────────────── #[test] - fn test_otherwise_without_return_warns() { - let warnings = check_warnings( + fn test_otherwise_without_return_errors() { + // Non-diverging otherwise blocks are now errors (not warnings) since they + // always crash at runtime — catching this at lint time prevents outages. + let diags = check( r#" let x = Some(42) otherwise { let y = 1 } "#, ); - let otherwise_warnings: Vec<_> = warnings + let otherwise_diags: Vec<_> = diags .iter() - .filter(|w| w.message.contains("otherwise")) + .filter(|d| d.message.contains("otherwise")) .collect(); assert!( - !otherwise_warnings.is_empty(), - "otherwise block without return should warn: {:?}", - warnings + !otherwise_diags.is_empty(), + "otherwise block without return should produce a diagnostic: {:?}", + diags + ); + assert!( + otherwise_diags + .iter() + .any(|d| d.severity == Severity::Error), + "otherwise block without return should be an error: {:?}", + otherwise_diags ); } @@ -5226,17 +5306,14 @@ mod tests { #[test] fn test_phase2_gradual_preserved() { + // Gradual typing: untyped code (including unannotated lambdas) produces no type errors. let diags = check( r#" - let val = Some(42) otherwise { - let x = 1 - } fn foo(a) { let b = fn(x) { x + 1 } } "#, ); - // Only the otherwise warning, no type errors let errors: Vec<_> = diags .iter() .filter(|d| d.severity == Severity::Error) @@ -5808,9 +5885,9 @@ let n: Int = double(5)"#; a_path.to_str().unwrap(), ); - // Should not panic/crash, just gracefully handle + // Should not panic/crash — no errors (cycle is a warning, not error) let errors: Vec<_> = diags - .into_iter() + .iter() .filter(|d| d.severity == Severity::Error) .collect(); assert!( @@ -5819,6 +5896,98 @@ let n: Int = double(5)"#; errors ); + // Should emit a warning about the circular import with the cycle chain + let warnings: Vec<_> = diags + .iter() + .filter(|d| { + d.severity == Severity::Warning && d.message.contains("Circular import detected") + }) + .collect(); + assert!( + !warnings.is_empty(), + "Circular imports should produce a warning diagnostic" + ); + + // Verify the cycle chain shows both files + let msg = &warnings[0].message; + assert!( + msg.contains("→"), + "Cycle warning should show chain with →, got: {}", + msg + ); + assert!( + msg.contains("a.tnt") && msg.contains("b.tnt"), + "Cycle warning should name both files, got: {}", + msg + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_circular_import_three_file_cycle() { + use std::io::Write; + // Create a three-file cycle: a → b → c → a + let dir = std::env::temp_dir().join("ntnt_test_circular_three"); + let _ = std::fs::create_dir_all(&dir); + + let a_path = dir.join("a.tnt"); + let b_path = dir.join("b.tnt"); + let c_path = dir.join("c.tnt"); + + let mut fa = std::fs::File::create(&a_path).unwrap(); + writeln!(fa, "import {{ cfn }} from \"./c\"").unwrap(); + writeln!(fa, "fn afn(x: Int) -> Int {{ return x + 1 }}").unwrap(); + + let mut fb = std::fs::File::create(&b_path).unwrap(); + writeln!(fb, "import {{ afn }} from \"./a\"").unwrap(); + writeln!(fb, "fn bfn(x: Int) -> Int {{ return x + 2 }}").unwrap(); + + let mut fc = std::fs::File::create(&c_path).unwrap(); + writeln!(fc, "import {{ bfn }} from \"./b\"").unwrap(); + writeln!(fc, "fn cfn(x: Int) -> Int {{ return x + 3 }}").unwrap(); + + let a_src = std::fs::read_to_string(&a_path).unwrap(); + let diags = check_program_with_file( + &{ + let lexer = crate::lexer::Lexer::new(&a_src); + let tokens: Vec<_> = lexer.collect(); + let mut parser = crate::parser::Parser::new(tokens); + parser.parse().unwrap() + }, + &a_src, + a_path.to_str().unwrap(), + ); + + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!( + errors.is_empty(), + "Three-file circular import should not produce errors: {:?}", + errors + ); + + let warnings: Vec<_> = diags + .iter() + .filter(|d| { + d.severity == Severity::Warning && d.message.contains("Circular import detected") + }) + .collect(); + assert!( + !warnings.is_empty(), + "Three-file circular import should produce a cycle warning" + ); + + // The cycle chain should include all three files + let msg = &warnings[0].message; + assert!( + msg.contains("a.tnt"), + "Cycle should reference a.tnt, got: {}", + msg + ); + let _ = std::fs::remove_dir_all(&dir); } diff --git a/tests/language_features_tests.rs b/tests/language_features_tests.rs index ffaae71..003d2dd 100644 --- a/tests/language_features_tests.rs +++ b/tests/language_features_tests.rs @@ -1698,6 +1698,65 @@ for [num, word] in pairs { assert_eq!(lines[2], "3: three"); } +// ============================================================================ +// Destructuring in Function Parameters +// ============================================================================ + +#[test] +fn test_destructured_map_param() { + let code = r#" +fn greet({ name, email }) { + return name + " <" + email + ">" +} +print(greet(map { "name": "Alice", "email": "a@b.com" })) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "Destructured map param should work"); + assert!(stdout.trim().contains("Alice ")); +} + +#[test] +fn test_destructured_map_param_with_type() { + let code = r#" +fn greet({ name, email }: Map) -> String { + return name + " <" + email + ">" +} +print(greet(map { "name": "Bob", "email": "b@b.com" })) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "Destructured map param with type should work"); + assert!(stdout.trim().contains("Bob ")); +} + +#[test] +fn test_destructured_array_param() { + let code = r#" +fn sum_first_two([a, b, ...rest]) { + return a + b +} +print(sum_first_two([10, 20, 30])) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "Destructured array param should work"); + assert!(stdout.trim().contains("30")); +} + +#[test] +fn test_destructured_param_with_regular_params() { + let code = r#" +fn process(id, { name }) { + return str(id) + ": " + name +} +print(process(42, map { "name": "Alice" })) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!( + exit_code, 0, + "Destructured param mixed with regular params should work" + ); + assert!(stdout.trim().contains("42: Alice")); +} + // ============================================================================ // Higher-Order Functions: filter, transform // ============================================================================