diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f06281b..720dfd40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ repository. Since we follow [semver](https://semver.org/), when a new feature is released we don't backport it but simply -create a new version branch, such as `1.8.x`. Bugs, instead, +create a new version branch, such as `1.9.x`. Bugs, instead, might be backported from `1.1.0` to, for example, `1.0.x` and we will have a new [release](https://github.com/abs-lang/abs/releases), say `1.0.1` for the `1.0.x` version branch. diff --git a/ast/ast.go b/ast/ast.go index 09023fd5..6deb1c66 100644 --- a/ast/ast.go +++ b/ast/ast.go @@ -255,6 +255,7 @@ type MethodExpression struct { Object Expression Method Expression Arguments []Expression + Optional bool } func (me *MethodExpression) expressionNode() {} @@ -268,6 +269,9 @@ func (me *MethodExpression) String() string { } out.WriteString(me.Object.String()) + if me.Optional { + out.WriteString("?") + } out.WriteString(".") out.WriteString(me.Method.String()) out.WriteString("(") @@ -530,6 +534,7 @@ type PropertyExpression struct { Token token.Token // The . token Object Expression Property Expression + Optional bool } func (pe *PropertyExpression) expressionNode() {} @@ -539,6 +544,11 @@ func (pe *PropertyExpression) String() string { out.WriteString("(") out.WriteString(pe.Object.String()) + + if pe.Optional { + out.WriteString("?") + } + out.WriteString(".") out.WriteString(pe.Property.String()) out.WriteString(")") diff --git a/docs/abs.wasm b/docs/abs.wasm index 76f05577..f953b9cd 100644 Binary files a/docs/abs.wasm and b/docs/abs.wasm differ diff --git a/docs/installer.sh b/docs/installer.sh index 690e9d9a..086804e1 100644 --- a/docs/installer.sh +++ b/docs/installer.sh @@ -27,7 +27,7 @@ if [ "${MACHINE_TYPE}" = 'x86_64' ]; then ARCH="amd64" fi -VERSION=1.8.3 +VERSION=1.9.0 echo "Trying to detect the details of your architecture." echo "" diff --git a/docs/misc/3pl.md b/docs/misc/3pl.md index 4907b15b..4f13e5c5 100644 --- a/docs/misc/3pl.md +++ b/docs/misc/3pl.md @@ -16,7 +16,7 @@ Creating alias... Install Success. You can use the module with `require("abs-sample-module")` ``` -Modules will be saved under the `vendor/$MODULE-master` directory. Each module +Modules will be saved under the `vendor/$MODULE` directory. Each module also gets an alias to facilitate requiring them in your code, meaning that both of these forms are supported: @@ -24,12 +24,10 @@ both of these forms are supported: ⧐ require("abs-sample-module/sample.abs") {"another": f() {return hello world;}} -⧐ require("vendor/github.com/abs-lang/abs-sample-module-master/sample.abs") +⧐ require("vendor/github.com/abs-lang/abs-sample-module/sample.abs") {"another": f() {return hello world;}} ``` -Note that the `-master` prefix [will be removed](https://github.com/abs-lang/abs/issues/286) in future versions of ABS. - Module aliases are saved in the `packages.abs.json` file which is created in the same directory where you run the `abs get ...` command: @@ -43,7 +41,7 @@ Install Success. You can use the module with `require("abs-sample-module")` $ cat packages.abs.json { - "abs-sample-module": "./vendor/github.com/abs-lang/abs-sample-module-master" + "abs-sample-module": "./vendor/github.com/abs-lang/abs-sample-module" } ``` @@ -58,7 +56,7 @@ $ abs get github.com/abs-lang/abs-sample-module Unpacking... Creating alias...This module could not be aliased because module of same name exists -Install Success. You can use the module with `require("./vendor/github.com/abs-lang/abs-sample-module-master")` +Install Success. You can use the module with `require("./vendor/github.com/abs-lang/abs-sample-module")` ``` When requiring a module, ABS will try to load the `index.abs` file unless @@ -66,7 +64,7 @@ another file is specified: ``` $ ~/projects/abs/builds/abs -Hello alex, welcome to the ABS (1.8.3) programming language! +Hello alex, welcome to the ABS (1.9.0) programming language! Type 'quit' when you're done, 'help' if you get lost! ⧐ require("abs-sample-module") diff --git a/docs/syntax/operators.md b/docs/syntax/operators.md index 5bfd6a70..d3f2ad2b 100644 --- a/docs/syntax/operators.md +++ b/docs/syntax/operators.md @@ -230,6 +230,49 @@ true || false # true "hello" || "world" # "hello" ``` +## . + +Property accessor, used to access properties or methods of specific variables: + +``` bash +hello = {"to_who": "the world"} +hello.to_who # "the world" +``` + +There are some builtin functions that you can access through the property accessor: + +``` bash +"hello".len() # 5 +``` + +(a comprehensive list of function is documented in the "*Types and functions*" section of the documentation) + +## ?. + +Optional chaining operator, used to access properties in a "safe" way. + +Given the following object: + +``` bash +test = {"property": 1} +``` + +An error would be raised if you were trying to access a non-existing property +such as `test.something.something_else`: + +``` +ERROR: invalid property 'something_else' on type NULL + [1:15] test.something.something_else +``` + +Optional chainig prevents those errors from being raised, auto-magically +converting non-existing properties and methods to `NULL`: + +``` bash +test?.something?.something_else # null +test?.something?.something_else() # null +``` + ## .. Range operator, which creates an array from start to end: diff --git a/docs/types/builtin-function.md b/docs/types/builtin-function.md index 87926f8f..82658193 100644 --- a/docs/types/builtin-function.md +++ b/docs/types/builtin-function.md @@ -304,7 +304,7 @@ $ cat ~/.absrc source("~/abs/lib/library.abs") $ abs -Hello user, welcome to the ABS (1.8.3) programming language! +Hello user, welcome to the ABS (1.9.0) programming language! Type 'quit' when you are done, 'help' if you get lost! ⧐ adder(1, 2) 3 diff --git a/evaluator/evaluator.go b/evaluator/evaluator.go index d34fec43..52b732c6 100644 --- a/evaluator/evaluator.go +++ b/evaluator/evaluator.go @@ -186,7 +186,7 @@ func Eval(node ast.Node, env *object.Environment) object.Object { return args[0] } - return applyMethod(node.Token, o, node.Method.String(), env, args) + return applyMethod(node.Token, o, node, env, args) case *ast.PropertyExpression: return evalPropertyExpression(node, env) @@ -996,6 +996,10 @@ func evalPropertyExpression(pe *ast.PropertyExpression, env *object.Environment) return evalHashIndexExpression(obj.Token, obj, &object.String{Token: pe.Token, Value: pe.Property.String()}) } + if pe.Optional { + return NULL + } + return newError(pe.Token, "invalid property '%s' on type %s", pe.Property.String(), o.Type()) } @@ -1019,7 +1023,8 @@ func applyFunction(tok token.Token, fn object.Object, env *object.Environment, a } } -func applyMethod(tok token.Token, o object.Object, method string, env *object.Environment, args []object.Object) object.Object { +func applyMethod(tok token.Token, o object.Object, me *ast.MethodExpression, env *object.Environment, args []object.Object) object.Object { + method := me.Method.String() // Check if the current object is an hash, // it might have user-defined functions hash, isHash := o.(*object.Hash) @@ -1034,6 +1039,10 @@ func applyMethod(tok token.Token, o object.Object, method string, env *object.En f, ok := Fns[method] if !ok { + if me.Optional { + return NULL + } + return newError(tok, "%s does not have method '%s()'", o.Type(), method) } diff --git a/evaluator/evaluator_test.go b/evaluator/evaluator_test.go index d8d88925..4959f3fb 100644 --- a/evaluator/evaluator_test.go +++ b/evaluator/evaluator_test.go @@ -835,6 +835,8 @@ func TestBuiltinFunctions(t *testing.T) { {`len([])`, 0}, {`echo("hello", "world!")`, nil}, {`env("CONTEXT")`, "abs"}, + {`env("FOO")`, ""}, + {`env("FOO", "bar")`, "bar"}, {`type("SOME")`, "STRING"}, {`type(1)`, "NUMBER"}, {`type({})`, "HASH"}, @@ -1508,6 +1510,45 @@ func TestHashLiterals(t *testing.T) { } } +func TestOptionalChaining(t *testing.T) { + tests := []struct { + input string + expected interface{} + }{ + { + `a = null; a?.b?.c`, + nil, + }, + { + `a = 1; a?.b?.c`, + nil, + }, + { + `a = 1; a?.b?.c()`, + nil, + }, + { + `a = {"b" : {"c": 1}}; a?.b?.c`, + 1, + }, + { + `a = {"b": 1}; a.b`, + 1, + }, + } + + for _, tt := range tests { + evaluated := testEval(tt.input) + + switch evaluated.(type) { + case *object.Number: + testNumberObject(t, evaluated, float64(tt.expected.(int))) + default: + testNullObject(t, evaluated) + } + } +} + func TestHashIndexExpressions(t *testing.T) { tests := []struct { input string diff --git a/evaluator/functions.go b/evaluator/functions.go index cd9c284a..1424b549 100644 --- a/evaluator/functions.go +++ b/evaluator/functions.go @@ -116,9 +116,9 @@ func getFns() map[string]*object.Builtin { Types: []string{}, Fn: stdinFn, }, - // env(variable:"PWD") + // env(variable:"PWD") or env(string:"KEY", string:"VAL") "env": &object.Builtin{ - Types: []string{object.STRING_OBJ}, + Types: []string{}, Fn: envFn, }, // arg(position:1) @@ -374,6 +374,22 @@ func validateArgs(tok token.Token, name string, args []object.Object, size int, return nil } +func validateVarArgs(tok token.Token, name string, args []object.Object, required int, types [][][]string) object.Object { + if len(args) < required { + return newError(tok, "wrong number of arguments to %s(...): got=%d, min=%d, max=%d", name, len(args), required, len(types)) + } + + for i, set := range types { + for _, t := range set { + if !util.Contains(t, string(args[i].Type())) { + return newError(tok, "argument %d to %s(...) is not supported (got: %s, allowed: %s)", i, name, args[i].Inspect(), strings.Join(t, ", ")) + } + } + } + + return nil +} + // len(var:"hello") func lenFn(tok token.Token, env *object.Environment, args ...object.Object) object.Object { err := validateArgs(tok, "len", args, 1, [][]string{{object.STRING_OBJ, object.ARRAY_OBJ}}) @@ -704,15 +720,21 @@ func stdinNextFn() (object.Object, object.Object) { return &object.Number{Value: float64(scannerPosition)}, &object.String{Token: tok, Value: scanner.Text()} } -// env(variable:"PWD") +// env(variable:"PWD") or env(string:"KEY", string:"VAL") func envFn(tok token.Token, env *object.Environment, args ...object.Object) object.Object { - err := validateArgs(tok, "env", args, 1, [][]string{{object.STRING_OBJ}}) + err := validateVarArgs(tok, "env", args, 1, [][][]string{{{object.STRING_OBJ}, {object.STRING_OBJ}}}) if err != nil { return err } - arg := args[0].(*object.String) - return &object.String{Token: tok, Value: os.Getenv(arg.Value)} + key := args[0].(*object.String) + + if len(args) > 1 { + val := args[1].(*object.String) + os.Setenv(key.Value, val.Value) + } + + return &object.String{Token: tok, Value: os.Getenv(key.Value)} } // arg(position:1) diff --git a/install/install.go b/install/install.go index 2f23ca38..e653ecfd 100644 --- a/install/install.go +++ b/install/install.go @@ -68,7 +68,7 @@ func printLoader(done chan int64, message string) { } func getZip(module string) error { - path := fmt.Sprintf("./vendor/%s", module) + path := fmt.Sprintf("./vendor/%s-master.zip", module) // Create all the parent directories if needed err := os.MkdirAll(filepath.Dir(path), 0755) @@ -123,7 +123,7 @@ func getZip(module string) error { // Unzip will decompress a zip archive func unzip(module string) error { fmt.Printf("\nUnpacking...") - src := fmt.Sprintf("./vendor/%s", module) + src := fmt.Sprintf("./vendor/%s-master.zip", module) dest := filepath.Dir(src) r, err := zip.OpenReader(src) @@ -133,8 +133,17 @@ func unzip(module string) error { defer r.Close() for _, f := range r.File { + filename := f.Name + parts := strings.Split(f.Name, string(os.PathSeparator)) + if len(parts) > 1 { + if strings.HasSuffix(parts[0], "-master") { + // Trim "master" suffix due to github's naming convention for archives + parts[0] = strings.TrimSuffix(parts[0], "-master") + filename = strings.Join(parts, string(os.PathSeparator)) + } + } // Store filename/path for returning and using later on - fpath := filepath.Join(dest, f.Name) + fpath := filepath.Join(dest, filename) // Check for ZipSlip. More Info: http://bit.ly/2MsjAWE if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { @@ -188,8 +197,7 @@ func createAlias(module string) (string, error) { data := make(map[string]string) moduleName := filepath.Base(module) - // Appending "master" as Github zip file has "-master" suffix - modulePath := fmt.Sprintf("./vendor/%s-master", module) + modulePath := fmt.Sprintf("./vendor/%s", module) // If package.abs.json file is empty if len(b) == 0 { diff --git a/lexer/lexer.go b/lexer/lexer.go index 37077e90..b7698d67 100644 --- a/lexer/lexer.go +++ b/lexer/lexer.go @@ -228,6 +228,8 @@ func (l *Lexer) NextToken() token.Token { } else { tok = l.newToken(token.DOT) } + case '?': + tok = l.newToken(token.QUESTION) case '|': if l.peekChar() == '|' { tok.Type = token.OR diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go index 5127fab2..8af16665 100644 --- a/lexer/lexer_test.go +++ b/lexer/lexer_test.go @@ -105,6 +105,8 @@ for true { continue } a[1:3] +a?.b +a?.b() ` tests := []struct { @@ -353,6 +355,16 @@ a[1:3] {token.COLON, ":"}, {token.NUMBER, "3"}, {token.RBRACKET, "]"}, + {token.IDENT, "a"}, + {token.QUESTION, "?"}, + {token.DOT, "."}, + {token.IDENT, "b"}, + {token.IDENT, "a"}, + {token.QUESTION, "?"}, + {token.DOT, "."}, + {token.IDENT, "b"}, + {token.LPAREN, "("}, + {token.RPAREN, ")"}, {token.EOF, ""}, } diff --git a/main.go b/main.go index cb7f8001..14e101c7 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "github.com/abs-lang/abs/repl" ) -var Version = "1.8.3" +var Version = "1.9.0" // The ABS interpreter func main() { diff --git a/parser/parser.go b/parser/parser.go index f281f76f..f6f9b23d 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -22,7 +22,8 @@ const ( PREFIX // -X or !X CALL // myFunction(X) INDEX // array[index] - DOT // some.function() or some | function() or some.property + QUESTION // some?.function() or some?.property + DOT // some.function() or some.property ) var precedences = map[token.TokenType]int{ @@ -58,6 +59,7 @@ var precedences = map[token.TokenType]int{ token.RANGE: RANGE, token.LPAREN: CALL, token.LBRACKET: INDEX, + token.QUESTION: QUESTION, token.DOT: DOT, } @@ -112,6 +114,7 @@ func New(l *lexer.Lexer) *Parser { p.registerPrefix(token.CONTINUE, p.parseContinue) p.infixParseFns = make(map[token.TokenType]infixParseFn) + p.registerInfix(token.QUESTION, p.parseQuestionExpression) p.registerInfix(token.DOT, p.parseDottedExpression) p.registerInfix(token.PLUS, p.parseInfixExpression) p.registerInfix(token.MINUS, p.parseInfixExpression) @@ -518,6 +521,25 @@ func (p *Parser) parseDottedExpression(object ast.Expression) ast.Expression { } } +// some?.function() or some?.property +// Here we skip the "?" and parse the expression as a regular dotted one. +// When we're back, we mark the expression as optional. +func (p *Parser) parseQuestionExpression(object ast.Expression) ast.Expression { + p.nextToken() + exp := p.parseDottedExpression(object) + + switch res := exp.(type) { + case *ast.PropertyExpression: + res.Optional = true + return res + case *ast.MethodExpression: + res.Optional = true + return res + default: + return exp + } +} + // some.function() func (p *Parser) parseMethodExpression(object ast.Expression) ast.Expression { exp := &ast.MethodExpression{Token: p.curToken, Object: object} diff --git a/parser/parser_test.go b/parser/parser_test.go index b7c564f2..2f079b0e 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -1463,6 +1463,62 @@ func TestParsingProperty(t *testing.T) { } } +func TestParsingOptionalProperty(t *testing.T) { + input := "var?.prop" + + l := lexer.New(input) + p := New(l) + program := p.ParseProgram() + checkParserErrors(t, p) + + stmt, ok := program.Statements[0].(*ast.ExpressionStatement) + propExpr, ok := stmt.Expression.(*ast.PropertyExpression) + + if !ok { + t.Fatalf("exp not *ast.PropertyExpression. got=%T", stmt.Expression) + } + + if !testIdentifier(t, propExpr.Object, "var") { + return + } + + if !testIdentifier(t, propExpr.Property, "prop") { + return + } + + if propExpr.Optional != true { + t.Fatalf("exp is not an optional property") + } +} + +func TestParsingOptionalmethod(t *testing.T) { + input := "var?.prop()" + + l := lexer.New(input) + p := New(l) + program := p.ParseProgram() + checkParserErrors(t, p) + + stmt, ok := program.Statements[0].(*ast.ExpressionStatement) + methodExpr, ok := stmt.Expression.(*ast.MethodExpression) + + if !ok { + t.Fatalf("exp not *ast.methodExpression. got=%T", stmt.Expression) + } + + if !testIdentifier(t, methodExpr.Object, "var") { + return + } + + if !testIdentifier(t, methodExpr.Method, "prop") { + return + } + + if methodExpr.Optional != true { + t.Fatalf("exp is not an optional property") + } +} + func TestParsingEmptyHashLiteral(t *testing.T) { input := "{}" diff --git a/token/token.go b/token/token.go index 708ee23c..1fe8b629 100644 --- a/token/token.go +++ b/token/token.go @@ -66,6 +66,7 @@ const ( LBRACKET = "[" RBRACKET = "]" DOT = "." + QUESTION = "?" COMMAND = "$()" // Keywords