Repository: abs-lang/abs
Pull request #285 (2019-09-18 13:27:06)

Fixes to the ABS installer PR
* if the package file is not present, calls to `require` shouldn't fail
* a package `user/package` might contain different abs files (eg. `user/package/file.abs`). When we resolve calls to `require("package/file.abs")` we should only take `package`, and not the whole `package/file.abs`
* saving aliases was only working for the first package installed, now the installer will save all modules that are being installed locally
* when including a module, if a file is not specified (`require("module")`), we will try to load the `index.abs` file by default

/cc @mingwho 


Patch:
diff --git a/.gitignore b/.gitignore
index 6040af9..c287260 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 docs/_site
 *.ignore
+test-ignore-*
 vendor
 builds/*
 !builds/.gitkeep
diff --git a/docs/_includes/toc.md b/docs/_includes/toc.md
index d42d1d2..89a31e8 100644
--- a/docs/_includes/toc.md
+++ b/docs/_includes/toc.md
@@ -28,6 +28,7 @@
 
 ## Miscellaneous
 
+* [Installing 3rd party libraries](/misc/3pl)
 * [Errors](/misc/error)
 * [Configuring the REPL](/misc/configuring-the-repl)
 * [Runtime](/misc/runtime)
diff --git a/docs/misc/3pl.md b/docs/misc/3pl.md
new file mode 100644
index 0000000..278edb6
--- /dev/null
+++ b/docs/misc/3pl.md
@@ -0,0 +1,92 @@
+# Installing 3rd party libraries
+
+The ABS interpreter comes with a built-in installer for 3rd party libraries,
+very similar to `npm install`, `pip install` or `go get`.
+
+The installer, budled since the `1.8.0` release, is currently **experimental**
+and a few things might change.
+
+In order to install a package, you simply need to run `abs get`:
+
+``` bash
+$ abs get github.com/abs-lang/abs-sample-module 
+🌘  - Downloading archive
+Unpacking...
+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
+also gets an alias to facilitate requiring them in your code, meaning that
+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")
+{"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:
+
+```
+$ abs get github.com/abs-lang/abs-sample-module
+🌗  - Downloading archive
+Unpacking...
+Creating alias...
+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"
+}
+```
+
+If an alias is already taken, the installer will let you know that you
+will need to use the full path when requiring the module:
+
+```
+$ echo '{"abs-sample-module": "xyz"}' > packages.abs.json 
+
+$ abs get github.com/abs-lang/abs-sample-module
+🌘  - Downloading archive
+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")`
+```
+
+When requiring a module, ABS will try to load the `index.abs` file unless
+another file is specified:
+
+```
+$ ~/projects/abs/builds/abs                                          
+Hello alex, welcome to the ABS (1.8.0) programming language!
+Type 'quit' when you're done, 'help' if you get lost!
+
+⧐  require("abs-sample-module")
+{"another": f() {return hello world;}}
+
+⧐  require("abs-sample-module/index.abs")
+{"another": f() {return hello world;}}
+
+⧐  require("abs-sample-module/another.abs")
+f() {return hello world;}
+```
+
+## Supported hosting platforms
+
+Currently, the installer supports modules hosted on:
+
+* GitHub
+
+## Next
+
+That's about it for this section!
+
+You can now head over to read a little bit about [errors](/misc/error).
\ No newline at end of file
diff --git a/docs/types/builtin-function.md b/docs/types/builtin-function.md
index 6610caa..7a5aafa 100644
--- a/docs/types/builtin-function.md
+++ b/docs/types/builtin-function.md
@@ -339,4 +339,4 @@ statements until changed.
 
 That's about it for this section!
 
-You can now head over to read a little bit about [errors](/misc/error).
\ No newline at end of file
+You can now head over to read a little bit about [how to install 3rd party libraries](/misc/3pl).
\ No newline at end of file
diff --git a/evaluator/functions.go b/evaluator/functions.go
index c7b19b3..ce9d0a4 100644
--- a/evaluator/functions.go
+++ b/evaluator/functions.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"crypto/rand"
 	"encoding/csv"
+	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"math"
@@ -1574,31 +1575,31 @@ func sourceFn(tok token.Token, env *object.Environment, args ...object.Object) o
 // require("file.abs")
 var history = make(map[string]string)
 
-type StringFn func(string) (string, error)
+var packageAliases map[string]string
+var packageAliasesLoaded bool
 
 func requireFn(tok token.Token, env *object.Environment, args ...object.Object) object.Object {
-	getAlias := Memoize(util.ReadAliasFromFile)
-	a, error := getAlias(args[0].Inspect())
-	if error != nil {
-		return newError(tok, "error resolving '%s': %s\n", args[0].Inspect(), error.Error())
+	if !packageAliasesLoaded {
+		a, err := ioutil.ReadFile("./packages.abs.json")
+
+		// We couldn't open the packages, file, possibly doesn't exists
+		// and the code shouldn't fail
+		if err == nil {
+			// Try to decode the packages file:
+			// if an error occurs we will simply
+			// ignore it
+			json.Unmarshal(a, &packageAliases)
+		}
+
+		packageAliasesLoaded = true
 	}
 
+	a := util.UnaliasPath(args[0].Inspect(), packageAliases)
 	file := filepath.Join(env.Dir, a)
 	e := object.NewEnvironment(env.Writer, filepath.Dir(file))
 	return doSource(tok, e, file, args...)
 }
 
-func Memoize(fn StringFn) StringFn {
-	return func(str string) (string, error) {
-		if res, ok := history[str]; ok {
-			return res, nil
-		}
-		res, err := fn(str)
-		history[str] = res
-		return res, err
-	}
-}
-
 func doSource(tok token.Token, env *object.Environment, fileName string, args ...object.Object) object.Object {
 	err := validateArgs(tok, "source", args, 1, [][]string{{object.STRING_OBJ}})
 	if err != nil {
diff --git a/install/install.go b/install/install.go
index bb7bdee..2f23ca3 100644
--- a/install/install.go
+++ b/install/install.go
@@ -18,7 +18,6 @@ func valid(module string) bool {
 }
 
 func Install(module string) {
-
 	if !valid(module) {
 		fmt.Printf(`Error reading URL. Please use "github.com/USER/REPO" format to install`)
 		return
@@ -44,7 +43,7 @@ func Install(module string) {
 	return
 }
 
-func PrintLoader(done chan int64, message string) {
+func printLoader(done chan int64, message string) {
 	var stop bool = false
 	symbols := []string{"🌑 ", "🌒 ", "🌓 ", "🌔 ", "🌕 ", "🌖 ", "🌗 ", "🌘 "}
 	i := 0
@@ -92,7 +91,7 @@ func getZip(module string) error {
 	url := fmt.Sprintf("https://%s/archive/master.zip", module)
 
 	done := make(chan int64)
-	go PrintLoader(done, "Downloading archive")
+	go printLoader(done, "Downloading archive")
 
 	req, err := http.NewRequest("GET", url, nil)
 	if err != nil {
@@ -189,21 +188,19 @@ 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)
 
 	// If package.abs.json file is empty
 	if len(b) == 0 {
-		// Appending "master" as Github zip file has "-master" suffix
 		// Add alias key-value pair to file
 		data[moduleName] = modulePath
-
 	} else {
 		err = json.Unmarshal(b, &data)
 		if err != nil {
 			fmt.Printf("Could not unmarshal alias json %s\n", err)
 			return "", err
 		}
-
 		// module already installed and aliased
 		if data[moduleName] == modulePath {
 			return moduleName, nil
@@ -211,12 +208,14 @@ func createAlias(module string) (string, error) {
 
 		if data[moduleName] != "" {
 			fmt.Printf("This module could not be aliased because module of same name exists\n")
-			moduleName = module
-			data[moduleName] = modulePath
+			return modulePath, nil
 		}
+
+		data[moduleName] = modulePath
 	}
 
 	newData, err := json.MarshalIndent(data, "", "    ")
+
 	if err != nil {
 		fmt.Printf("Could not marshal alias json when installing module %s\n", err)
 		return "", err
diff --git a/util/util.go b/util/util.go
index 91a8b9a..e5a06c4 100644
--- a/util/util.go
+++ b/util/util.go
@@ -1,13 +1,12 @@
 package util
 
 import (
-	"encoding/json"
-	"io/ioutil"
 	"os"
 	"os/user"
 	"path/filepath"
 	"regexp"
 	"strconv"
+	"strings"
 
 	"github.com/abs-lang/abs/object"
 )
@@ -103,16 +102,38 @@ func UniqueStrings(slice []string) []string {
 	return list
 }
 
-func ReadAliasFromFile(path string) (string, error) {
-	var packageAlias map[string]string
-	a, _ := ioutil.ReadFile("./packages.abs.json")
-	err := json.Unmarshal(a, &packageAlias)
-	if err != nil {
-		return path, err
+// UnaliasPath translates a path alias
+// to the full path in the filesystem.
+func UnaliasPath(path string, packageAlias map[string]string) string {
+	// An alias can come in different forms:
+	//  - package
+	//  - package/file.abs
+	// but we only really need to resolve the
+	// first path in the alias.
+	parts := strings.Split(path, string(os.PathSeparator))
+
+	if len(parts) < 1 {
+		return path
+	}
+
+	if packageAlias[parts[0]] != "" {
+		// If we are able to resolve a path, then
+		// we should join in back with the rest of the
+		// paths
+		p := []string{packageAlias[parts[0]]}
+		p = append(p, parts[1:]...)
+		path = filepath.Join(p...)
 	}
+	return appendIndexFile(path)
+}
 
-	if packageAlias[path] != "" {
-		return packageAlias[path], nil
+// If our path didn't end with an ABS file (.abs),
+// let's assume it's a directory and we will
+// auto-include the index.abs file from it
+func appendIndexFile(path string) string {
+	if filepath.Ext(path) != ".abs" {
+		return filepath.Join(path, "index.abs")
 	}
-	return path, nil
+
+	return path
 }


Test patch:
diff --git a/evaluator/evaluator_test.go b/evaluator/evaluator_test.go
index cc0d760..a4a2024 100644
--- a/evaluator/evaluator_test.go
+++ b/evaluator/evaluator_test.go
@@ -945,10 +945,10 @@ c")`, []string{"a", "b", "c"}},
 		{`sleep(0.01)`, nil},
 		{`$()`, ""},
 		{`a = 1; eval("a")`, 1},
-		{`"a = 2; return 10" >> "test-source-vs-require.abs.ignore"; a = 1; x = source("test-source-vs-require.abs.ignore"); a`, 2},
-		{`"a = 2; return 10" >> "test-source-vs-require.abs.ignore"; a = 1; x = require("test-source-vs-require.abs.ignore"); a`, 1},
-		{`"a = 2; return 10" >> "test-source-vs-require.abs.ignore"; a = 1; x = source("test-source-vs-require.abs.ignore"); x`, 10},
-		{`"a = 2; return 10" >> "test-source-vs-require.abs.ignore"; a = 1; x = require("test-source-vs-require.abs.ignore"); x`, 10},
+		{`"a = 2; return 10" >> "test-ignore-source-vs-require.abs"; a = 1; x = source("test-ignore-source-vs-require.abs"); a`, 2},
+		{`"a = 2; return 10" >> "test-ignore-source-vs-require.abs"; a = 1; x = require("test-ignore-source-vs-require.abs"); a`, 1},
+		{`"a = 2; return 10" >> "test-ignore-source-vs-require.abs"; a = 1; x = source("test-ignore-source-vs-require.abs"); x`, 10},
+		{`"a = 2; return 10" >> "test-ignore-source-vs-require.abs"; a = 1; x = require("test-ignore-source-vs-require.abs"); x`, 10},
 		{`[[1,2,3], [2,3,4]].tsv()`, "1\t2\t3\n2\t3\t4"},
 		{`[1].tsv()`, "tsv() must be called on an array of arrays or objects, such as [[1, 2, 3], [4, 5, 6]], '[1]' given"},
 		{`[{"c": 3, "b": "hello"}, {"b": 20, "c": 0}].tsv()`, "b\tc\nhello\t3\n20\t0"},
diff --git a/util/util_test.go b/util/util_test.go
new file mode 100644
index 0000000..8cf83df
--- /dev/null
+++ b/util/util_test.go
@@ -0,0 +1,78 @@
+package util
+
+import (
+	"os"
+	"testing"
+)
+
+func TestUnaliasPath(t *testing.T) {
+	tests := []struct {
+		path     string
+		aliases  map[string]string
+		expected string
+	}{
+		{"test", map[string]string{}, "test" + string(os.PathSeparator) + "index.abs"},
+		{"test" + string(os.PathSeparator) + "sample.abs", map[string]string{}, "test" + string(os.PathSeparator) + "sample.abs"},
+		{"test" + string(os.PathSeparator) + "sample.abs", map[string]string{"test": "path"}, "path" + string(os.PathSeparator) + "sample.abs"},
+		{"test", map[string]string{"test": "path"}, "path" + string(os.PathSeparator) + "index.abs"},
+		{"." + string(os.PathSeparator) + "test", map[string]string{"test": "path"}, "test" + string(os.PathSeparator) + "index.abs"},
+	}
+
+	for _, tt := range tests {
+		res := UnaliasPath(tt.path, tt.aliases)
+
+		if res != tt.expected {
+			t.Fatalf("error unaliasing path, expected %s, got %s", tt.expected, res)
+		}
+	}
+}
+
+func TestUniqueStrings(t *testing.T) {
+	tests := []struct {
+		strings []string
+		len     int
+	}{
+		{[]string{"a", "b", "c"}, 3},
+		{[]string{"a", "a", "a"}, 1},
+	}
+
+	for _, tt := range tests {
+		if len(UniqueStrings(tt.strings)) != tt.len {
+			t.Fatalf("expected %d, got %d", tt.len, len(UniqueStrings(tt.strings)))
+		}
+	}
+}
+
+func TestContains(t *testing.T) {
+	tests := []struct {
+		strings  []string
+		match    string
+		expected bool
+	}{
+		{[]string{"a", "b", "c"}, "a", true},
+		{[]string{"a", "a", "a"}, "d", false},
+	}
+
+	for _, tt := range tests {
+		if tt.expected != Contains(tt.strings, tt.match) {
+			t.Fatalf("expected %v", tt.expected)
+		}
+	}
+}
+
+func TestIsNumber(t *testing.T) {
+	tests := []struct {
+		number   string
+		expected bool
+	}{
+		{"12", true},
+		{"12a", false},
+		{"12.2", true},
+	}
+
+	for _, tt := range tests {
+		if tt.expected != IsNumber(tt.number) {
+			t.Fatalf("expected %v (%s)", tt.expected, tt.number)
+		}
+	}
+}