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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions manual/src/features/casts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`.

```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<Int>); // None
print(mixed as? List<Any>); // 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<Int>).max()`.
needs parentheses, as in `(values.keys as List<Int>).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
Expand Down
22 changes: 19 additions & 3 deletions ndc_analyser/src/analyser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,17 +433,26 @@ 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
// it is: `(fn(x) => x) as Fn(Int) -> Int` takes an Int, and
// 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<Int>).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);
Expand All @@ -453,7 +462,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,
Expand Down
12 changes: 9 additions & 3 deletions ndc_fmt/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions ndc_fmt/tests/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>;",
"let a = x as Int;\nlet b = x as? List<Int>;\n",
);
}

#[test]
fn statements_are_indented_by_four_spaces() {
assert_formats(
Expand Down
1 change: 1 addition & 0 deletions ndc_highlight/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ fn kind(
| Token::While
| Token::Pure
| Token::As
| Token::AsOptional
| Token::LogicAnd
| Token::LogicOr
| Token::LogicNot => Kind::Keyword,
Expand Down
3 changes: 3 additions & 0 deletions ndc_lexer/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub enum Token {
FatArrow, // =>
// Keywords
As,
AsOptional, // as?
Break,
Continue,
Else,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -332,6 +334,7 @@ impl From<String> for Token {
// Normal keywords
"and" => Self::LogicAnd,
"as" => Self::As,
"as?" => Self::AsOptional,
"break" => Self::Break,
"continue" => Self::Continue,
"else" => Self::Else,
Expand Down
4 changes: 2 additions & 2 deletions ndc_lsp/src/features/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = CompletionItem> {
Expand Down
3 changes: 3 additions & 0 deletions ndc_parser/src/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ pub enum Expression {
},
Grouping(Box<ExpressionLocation>),
/// `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<ExpressionLocation>,
annotation: TypeExpr,
optional: bool,
},
VariableDeclaration {
l_value: BindingPatternLocation,
Expand Down
3 changes: 2 additions & 1 deletion ndc_parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,12 +650,13 @@ impl Parser {

fn cast(&mut self) -> Result<ExpressionLocation, Error> {
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);
}
Expand Down
7 changes: 7 additions & 0 deletions ndc_vm/src/chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ pub enum OpCode {
Memoize,
/// Peeks top; errors if the value does not conform to the type. `[… value → … value]`
CheckType(Rc<StaticType>),
/// Pops a value, pushes `Some(value)` if it conforms to the type and
/// `None` if it doesn't. `[… value → … option]`
TryCast(Rc<StaticType>),
/// Peeks top, a value of type `Any` where code expects the type; errors
/// if it isn't one. `[… value → … value]`
ExpectType(Rc<StaticType>),
Expand Down Expand Up @@ -194,6 +197,7 @@ impl OpCode {
Self::NewCell(_) => "NewCell",
Self::Memoize => "Memoize",
Self::CheckType(_) => "CheckType",
Self::TryCast(_) => "TryCast",
Self::ExpectType(_) => "ExpectType",
}
}
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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})"),
}
}
Expand Down
23 changes: 18 additions & 5 deletions ndc_vm/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
13 changes: 13 additions & 0 deletions ndc_vm/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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`]).
Expand Down
31 changes: 31 additions & 0 deletions tests/compiler/tests/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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<Int>, mixed as? List<Any>, ints as? List<Int>);
Original file line number Diff line number Diff line change
@@ -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());
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -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]));
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading