allow records to have type annotations (#8914)

# Description
follow up to #8529
cleaned up version of #8892 

- the original syntax is okay
```nu
def okay [rec: record] {}
```
- you can now add type annotations for fields if you know
  them before hand
```nu
def okay [rec: record<name: string>] {}
```

- you can specify multiple fields
```nu
def okay [person: record<name: string age: int>] {}

# an optional comma is allowed
def okay [person: record<name: string, age: int>] {}
```

- if annotations are specified, any use of the command will be type
  checked against the specified type
```nu
def unwrap [result: record<ok: bool, value: any>] {}

unwrap {ok: 2, value: "value"}

# errors with

Error: nu::parser::type_mismatch

  × Type mismatch.
   ╭─[entry #4:1:1]
 1 │ unwrap {ok: 2, value: "value"}
   ·         ───────┬─────
   ·                    ╰── expected record<ok: bool, value: any>, found record<ok: int, value: string>
   ╰────
```
> here the error is in the `ok` field, since `any` is coerced into any
type
> as a result `unwrap {ok: true, value: "value"}` is okay

- the key must be a string, either quoted or unquoted
```nu
def err [rec: record<{}: list>] {}

# errors with
Error:
  × `record` type annotations key not string
   ╭─[entry #7:1:1]
 1 │ def unwrap [result: record<{}: bool, value: any>] {}
   ·                            ─┬
   ·                             ╰── must be a string
   ╰────
```

- a key doesn't have to have a type in which case it is assumed to be
`any`
```nu
def okay [person: record<name age>] {}

def okay [person: record<name: string age>] {}
```

- however, if you put a colon, you have to specify a type
```nu
def err [person: record<name: >] {}

# errors with
Error: nu::parser::parse_mismatch

  × Parse mismatch during operation.
   ╭─[entry #12:1:1]
 1 │ def unwrap [res: record<name: >] { $res }
   ·                             ┬
   ·                             ╰── expected type after colon
   ╰────
```

# User-Facing Changes
**[BREAKING CHANGES]**
- this change adds a field to `SyntaxShape::Record` so any plugins that
used it will have to update and include the field. though if you are
unsure of the type the record expects, `SyntaxShape::Record(vec![])`
will suffice
This commit is contained in:
mike
2023-04-26 16:16:55 +03:00
committed by GitHub
parent 48c75831fc
commit 77ca73f414
8 changed files with 347 additions and 93 deletions

View File

@ -95,7 +95,7 @@ pub enum SyntaxShape {
Range,
/// A record value, eg `{x: 1, y: 2}`
Record,
Record(Vec<(String, SyntaxShape)>),
/// A math expression which expands shorthand forms on the lefthand side, eg `foo > 1`
/// The shorthand allows us to more easily reach columns inside of the row being passed in
@ -151,7 +151,13 @@ impl SyntaxShape {
SyntaxShape::OneOf(_) => Type::Any,
SyntaxShape::Operator => Type::Any,
SyntaxShape::Range => Type::Any,
SyntaxShape::Record => Type::Record(vec![]), // FIXME: What role should fields play in the Record type?
SyntaxShape::Record(entries) => {
let ty = entries
.iter()
.map(|(key, val)| (key.clone(), val.to_type()))
.collect();
Type::Record(ty)
}
SyntaxShape::RowCondition => Type::Bool,
SyntaxShape::Boolean => Type::Bool,
SyntaxShape::Signature => Type::Signature,
@ -194,7 +200,21 @@ impl Display for SyntaxShape {
SyntaxShape::Binary => write!(f, "binary"),
SyntaxShape::Table => write!(f, "table"),
SyntaxShape::List(x) => write!(f, "list<{x}>"),
SyntaxShape::Record => write!(f, "record"),
SyntaxShape::Record(entries) => {
if entries.is_empty() {
write!(f, "record")
} else {
write!(
f,
"record<{}>",
entries
.iter()
.map(|(x, y)| format!("{x}: {y}"))
.collect::<Vec<String>>()
.join(", "),
)
}
}
SyntaxShape::Filesize => write!(f, "filesize"),
SyntaxShape::Duration => write!(f, "duration"),
SyntaxShape::DateTime => write!(f, "datetime"),

View File

@ -35,25 +35,28 @@ pub enum Type {
impl Type {
pub fn is_subtype(&self, other: &Type) -> bool {
// Structural subtyping
let is_subtype_collection = |this: &[(String, Type)], that: &[(String, Type)]| {
if this.is_empty() || that.is_empty() {
true
} else if this.len() != that.len() {
false
} else {
this.iter()
.zip(that.iter())
.all(|(lhs, rhs)| lhs.0 == rhs.0 && lhs.1.is_subtype(&rhs.1))
}
};
match (self, other) {
(t, u) if t == u => true,
(Type::Float, Type::Number) => true,
(Type::Int, Type::Number) => true,
(_, Type::Any) => true,
(Type::List(t), Type::List(u)) if t.is_subtype(u) => true, // List is covariant
// TODO: Currently Record types specify their field types. If we are
// going to continue to do that, then it might make sense to define
// a "structural subtyping" whereby r1 is a subtype of r2 is the
// fields of r1 are a "subset" of the fields of r2 (names are a
// subset and agree on types). However, if we do that, then we need
// a way to specify the supertype of all Records. For now, we define
// any Record to be a subtype of any other Record. This allows
// Record(vec![]) to be used as an ad-hoc supertype of all Records
// in command signatures. This comment applies to Tables also, with
// "columns" in place of "fields".
(Type::Record(_), Type::Record(_)) => true,
(Type::Table(_), Type::Table(_)) => true,
(Type::Record(this), Type::Record(that)) | (Type::Table(this), Type::Table(that)) => {
is_subtype_collection(this, that)
}
_ => false,
}
}
@ -87,7 +90,13 @@ impl Type {
Type::List(x) => SyntaxShape::List(Box::new(x.to_shape())),
Type::Number => SyntaxShape::Number,
Type::Nothing => SyntaxShape::Nothing,
Type::Record(_) => SyntaxShape::Record,
Type::Record(entries) => {
let entries = entries
.iter()
.map(|(key, val)| (key.clone(), val.to_shape()))
.collect();
SyntaxShape::Record(entries)
}
Type::Table(_) => SyntaxShape::Table,
Type::ListStream => SyntaxShape::List(Box::new(SyntaxShape::Any)),
Type::Any => SyntaxShape::Any,