From 151d592b8b60e701f2572d7c3c7bec9a214659da Mon Sep 17 00:00:00 2001 From: Larri Date: Wed, 11 Mar 2026 09:50:36 +0000 Subject: [PATCH 1/4] feat: destructuring in function parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow map and array destructuring patterns directly in function parameter lists, consistent with existing destructuring in let bindings and for loops. fn greet({ name, email }) { ... } fn first_two([a, b, ...rest]) { ... } fn process(id, { name }: Map) { ... } - ast.rs: add optional `pattern` field to Parameter struct - parser.rs: parse_parameters() detects `{`/`[` and calls parse_pattern() to build a synthetic parameter name with an attached pattern - interpreter.rs: call_user_function() calls bind_pattern() after binding the synthetic name, expanding destructured variables into scope - typechecker.rs: bind_pattern() called for param patterns; strict-lint annotation warnings skip synthetic destructured params - typechecker.rs: update test expectations for otherwise-block-must-diverge behaviour change (warning → error) already present on this branch - tests: 4 unit tests in interpreter.rs + 4 integration tests in language_features_tests.rs All 984 tests pass. --- src/ast.rs | 2 + src/interpreter.rs | 72 +++++++++++++++++++++++++++++++- src/parser.rs | 12 +++++- src/typechecker.rs | 48 ++++++++++++++------- tests/language_features_tests.rs | 59 ++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 17 deletions(-) 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..5176408 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); + func_env + .borrow_mut() + .define(param.name.clone(), value.clone()); + if let Some(ref pat) = param.pattern { + self.bind_pattern(pat, &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..d66968c 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -744,10 +744,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 +842,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 +873,10 @@ impl TypeContext { .as_ref() .map(|t| self.resolve_type_expr(t)) .unwrap_or(Type::Any); - self.bind(¶m.name, typ); + self.bind(¶m.name, typ.clone()); + if let Some(ref pat) = param.pattern { + self.bind_pattern(pat, &typ); + } } // Set expected return type @@ -1941,7 +1948,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 +1973,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(); @@ -5121,21 +5131,30 @@ mod tests { #[test] fn test_otherwise_without_return_warns() { - let warnings = check_warnings( + // 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 +5245,16 @@ mod tests { #[test] fn test_phase2_gradual_preserved() { + // Gradual typing: untyped code produces no type errors. + // Note: a non-diverging otherwise block is now an error (not a type error), + // so we use a properly diverging otherwise to isolate the gradual typing check. 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) 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 // ============================================================================ From 5ea6cc7c559af67df97085d5fa446ba95dd55346 Mon Sep 17 00:00:00 2001 From: Larri Date: Wed, 11 Mar 2026 10:16:58 +0000 Subject: [PATCH 2/4] feat: circular import cycle diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When files form an import cycle (a.tnt → b.tnt → a.tnt), the type checker now emits a warning showing the exact cycle chain instead of silently returning empty exports. warning: Circular import detected: a.tnt → b.tnt → a.tnt Hint: break one of these imports to resolve the cycle Implementation: - typechecker.rs: detect cycles before the module cache check (Pass 1 cache was masking cycles), accumulate cycle messages in a new detected_cycles field threaded through nested contexts via mem::take - Deduplicate cycle warnings at the root check_program_with_options entry point and attempt to locate the import line number - Two new tests: 2-file cycle and 3-file cycle, both verifying the warning message includes file names and the → chain Inspired by Zig's dependency loop diagnostics (devlog 2026-03-10). --- src/typechecker.rs | 169 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 9 deletions(-) diff --git a/src/typechecker.rs b/src/typechecker.rs index d66968c..ad1a4d4 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(), } } @@ -2893,16 +2921,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, @@ -2924,9 +2981,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 { @@ -2969,6 +3027,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 } @@ -5826,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!( @@ -5837,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); } From 767fa1fd2f8bfe3f1ae8128680e8a68abd6d74e5 Mon Sep 17 00:00:00 2001 From: Larri Date: Wed, 11 Mar 2026 12:37:18 +0000 Subject: [PATCH 3/4] fix: address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Don't leak synthetic _destructure_N names into scope; only bind pattern variables for destructured params (interpreter + typechecker) - Avoid unnecessary value.clone() on non-destructured params - Rename test_otherwise_without_return_warns → _errors to match behavior - Fix stale test comment about diverging otherwise block --- src/interpreter.rs | 6 +++--- src/typechecker.rs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/interpreter.rs b/src/interpreter.rs index 5176408..83d5973 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -6339,11 +6339,11 @@ impl Interpreter { // Should not reach here due to arity check above Value::Unit }; - func_env - .borrow_mut() - .define(param.name.clone(), value.clone()); 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); } } diff --git a/src/typechecker.rs b/src/typechecker.rs index ad1a4d4..ce04bcd 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -901,9 +901,11 @@ impl TypeContext { .as_ref() .map(|t| self.resolve_type_expr(t)) .unwrap_or(Type::Any); - self.bind(¶m.name, typ.clone()); 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); } } @@ -5189,7 +5191,7 @@ mod tests { // ── Phase 2: Block divergence analysis ────────────────────── #[test] - fn test_otherwise_without_return_warns() { + 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( @@ -5304,9 +5306,7 @@ mod tests { #[test] fn test_phase2_gradual_preserved() { - // Gradual typing: untyped code produces no type errors. - // Note: a non-diverging otherwise block is now an error (not a type error), - // so we use a properly diverging otherwise to isolate the gradual typing check. + // Gradual typing: untyped code (including unannotated lambdas) produces no type errors. let diags = check( r#" fn foo(a) { From e24562d4f23700fa88fc8259255b7aad6f4ba1e1 Mon Sep 17 00:00:00 2001 From: Larri Date: Thu, 12 Mar 2026 00:48:08 +0000 Subject: [PATCH 4/4] docs: update roadmap and guides for v0.4.1 features - Mark destructuring in function parameters as complete - Mark otherwise block divergence as lint error (upgraded from warning) - Mark circular import diagnostics as complete - Add function parameter destructuring examples to AI_AGENT_GUIDE - Add circular import detection note to type checker docs - Sync CLAUDE.md and copilot-instructions.md --- .github/copilot-instructions.md | 19 +++++++++++++++++-- CLAUDE.md | 19 +++++++++++++++++-- ROADMAP.md | 7 ++++--- docs/AI_AGENT_GUIDE.md | 17 ++++++++++++++++- 4 files changed, 54 insertions(+), 8 deletions(-) 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