Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: union types #466

Draft
wants to merge 19 commits into
base: staging
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 14 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
119 changes: 112 additions & 7 deletions src/modules/types.rs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function try_parse_type could be simply extended with a recursive approach. Here is a code block that illustrates how can it be achieved without introducing new functions:

    if token(meta, "|").is_ok() {
        // We're parsing this function recursively
        match (res, try_parse_type(meta)) {
            // And we flatten the result into a single union
            (Ok(lhs), Ok(rhs)) => return Ok(Type::Union([&[lhs], &rhs[..]].concat()))
            (Err(_), _) => error!(meta, tok, "Expected type before '|'.")
            (_, Err(_)) => error!(meta, tok, "Expected type after '|'.")
        }
    }

Here is the full function for a reference:

Full implementation of try_parse_type
// Tries to parse the type - if it fails, it fails quietly
pub fn try_parse_type(meta: &mut ParserMetadata) -> Result<Type, Failure> {
    let tok = meta.get_current_token();
    let res = match tok.clone() {
        Some(matched_token) => {
            match matched_token.word.as_ref() {
                "Text" => {
                    meta.increment_index();
                    Ok(Type::Text)
                },
                "Bool" => {
                    meta.increment_index();
                    Ok(Type::Bool)
                },
                "Num" => {
                    meta.increment_index();
                    Ok(Type::Num)
                },
                "Null" => {
                    meta.increment_index();
                    Ok(Type::Null)
                },
                "[" => {
                    let index = meta.get_index();
                    meta.increment_index();
                    if token(meta, "]").is_ok() {
                        Ok(Type::Array(Box::new(Type::Generic)))
                    } else {
                        match try_parse_type(meta) {
                            Ok(Type::Array(_)) => error!(meta, tok, "Arrays cannot be nested due to the Bash limitations"),
                            Ok(result_type) => {
                                token(meta, "]")?;
                                Ok(Type::Array(Box::new(result_type)))
                            },
                            Err(_) => {
                                meta.set_index(index);
                                Err(Failure::Quiet(PositionInfo::at_eof(meta)))
                            }
                        }
                    }
                },
                // Error messages to help users of other languages understand the syntax
                text @ ("String" | "Char") => {
                    error!(meta, tok, format!("'{text}' is not a valid data type. Did you mean 'Text'?"))
                },
                number @ ("Number" | "Int" | "Float" | "Double") => {
                    error!(meta, tok, format!("'{number}' is not a valid data type. Did you mean 'Num'?"))
                },
                "Boolean" => {
                    error!(meta, tok, "'Boolean' is not a valid data type. Did you mean 'Bool'?")
                },
                array @ ("List" | "Array") => {
                    error!(meta, tok => {
                        message: format!("'{array}'<T> is not a valid data type. Did you mean '[T]'?"),
                        comment: "Where 'T' is the type of the array elements"
                    })
                },
                // The quiet error
                _ => Err(Failure::Quiet(PositionInfo::at_eof(meta)))
            }
        },
        None => {
            Err(Failure::Quiet(PositionInfo::at_eof(meta)))
        }
    };

    if token(meta, "?").is_ok() {
        res = Ok(res.map(|t| Type::Failable(Box::new(t))));
    }
    
    if token(meta, "|").is_ok() {
        // We're parsing this function recursively
        match (res, try_parse_type(meta)) {
            // And we flatten the result into a single union
            (Ok(lhs), Ok(rhs)) => return Ok(Type::Union([&[lhs], &rhs[..]].concat()))
            (Err(_), _) => error!(meta, tok, "Expected type before '|'.")
            (_, Err(_)) => error!(meta, tok, "Expected type after '|'.")
        }
    }

    res
}

Original file line number Diff line number Diff line change
@@ -1,26 +1,108 @@
use std::fmt::Display;

use heraclitus_compiler::prelude::*;
use itertools::Itertools;
use crate::utils::ParserMetadata;

#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Eq, Default)]
pub enum Type {
#[default] Null,
Text,
Bool,
Num,
Union(Vec<Type>),
Array(Box<Type>),
Failable(Box<Type>),
Generic
}

impl Type {
fn eq_union_normal(one: &[Type], other: &Type) -> bool {
one.iter().any(|x| (*x).to_string() == other.to_string())
}

fn eq_unions(one: &[Type], other: &[Type]) -> bool {
let bigger;
let smaller = if one.len() < other.len() { bigger = other; one } else { bigger = one; other };
b1ek marked this conversation as resolved.
Show resolved Hide resolved

smaller.iter().all(|x| {
Self::eq_union_normal(bigger, x)
})
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those types will not really be equal. Look at this example:

fun foo(): Num | [Num] {
  return [1]
}

fun bar(x: Num) {
  return x + 1
}

echo bar(foo())

This should raise an error. Just because value can be of some type, we cannot say that it is. We lose type safety here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo union types should not be automatically equal to normal type, but a normal type may be equal to a union type if the latter has the same type in its list.

like

Text | Num  !=  Text
Text        ==  Text | Num
Text        !=  Null | Num

and to convert Text | Num to Text, one would check it with an if x is Text which would convert x to Text inside that if block

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I don't know if you can check it this way. It's more of a one way casting ability

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typescript does it that way (mostly)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly is crucial here. We shouldn't be able to say that Text == Text | Num or Text | Num == Text

I know that thanks to this, you're able to do this:

fun foo(x: Text | Num) ...

foo(5)

But it creates an issue that I've shown before. We need to tackle this issue by checking if a value given to a var/param is the same as declared type or is in the Union type. Not by equating Text type with a union type that has Text in it.

You need to modify for example how run_function_with_args works

Copy link
Member

@Ph0enixKM Ph0enixKM Sep 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @KrosFire. @b1ek, please review this line of code. Instead of == (there actually is != but for simplicity I'll use equal sign) there should be a subset_compare function, that for union it checks if the value is part of the union, and for the regular values it applies just == operator as normal.

Explanation

When it comes to types - we are working on sets, not values. For primitive types it's easy as they are singletons:

 Text    Num    Bool
{Text}, {Num}, {Bool}

Here we can simply {Text} == {Text} because that indeed is the case.

When it comes to the more complex types like unions:

 Text | Num   Bool | [Text]
{Text, Num}, {Bool, [Text]} 

We need to compare if one set is a subset of another set and for that we cannot rely on Rust's PartialEq. This requires a comparison <=. So in the line that I've linked above - there should actually be <= because we want to check if this is the same value OR is it a subset of this value. This is a not an eq. It's an leq aka subset.

Overloading == with <= will cause bad behaviors later when we might want to >= instead.


/**
Hash is calculated in a hex number with 8 digits. Each digit has its own meaning.

Last two digits are reserved for "singular" types, like text and/or bool.

# Important
This hash is not supposed to be readed.
It is generated in a way that it can't collide, but it is not supposed to be used for representing a type.

```
0x00 00 00 00
^^ ^^ ^^ ^^ -- singular type indicator
|| -- -- number of nested arrays, such as [Num] will be 1, and [[[Num]]] will be 3.
|| -- -- modifier
|| -- -- reserved for future use
```

## Modifiers
These modifiers are valid:

| code | desc |
| ---- | ------------- |
| `00` | no modifier |
| `01` | failable type |
*/
fn type_hash(&self) -> u32 {
match self {
Type::Null => 0x00000001,
Type::Text => 0x00000002,
Type::Bool => 0x00000003,
Type::Num => 0x00000004,
Type::Generic => 0x00000005,

Type::Array(t) => t.type_hash() + 0x00000100,

Type::Failable(t) => {
if let Type::Failable(_) = **t {
panic!("Failable types can't be nested!");
}
t.type_hash() + 0x00010000
},

Type::Union(_) => unreachable!("Type hash is not available for union types! Use the PartialEq trait instead"),
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs some proof, but it's late and I'm lazy XD

Try by doing some tests, or math. Or try with hexes to be sure

Suggested change
fn type_hash(&self) -> u32 {
match self {
Type::Null => 0x00000001,
Type::Text => 0x00000002,
Type::Bool => 0x00000003,
Type::Num => 0x00000004,
Type::Generic => 0x00000005,
Type::Array(t) => t.type_hash() + 0x00000100,
Type::Failable(t) => {
if let Type::Failable(_) = **t {
panic!("Failable types can't be nested!");
}
t.type_hash() + 0x00010000
},
Type::Union(_) => unreachable!("Type hash is not available for union types! Use the PartialEq trait instead"),
}
}
}
fn type_hash(&self) -> u32 {
match self {
Type::Null => 1 << 0,
Type::Text => 1 << 1,
Type::Bool => 1 << 2,
Type::Num => 1 << 3,
Type::Generic => 1 << 4,
Type::Array(t) => t.type_hash() + (1 << 5),
Type::Failable(t) => {
if let Type::Failable(_) = **t {
panic!("Failable types can't be nested!");
}
t.type_hash() +(1 << 6)
},
Type::Union(types) => sum(dedup(types)) + (1 << 7),
}
}
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i removed that function the same day i wrote it, you should review on the latest commit

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm, i forgot to push it.. lmao


impl PartialEq for Type {
fn eq(&self, other: &Self) -> bool {
if let Type::Union(union) = self {
if let Type::Union(other) = other {
return Type::eq_unions(union, other);
} else {
return Type::eq_union_normal(union, other);
}
}

if let Type::Union(other) = other {
Type::eq_union_normal(other, self)
} else {
self.type_hash() == other.type_hash()
}
}
}

impl Display for Type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Type::Text => write!(f, "Text"),
Type::Bool => write!(f, "Bool"),
Type::Num => write!(f, "Num"),
Type::Null => write!(f, "Null"),
Type::Union(types) => write!(f, "{}", types.iter().map(|x| format!("{x}")).join(" | ")),
Type::Array(t) => write!(f, "[{}]", t),
Type::Failable(t) => write!(f, "{}?", t),
Type::Generic => write!(f, "Generic")
Expand All @@ -39,10 +121,8 @@ pub fn parse_type(meta: &mut ParserMetadata) -> Result<Type, Failure> {
.map_err(|_| Failure::Loud(Message::new_err_at_token(meta, tok).message("Expected a data type")))
}

// Tries to parse the type - if it fails, it fails quietly
pub fn try_parse_type(meta: &mut ParserMetadata) -> Result<Type, Failure> {
let tok = meta.get_current_token();
let res = match tok.clone() {
fn parse_type_tok(meta: &mut ParserMetadata, tok: Option<Token>) -> Result<Type, Failure> {
match tok.clone() {
Some(matched_token) => {
match matched_token.word.as_ref() {
"Text" => {
Expand Down Expand Up @@ -99,10 +179,35 @@ pub fn try_parse_type(meta: &mut ParserMetadata) -> Result<Type, Failure> {
None => {
Err(Failure::Quiet(PositionInfo::at_eof(meta)))
}
};
}
}

fn parse_one_type(meta: &mut ParserMetadata, tok: Option<Token>) -> Result<Type, Failure> {
let res = parse_type_tok(meta, tok)?;
if token(meta, "?").is_ok() {
return res.map(|t| Type::Failable(Box::new(t)))
return Ok(Type::Failable(Box::new(res)))
}
Ok(res)
}

// Tries to parse the type - if it fails, it fails quietly
pub fn try_parse_type(meta: &mut ParserMetadata) -> Result<Type, Failure> {
let tok = meta.get_current_token();
let res = parse_one_type(meta, tok);

if token(meta, "|").is_ok() {
// is union type
let mut unioned = vec![ res? ];
loop {
match parse_one_type(meta, meta.get_current_token()) {
Err(err) => return Err(err),
Ok(t) => unioned.push(t)
};
if token(meta, "|").is_err() {
break;
}
}
return Ok(Type::Union(unioned))
}

res
Expand Down
2 changes: 2 additions & 0 deletions src/tests/errors.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use super::test_amber;

mod unions;

#[test]
#[should_panic(expected = "ERROR: Return type does not match function return type")]
fn function_with_wrong_typed_return() {
Expand Down
32 changes: 32 additions & 0 deletions src/tests/errors/unions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use crate::tests::test_amber;

#[test]
#[should_panic(expected = "ERROR: 1st argument 'param' of function 'abc' expects type 'Text | Null', but 'Num' was given")]
fn invalid_union_type_eq_normal_type() {
let code = r#"
fun abc(param: Text | Null) {}
abc("")
abc(123)
"#;
test_amber(code, "");
}

#[test]
#[should_panic(expected = "ERROR: 1st argument 'param' of function 'abc' expects type 'Text | Null', but 'Num | [Text]' was given")]
fn invalid_two_unions() {
let code = r#"
fun abc(param: Text | Null) {}
abc(123 as Num | [Text])
"#;
test_amber(code, "");
}

#[test]
#[should_panic(expected = "ERROR: 1st argument 'param' of function 'abc' expects type 'Text | Num | Text? | Num? | [Null]', but 'Null' was given")]
fn big_union() {
let code = r#"
fun abc(param: Text | Num | Text? | Num? | [Null]) {}
abc(null)
"#;
test_amber(code, "");
}
1 change: 1 addition & 0 deletions src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod cli;
pub mod errors;
pub mod extra;
pub mod formatter;
pub mod types;
mod stdlib;
mod validity;

Expand Down
34 changes: 34 additions & 0 deletions src/tests/types/eq.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use crate::modules::types::Type;

#[test]
fn normal_types_eq() {
let types = vec![Type::Null, Type::Text, Type::Bool, Type::Num, Type::Generic];
for typ in types {
assert_eq!(typ, typ, "{typ} and {typ} must be equal!");
}
}

#[test]
fn two_different_normal_types() {
assert_ne!(Type::Null, Type::Bool);
}

#[test]
fn normal_and_failable_type() {
assert_ne!(Type::Failable(Box::new(Type::Text)), Type::Text, "Text? and Text must not be equal!")
}

#[test]
fn array_and_normal_type() {
assert_ne!(Type::Array(Box::new(Type::Bool)), Type::Bool);
}

#[test]
fn array_and_array_of_failables() {
assert_ne!(Type::Array(Box::new(Type::Bool)), Type::Array(Box::new(Type::Failable(Box::new(Type::Bool)))));
}

#[test]
fn nested_array_normal_array_with_failable() {
assert_ne!(Type::Array(Box::new(Type::Array(Box::new(Type::Bool)))), Type::Failable(Box::new(Type::Bool)));
}
2 changes: 2 additions & 0 deletions src/tests/types/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod union;
pub mod eq;
73 changes: 73 additions & 0 deletions src/tests/types/union.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use crate::modules::types::Type;

#[test]
fn partially_overlapping_types() {
let one = Type::Union(vec![Type::Text, Type::Num]);
let two = Type::Union(vec![Type::Num, Type::Null]);

assert_ne!(one, two, "Text | Num must not be equal to Num | Null!")
}

#[test]
fn overlapping_types() {
let one = Type::Union(vec![Type::Text, Type::Num]);
let two = Type::Union(vec![Type::Text, Type::Num, Type::Null]);

assert_eq!(one, two, "Text | Num must be equal to Text | Num | Null!")
}

#[test]
fn same_union() {
let one = Type::Union(vec![Type::Text, Type::Num]);
let two = Type::Union(vec![Type::Text, Type::Num]);

assert_eq!(one, two, "Text | Num must be equal to Text | Num!")
}

#[test]
fn empty_union() {
let one = Type::Union(vec![]);
let two = Type::Union(vec![]);

assert_eq!(one, two, "If one of unions is empty, it must always be equal to another")
}

#[test]
fn empty_and_normal_union() {
let one = Type::Union(vec![Type::Text, Type::Num]);
let two = Type::Union(vec![]);

assert_eq!(one, two, "If one of unions is empty, it must always be equal to another")
}

#[test]
fn empty_union_and_normal_type() {
let one = Type::Union(vec![]);
let two = Type::Text;

assert_ne!(one, two, "An empty union and one type are not equal")
}

#[test]
fn big_union() {
let one = Type::Union(vec![Type::Text, Type::Text, Type::Text, Type::Text, Type::Text, Type::Text, Type::Text, Type::Num]);
let two = Type::Union(vec![Type::Text, Type::Num]);

assert_eq!(one, two, "Text | Text | ... | Text | Num and Text | Num must be equal!")
}

#[test]
fn normal_and_union() {
let one = Type::Text;
let two = Type::Union(vec![Type::Text, Type::Null]);

assert_eq!(one, two, "Text and Text | Null must be equal!");
}

#[test]
fn normal_not_in_union() {
let one = Type::Text;
let two = Type::Union(vec![Type::Num, Type::Null]);

assert_ne!(one, two, "Text and Num | Null must not be equal!");
}
10 changes: 10 additions & 0 deletions src/tests/validity/function_with_union_types.ab
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Output
// abc
// 123

fun check(thing: Text | Num): Null {
echo thing
}

check("abc")
check(123)
7 changes: 7 additions & 0 deletions src/tests/validity/union_types.ab
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Output
// 123

let thingy = "abc" as Text | Num;
thingy = 123;

echo thingy;
19 changes: 19 additions & 0 deletions src/tests/validity/union_types_if.ab
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Output
// is text
// abc
// is num
// 123

fun check(thing: Text | Num): Null {
if thing is Text {
echo "is text"
echo thing
}
if thing is Num {
echo "is num"
echo thing
}
}

check("abc")
check(123)
Loading