From 9b1031a8c99b8f2f2b371b36e29538155b06734d Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 22:11:14 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20test=20a=20value's=20type=20with=20?= =?UTF-8?q?x=20as=3F=20T=20=F0=9F=94=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- manual/src/features/casts.md | 58 ++++++++++++++++++- ndc_analyser/src/analyser/mod.rs | 15 ++++- ndc_fmt/src/format.rs | 12 +++- ndc_fmt/tests/style.rs | 8 +++ ndc_highlight/src/lib.rs | 1 + ndc_lexer/src/token.rs | 3 + ndc_lsp/src/features/completion.rs | 4 +- ndc_parser/src/expression.rs | 3 + ndc_parser/src/parser.rs | 3 +- ndc_vm/src/chunk.rs | 7 +++ ndc_vm/src/compiler.rs | 23 ++++++-- ndc_vm/src/vm.rs | 13 +++++ tests/compiler/tests/compiler.rs | 31 ++++++++++ .../016_casts/026_optional_cast_some.ndc | 4 ++ .../016_casts/027_optional_cast_none.ndc | 4 ++ .../028_optional_cast_containers.ndc | 5 ++ .../016_casts/029_optional_cast_functions.ndc | 6 ++ ...0_optional_cast_checked_function_error.ndc | 6 ++ .../016_casts/031_optional_cast_type_test.ndc | 12 ++++ .../032_impossible_optional_cast_error.ndc | 3 + .../016_casts/033_proven_optional_cast.ndc | 4 ++ 21 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 tests/functional/programs/016_casts/026_optional_cast_some.ndc create mode 100644 tests/functional/programs/016_casts/027_optional_cast_none.ndc create mode 100644 tests/functional/programs/016_casts/028_optional_cast_containers.ndc create mode 100644 tests/functional/programs/016_casts/029_optional_cast_functions.ndc create mode 100644 tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc create mode 100644 tests/functional/programs/016_casts/031_optional_cast_type_test.ndc create mode 100644 tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc create mode 100644 tests/functional/programs/016_casts/033_proven_optional_cast.ndc diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index 2293ab35..3d7391dd 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -62,12 +62,66 @@ The analyser rejects casts between types that cannot share a value: // error[analysis]: invalid cast: String can never be Int ``` +## Testing a value's type with `as?` + +`value as? T` is the cast that doesn't fail: it is `Some(value)`, as a `T`, +when the value is a `T`, and `None` when it isn't. Its type is `Option`. + +```ndc +let value: Any = 5; +print(value as? Int); // Some(5) +print(value as? String); // None +``` + +It checks a value the way `as` does, so a container is checked element by +element, and a function that may fit a function type comes out checking its +calls: + +```ndc +let mixed: Any = [1, "a"]; +print(mixed as? List); // None +print(mixed as? List); // Some([1,"a"]) + +fn inc(x: Int) => x + 1; +let f: Any = inc; +print((f as? Fn(Int) -> Int).unwrap()(2)); // 3 +print((f as? Fn(String) -> Int).is_some()); // false +``` + +That makes it the way to ask what a value is: + +```ndc +fn describe(value: Any) { + if (value as? Int).is_some() { + "a number" + } else if (value as? String).is_some() { + "some text" + } else { + "something else" + } +} +print(describe(1)); // a number +print(describe("a")); // some text +print(describe([1])); // something else +``` + +An `as?` that can never succeed is rejected like the `as` it stands for, +since it would always be `None`: + +```ndc,fails +"foo" as? Int +// error[analysis]: invalid cast: String can never be Int +``` + ## Precedence -`as` binds tighter than binary operators and looser than unary operators +`as` and `as?` bind tighter than binary operators and looser than unary operators and calls, so `a + b as Int` means `a + (b as Int)`. Method calls bind tighter than the cast: recovering an element type before a method call -needs parentheses, as in `(values.keys as List).max()`. +needs parentheses, as in `(values.keys as List).max()`. An `as?` cast +needs them too before a method call on the option: `(value as? Int).is_some()`. +A name can contain `?`, so `as?` needs a space after it: `x as?Int` reads as +`x` followed by a name `as?Int`. A `<` after the cast type is read as a type argument list when the tokens that follow form one, and as a less-than comparison otherwise, so diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 73577587..01007e8e 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -433,7 +433,11 @@ impl Analyser { StaticType::Bool } Expression::Grouping(expr) => self.analyse_expr(expr), - Expression::Cast { value, annotation } => { + Expression::Cast { + value, + annotation, + optional, + } => { let target = self.lower_value_type(annotation); let found = self.analyse_expr(value); // What isn't known about the value yet is what the cast says @@ -453,7 +457,14 @@ impl Analyser { } let check = (!found.fits(&target)).then(|| target.clone()); self.result.analysis.cast_checks.insert(*id, check); - target + // `as?` is the same cast, with `None` where `as` would fail. + // One that can never succeed is an error like `as`: it would + // always be `None`. + if *optional { + StaticType::Option(Box::new(target)) + } else { + target + } } Expression::VariableDeclaration { l_value, diff --git a/ndc_fmt/src/format.rs b/ndc_fmt/src/format.rs index 014483c7..7c13d076 100644 --- a/ndc_fmt/src/format.rs +++ b/ndc_fmt/src/format.rs @@ -208,9 +208,15 @@ impl<'a> Formatter<'a> { } Expression::Logical { .. } => self.operator_chain(expression), Expression::Grouping(inner) => concat([text("("), self.expr(inner), text(")")]), - Expression::Cast { value, annotation } => { - concat([self.expr(value), text(" as "), type_expr(annotation)]) - } + Expression::Cast { + value, + annotation, + optional, + } => concat([ + self.expr(value), + text(if *optional { " as? " } else { " as " }), + type_expr(annotation), + ]), Expression::VariableDeclaration { l_value, annotated_type, diff --git a/ndc_fmt/tests/style.rs b/ndc_fmt/tests/style.rs index 03dab4f2..3aefcdc4 100644 --- a/ndc_fmt/tests/style.rs +++ b/ndc_fmt/tests/style.rs @@ -24,6 +24,14 @@ fn spaces_go_around_operators_and_after_commas() { ); } +#[test] +fn a_cast_keeps_its_operator() { + assert_formats( + "let a = x as Int;let b = x as? List;", + "let a = x as Int;\nlet b = x as? List;\n", + ); +} + #[test] fn statements_are_indented_by_four_spaces() { assert_formats( diff --git a/ndc_highlight/src/lib.rs b/ndc_highlight/src/lib.rs index 9609b229..9385c3a2 100644 --- a/ndc_highlight/src/lib.rs +++ b/ndc_highlight/src/lib.rs @@ -151,6 +151,7 @@ fn kind( | Token::While | Token::Pure | Token::As + | Token::AsOptional | Token::LogicAnd | Token::LogicOr | Token::LogicNot => Kind::Keyword, diff --git a/ndc_lexer/src/token.rs b/ndc_lexer/src/token.rs index 116b779e..439554e1 100644 --- a/ndc_lexer/src/token.rs +++ b/ndc_lexer/src/token.rs @@ -83,6 +83,7 @@ pub enum Token { FatArrow, // => // Keywords As, + AsOptional, // as? Break, Continue, Else, @@ -155,6 +156,7 @@ impl fmt::Display for Token { Self::GreaterGreater => ">>", Self::Bang => "!", Self::As => "as", + Self::AsOptional => "as?", Self::Break => "break", Self::Continue => "continue", Self::Else => "else", @@ -332,6 +334,7 @@ impl From for Token { // Normal keywords "and" => Self::LogicAnd, "as" => Self::As, + "as?" => Self::AsOptional, "break" => Self::Break, "continue" => Self::Continue, "else" => Self::Else, diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index 38153874..8d005f9a 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -212,8 +212,8 @@ fn merged_type_display(candidates: &[&StaticType]) -> String { /// Language keywords offered in general (non-dot) completion. const KEYWORDS: &[&str] = &[ - "let", "fn", "pure", "struct", "if", "else", "while", "for", "in", "as", "return", "break", - "continue", "and", "or", "not", "true", "false", "None", + "let", "fn", "pure", "struct", "if", "else", "while", "for", "in", "as", "as?", "return", + "break", "continue", "and", "or", "not", "true", "false", "None", ]; fn keyword_completions() -> impl Iterator { diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index fa24d990..56147bec 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -35,9 +35,12 @@ pub enum Expression { }, Grouping(Box), /// `value as Type` — asserts the value has the target type; never converts. + /// `value as? Type` (`optional`) — `Some(value)` when the value has the + /// target type, `None` when it doesn't. Cast { value: Box, annotation: TypeExpr, + optional: bool, }, VariableDeclaration { l_value: BindingPatternLocation, diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index d5042142..0775d419 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -650,12 +650,13 @@ impl Parser { fn cast(&mut self) -> Result { let mut expression = self.tight_unary()?; - while self.consume_token_if(&[Token::As]).is_some() { + while let Some(operator) = self.consume_token_if(&[Token::As, Token::AsOptional]) { let annotation = self.cast_type_annotation()?; let span = expression.span.merge(annotation.span()); expression = Expression::Cast { value: Box::new(expression), annotation, + optional: operator.token == Token::AsOptional, } .to_location(span); } diff --git a/ndc_vm/src/chunk.rs b/ndc_vm/src/chunk.rs index 97878caf..c93e80e8 100644 --- a/ndc_vm/src/chunk.rs +++ b/ndc_vm/src/chunk.rs @@ -154,6 +154,9 @@ pub enum OpCode { Memoize, /// Peeks top; errors if the value does not conform to the type. `[… value → … value]` CheckType(Rc), + /// Pops a value, pushes `Some(value)` if it conforms to the type and + /// `None` if it doesn't. `[… value → … option]` + TryCast(Rc), /// Peeks top, a value of type `Any` where code expects the type; errors /// if it isn't one. `[… value → … value]` ExpectType(Rc), @@ -194,6 +197,7 @@ impl OpCode { Self::NewCell(_) => "NewCell", Self::Memoize => "Memoize", Self::CheckType(_) => "CheckType", + Self::TryCast(_) => "TryCast", Self::ExpectType(_) => "ExpectType", } } @@ -251,6 +255,8 @@ impl OpCode { Self::Memoize => 0, // Peeks the value without popping it. Self::CheckType(_) | Self::ExpectType(_) => 0, + // Replaces the value with the option holding it. + Self::TryCast(_) => 0, } } } @@ -305,6 +311,7 @@ impl std::fmt::Debug for OpCode { Self::Unpack(n) => write!(f, "Unpack({n})"), Self::Memoize => write!(f, "Memoize"), Self::CheckType(typ) => write!(f, "CheckType({typ})"), + Self::TryCast(typ) => write!(f, "TryCast({typ})"), Self::ExpectType(typ) => write!(f, "ExpectType({typ})"), } } diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index de8ad145..27db14f8 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -246,15 +246,28 @@ impl Compiler { } binding => self.compile_binding(&binding, span)?, }, - Expression::Cast { value, .. } => { + Expression::Cast { + value, optional, .. + } => { self.compile_expr(*value)?; - match self.analysis.cast_checks.get(&id) { + let check = match self.analysis.cast_checks.get(&id) { None => return Err(CompileError::unresolved_cast(span)), - Some(None) => {} - Some(Some(target)) => { - let target = Rc::new(target.clone()); + Some(check) => check.clone().map(Rc::new), + }; + match (check, optional) { + (None, false) => {} + (Some(target), false) => { self.ir.write(OpCode::CheckType(target), span); } + // A value proven to be the type is always `Some`, as + // every value is an `Any`. + (None, true) => { + self.ir + .write(OpCode::TryCast(Rc::new(StaticType::Any)), span); + } + (Some(target), true) => { + self.ir.write(OpCode::TryCast(target), span); + } } } Expression::Statement(stm) => { diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index f5675b50..732d5c1d 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -239,6 +239,7 @@ impl Vm { )); } } + OpCode::TryCast(target) => try_cast(&mut self.stack, target), OpCode::ExpectType(target) => { if !convert_top(&mut self.stack, target) { let value = self.stack.last().expect("stack underflow"); @@ -1292,6 +1293,18 @@ fn bad_range_bound(value: &Value) -> String { format!("range bounds must be Int, got {}", value.diagnostic_type()) } +/// Replaces the value on top of `stack` with `Some` of it, made the +/// `target` as [`Value::converted_to`] does, or with `None` when it isn't +/// one. +#[cold] +fn try_cast(stack: &mut [Value], target: &StaticType) { + let value = stack.last_mut().expect("stack underflow"); + *value = match value.converted_to(target) { + Some(converted) => Value::Object(Rc::new(Object::Some(converted))), + None => Value::None, + }; +} + /// Checks that the value on top of `stack` is a `target`, and makes it one: /// a function that may be a function `target` gets its calls checked (see /// [`Value::converted_to`]). diff --git a/tests/compiler/tests/compiler.rs b/tests/compiler/tests/compiler.rs index 709417e5..8969dd50 100644 --- a/tests/compiler/tests/compiler.rs +++ b/tests/compiler/tests/compiler.rs @@ -534,6 +534,37 @@ fn a_proven_cast_needs_no_runtime_check() { ); } +// let x: Any = 1; x as? Int +// +// An optional cast tests the value with a TryCast, which gives an option +// instead of a CheckType guard that fails. +#[test] +fn an_unproven_optional_cast_tries_the_type_at_runtime() { + let ops = compile_with_analysis("let x: Any = 1; x as? Int"); + assert!( + ops.iter() + .any(|op| matches!(op, TryCast(t) if **t == ndc_core::StaticType::Int)), + "expected a TryCast(Int), got: {ops:?}", + ); + assert!( + ops.iter().all(|op| !matches!(op, CheckType(_))), + "an optional cast must not emit CheckType, got: {ops:?}", + ); +} + +// 1 as? Int +// +// A proven optional cast is always `Some`, which a TryCast to `Any` makes. +#[test] +fn a_proven_optional_cast_is_always_some() { + let ops = compile_with_analysis("1 as? Int"); + assert!( + ops.iter() + .any(|op| matches!(op, TryCast(t) if **t == ndc_core::StaticType::Any)), + "expected a TryCast(Any), got: {ops:?}", + ); +} + // let a: Any = 1; let n: Int = a; // // An `Any` stored where an Int is expected is checked before it's stored. diff --git a/tests/functional/programs/016_casts/026_optional_cast_some.ndc b/tests/functional/programs/016_casts/026_optional_cast_some.ndc new file mode 100644 index 00000000..2c578e78 --- /dev/null +++ b/tests/functional/programs/016_casts/026_optional_cast_some.ndc @@ -0,0 +1,4 @@ +// `as?` gives Some of the value when it is the type. +// expect-output: Some(5) +let value: Any = 5; +print(value as? Int); diff --git a/tests/functional/programs/016_casts/027_optional_cast_none.ndc b/tests/functional/programs/016_casts/027_optional_cast_none.ndc new file mode 100644 index 00000000..ceeeeb32 --- /dev/null +++ b/tests/functional/programs/016_casts/027_optional_cast_none.ndc @@ -0,0 +1,4 @@ +// `as?` gives None when the value is not the type. +// expect-output: None +let value: Any = "five"; +print(value as? Int); diff --git a/tests/functional/programs/016_casts/028_optional_cast_containers.ndc b/tests/functional/programs/016_casts/028_optional_cast_containers.ndc new file mode 100644 index 00000000..a2cec4c5 --- /dev/null +++ b/tests/functional/programs/016_casts/028_optional_cast_containers.ndc @@ -0,0 +1,5 @@ +// A container is checked element by element, as `as` does. +// expect-output: None Some([1,"a"]) Some([1,2]) +let mixed: Any = [1, "a"]; +let ints: Any = [1, 2]; +print(mixed as? List, mixed as? List, ints as? List); diff --git a/tests/functional/programs/016_casts/029_optional_cast_functions.ndc b/tests/functional/programs/016_casts/029_optional_cast_functions.ndc new file mode 100644 index 00000000..40463844 --- /dev/null +++ b/tests/functional/programs/016_casts/029_optional_cast_functions.ndc @@ -0,0 +1,6 @@ +// A function that may be the function type comes out checking its calls. +// expect-output: 3 false +fn inc(x: Int) => x + 1; +let a: Any = inc; +let f = (a as? Fn(Int) -> Int).unwrap(); +print(f(2), (a as? Fn(String) -> Int).is_some()); diff --git a/tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc b/tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc new file mode 100644 index 00000000..8ba5a879 --- /dev/null +++ b/tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc @@ -0,0 +1,6 @@ +// A function whose result is checked as it is called. +// expect-error: expected the function to return Int but it returned String +fn text(x) => "s"; +let a: Any = text; +let f = (a as? Fn(Int) -> Int).unwrap(); +print(f(1)); diff --git a/tests/functional/programs/016_casts/031_optional_cast_type_test.ndc b/tests/functional/programs/016_casts/031_optional_cast_type_test.ndc new file mode 100644 index 00000000..ae52dfe0 --- /dev/null +++ b/tests/functional/programs/016_casts/031_optional_cast_type_test.ndc @@ -0,0 +1,12 @@ +// `as?` tests the type of a value. +// expect-output: int string other +fn describe(value: Any) { + if (value as? Int).is_some() { + "int" + } else if (value as? String).is_some() { + "string" + } else { + "other" + } +} +print(describe(1), describe("a"), describe([1])); diff --git a/tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc b/tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc new file mode 100644 index 00000000..ab9cae4b --- /dev/null +++ b/tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc @@ -0,0 +1,3 @@ +// An optional cast that can never succeed would always be None. +// expect-error: invalid cast: String can never be Int +print("foo" as? Int); diff --git a/tests/functional/programs/016_casts/033_proven_optional_cast.ndc b/tests/functional/programs/016_casts/033_proven_optional_cast.ndc new file mode 100644 index 00000000..d63a1f90 --- /dev/null +++ b/tests/functional/programs/016_casts/033_proven_optional_cast.ndc @@ -0,0 +1,4 @@ +// A proven optional cast is Some, and its type is an Option of the target. +// expect-output: 3 +let n: Option = 3 as? Int; +print(n.unwrap()); From f6111db463fd59ae616075a700e419c020c8849a Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 22:13:00 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(analyser):=20leave=20a=20value's=20type?= =?UTF-8?q?=20open=20when=20as=3F=20tests=20it=20=F0=9F=94=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ndc_analyser/src/analyser/mod.rs | 7 ++++++- .../034_an_optional_cast_leaves_the_value_s_type_open.ndc | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 01007e8e..ee638d07 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -445,9 +445,14 @@ impl Analyser { // needs no check as the program runs, and `1 as Float` is a // Float. Known parts are checked, and so is a value nothing // is known about, like what a call that waits returns. + // `as?` only tests the value, which stays what it is: in `fn + // f(xs) { (xs as? List).is_some() }`, `xs` can still be a + // list of Strings. A number written there is what it's cast + // to, as with `as`: `1 as? Float` is `Some(1.0)`. let found = self.vars.resolve(&found); let unknown = matches!(found, StaticType::Var(var) if !self.vars.is_literal(var)); - if found.has_vars() && !unknown { + let literal = matches!(found, StaticType::Var(var) if self.vars.is_literal(var)); + if found.has_vars() && !unknown && (!*optional || literal) { self.vars.try_unify(&found, &target); } let found = self.vars.resolve(&found); diff --git a/tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc b/tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc new file mode 100644 index 00000000..3edf1c60 --- /dev/null +++ b/tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc @@ -0,0 +1,7 @@ +// `as?` only tests a value: a list whose elements aren't known yet stays +// open, so it can still hold Strings. The empty list is a `List` then. +// expect-output: true ["a"] +let xs = []; +let ints = (xs as? List).is_some(); +xs.push("a"); +print(ints, xs);