Repository: d5/tengo (go)

Issue:
Enable relative imports
Importing a module `tre.tengo` 
```
fun := func(a) {
  return a
}

tre := {
  fun: fun
}
export tre
```
in another tengo script `due.tengo` (both files are in the same folder) 
```
tre := import(`tre`)

funcy := func(a) {
  return tre.fun(a)
}

due := {
  funcy: funcy
}
export due
```
throws an error, when tengo is invoked as CLI from another directory, e.g. the parent folder :

>Compile Error: module file read error: open tre.tengo: no such file or directory
        at due.tengo:2:8

It works fine however when tengo is invoked from within the folder. This leads me to suspect the import is done relative to the working directory. Relative imports feel more intuitive though, or am I wrong. Is there already a way to import relative to another file?

Pull request:
enable relative imports
After considering what could be effective, I came up with this implementation. Docs and some tests are missing and will be added if it is accepted. It closes #280 .
Implementation is based on @d5 [comment](https://github.com/d5/tengo/issues/280#issuecomment-630585317)
Notes:
- `checkCyclicImport` call removed which was called before `compileModule` in `Compiler` for file imports. It is already called in `compileModule`.
- I added testdata folder and bunch of tengo scripts under `cmd/tengo` for testing relative imports with CI (testdata folder may be moved to lib root folder).
- At Go side, user can set import dir to search importable tengo files which is updated for each module compiler based on file's dir.
- Absolute paths are used to ensure detecting cyclic imports.
- Physical file modules can be a symbolic link, they are resolved as well but following symbolic links is limited to max 2 levels (who needs more?).
- Tengo CLI compiles and runs both compiled and module files without any problem.
- I have only Linux machine, I dont know if this implementation works well on windows/mac (TODO).

Comments please @d5 @geseq 

Patch:
diff --git a/Makefile b/Makefile
index 793bc12..d461b66 100644
--- a/Makefile
+++ b/Makefile
@@ -6,6 +6,7 @@ lint:
 
 test: generate lint
 	go test -race -cover ./...
+	go run ./cmd/tengo -resolve ./testdata/cli/test.tengo
 
 fmt:
 	go fmt ./...
diff --git a/cmd/tengo/main.go b/cmd/tengo/main.go
index afddd68..a8e1498 100644
--- a/cmd/tengo/main.go
+++ b/cmd/tengo/main.go
@@ -25,6 +25,7 @@ var (
 	compileOutput string
 	showHelp      bool
 	showVersion   bool
+	resolvePath   bool // TODO Remove this flag at version 3
 	version       = "dev"
 )
 
@@ -32,6 +33,8 @@ func init() {
 	flag.BoolVar(&showHelp, "help", false, "Show help")
 	flag.StringVar(&compileOutput, "o", "", "Compile output file")
 	flag.BoolVar(&showVersion, "version", false, "Show version")
+	flag.BoolVar(&resolvePath, "resolve", false,
+		"Resolve relative import paths")
 	flag.Parse()
 }
 
@@ -55,10 +58,20 @@ func main() {
 	inputData, err := ioutil.ReadFile(inputFile)
 	if err != nil {
 		_, _ = fmt.Fprintf(os.Stderr,
-			"Error reading input file: %s", err.Error())
+			"Error reading input file: %s\n", err.Error())
 		os.Exit(1)
 	}
 
+	inputFile, err = filepath.Abs(inputFile)
+	if err != nil {
+		_, _ = fmt.Fprintf(os.Stderr, "Error file path: %s\n", err)
+		os.Exit(1)
+	}
+
+	if len(inputData) > 1 && string(inputData[:2]) == "#!" {
+		copy(inputData, "//")
+	}
+
 	if compileOutput != "" {
 		err := CompileOnly(modules, inputData, inputFile,
 			compileOutput)
@@ -67,9 +80,6 @@ func main() {
 			os.Exit(1)
 		}
 	} else if filepath.Ext(inputFile) == sourceFileExt {
-		if len(inputData) > 1 && string(inputData[:2]) == "#!" {
-			copy(inputData, "//")
-		}
 		err := CompileAndRun(modules, inputData, inputFile)
 		if err != nil {
 			_, _ = fmt.Fprintln(os.Stderr, err.Error())
@@ -90,7 +100,7 @@ func CompileOnly(
 	data []byte,
 	inputFile, outputFile string,
 ) (err error) {
-	bytecode, err := compileSrc(modules, data, filepath.Base(inputFile))
+	bytecode, err := compileSrc(modules, data, inputFile)
 	if err != nil {
 		return
 	}
@@ -125,7 +135,7 @@ func CompileAndRun(
 	data []byte,
 	inputFile string,
 ) (err error) {
-	bytecode, err := compileSrc(modules, data, filepath.Base(inputFile))
+	bytecode, err := compileSrc(modules, data, inputFile)
 	if err != nil {
 		return
 	}
@@ -215,10 +225,10 @@ func RunREPL(modules *tengo.ModuleMap, in io.Reader, out io.Writer) {
 func compileSrc(
 	modules *tengo.ModuleMap,
 	src []byte,
-	filename string,
+	inputFile string,
 ) (*tengo.Bytecode, error) {
 	fileSet := parser.NewFileSet()
-	srcFile := fileSet.AddFile(filename, -1, len(src))
+	srcFile := fileSet.AddFile(filepath.Base(inputFile), -1, len(src))
 
 	p := parser.NewParser(srcFile, src, nil)
 	file, err := p.ParseFile()
@@ -228,6 +238,9 @@ func compileSrc(
 
 	c := tengo.NewCompiler(srcFile, nil, nil, modules, nil)
 	c.EnableFileImport(true)
+	if resolvePath {
+		c.SetImportDir(filepath.Dir(inputFile))
+	}
 
 	if err := c.Compile(file); err != nil {
 		return nil, err
diff --git a/compiler.go b/compiler.go
index 1e2a494..cb1c8f3 100644
--- a/compiler.go
+++ b/compiler.go
@@ -44,6 +44,7 @@ type Compiler struct {
 	file            *parser.SourceFile
 	parent          *Compiler
 	modulePath      string
+	importDir       string
 	constants       []Object
 	symbolTable     *SymbolTable
 	scopes          []compilationScope
@@ -520,7 +521,7 @@ func (c *Compiler) Compile(node parser.Node) error {
 			switch v := v.(type) {
 			case []byte: // module written in Tengo
 				compiled, err := c.compileModule(node,
-					node.ModuleName, node.ModuleName, v)
+					node.ModuleName, v, false)
 				if err != nil {
 					return err
 				}
@@ -537,24 +538,20 @@ func (c *Compiler) Compile(node parser.Node) error {
 				moduleName += ".tengo"
 			}
 
-			modulePath, err := filepath.Abs(moduleName)
+			modulePath, err := filepath.Abs(
+				filepath.Join(c.importDir, moduleName))
 			if err != nil {
 				return c.errorf(node, "module file path error: %s",
 					err.Error())
 			}
 
-			if err := c.checkCyclicImports(node, modulePath); err != nil {
-				return err
-			}
-
-			moduleSrc, err := ioutil.ReadFile(moduleName)
+			moduleSrc, err := ioutil.ReadFile(modulePath)
 			if err != nil {
 				return c.errorf(node, "module file read error: %s",
 					err.Error())
 			}
 
-			compiled, err := c.compileModule(node,
-				moduleName, modulePath, moduleSrc)
+			compiled, err := c.compileModule(node, modulePath, moduleSrc, true)
 			if err != nil {
 				return err
 			}
@@ -634,6 +631,11 @@ func (c *Compiler) EnableFileImport(enable bool) {
 	c.allowFileImport = enable
 }
 
+// SetImportDir sets the initial import directory path for file imports.
+func (c *Compiler) SetImportDir(dir string) {
+	c.importDir = dir
+}
+
 func (c *Compiler) compileAssign(
 	node parser.Node,
 	lhs, rhs []parser.Expr,
@@ -847,8 +849,8 @@ func (c *Compiler) compileForInStmt(stmt *parser.ForInStmt) error {
 	//     ... body ...
 	//   }
 	//
-	// ":it" is a local variable but will be conflict with other user variables
-	// because character ":" is not allowed.
+	// ":it" is a local variable but it will not conflict with other user variables
+	// because character ":" is not allowed in the variable names.
 
 	// init
 	//   :it = iterator(iterable)
@@ -893,6 +895,7 @@ func (c *Compiler) compileForInStmt(stmt *parser.ForInStmt) error {
 		if keySymbol.Scope == ScopeGlobal {
 			c.emit(stmt, parser.OpSetGlobal, keySymbol.Index)
 		} else {
+			keySymbol.LocalAssigned = true
 			c.emit(stmt, parser.OpDefineLocal, keySymbol.Index)
 		}
 	}
@@ -909,6 +912,7 @@ func (c *Compiler) compileForInStmt(stmt *parser.ForInStmt) error {
 		if valueSymbol.Scope == ScopeGlobal {
 			c.emit(stmt, parser.OpSetGlobal, valueSymbol.Index)
 		} else {
+			valueSymbol.LocalAssigned = true
 			c.emit(stmt, parser.OpDefineLocal, valueSymbol.Index)
 		}
 	}
@@ -955,8 +959,9 @@ func (c *Compiler) checkCyclicImports(
 
 func (c *Compiler) compileModule(
 	node parser.Node,
-	moduleName, modulePath string,
+	modulePath string,
 	src []byte,
+	isFile bool,
 ) (*CompiledFunction, error) {
 	if err := c.checkCyclicImports(node, modulePath); err != nil {
 		return nil, err
@@ -967,7 +972,7 @@ func (c *Compiler) compileModule(
 		return compiledModule, nil
 	}
 
-	modFile := c.file.Set().AddFile(moduleName, -1, len(src))
+	modFile := c.file.Set().AddFile(modulePath, -1, len(src))
 	p := parser.NewParser(modFile, src, nil)
 	file, err := p.ParseFile()
 	if err != nil {
@@ -984,7 +989,7 @@ func (c *Compiler) compileModule(
 	symbolTable = symbolTable.Fork(false)
 
 	// compile module
-	moduleCompiler := c.fork(modFile, modulePath, symbolTable)
+	moduleCompiler := c.fork(modFile, modulePath, symbolTable, isFile)
 	if err := moduleCompiler.Compile(file); err != nil {
 		return nil, err
 	}
@@ -1082,11 +1087,16 @@ func (c *Compiler) fork(
 	file *parser.SourceFile,
 	modulePath string,
 	symbolTable *SymbolTable,
+	isFile bool,
 ) *Compiler {
 	child := NewCompiler(file, symbolTable, nil, c.modules, c.trace)
 	child.modulePath = modulePath // module file path
 	child.parent = c              // parent to set to current compiler
 	child.allowFileImport = c.allowFileImport
+	child.importDir = c.importDir
+	if isFile && c.importDir != "" {
+		child.importDir = filepath.Dir(modulePath)
+	}
 	return child
 }
 
diff --git a/script.go b/script.go
index 906771d..4f9608c 100644
--- a/script.go
+++ b/script.go
@@ -3,6 +3,7 @@ package tengo
 import (
 	"context"
 	"fmt"
+	"path/filepath"
 	"sync"
 
 	"github.com/d5/tengo/v2/parser"
@@ -16,6 +17,7 @@ type Script struct {
 	maxAllocs        int64
 	maxConstObjects  int
 	enableFileImport bool
+	importDir        string
 }
 
 // NewScript creates a Script instance with an input script.
@@ -56,6 +58,16 @@ func (s *Script) SetImports(modules *ModuleMap) {
 	s.modules = modules
 }
 
+// SetImportDir sets the initial import directory for script files.
+func (s *Script) SetImportDir(dir string) error {
+	dir, err := filepath.Abs(dir)
+	if err != nil {
+		return err
+	}
+	s.importDir = dir
+	return nil
+}
+
 // SetMaxAllocs sets the maximum number of objects allocations during the run
 // time. Compiled script will return ErrObjectAllocLimit error if it
 // exceeds this limit.
@@ -93,6 +105,7 @@ func (s *Script) Compile() (*Compiled, error) {
 
 	c := NewCompiler(srcFile, symbolTable, nil, s.modules, nil)
 	c.EnableFileImport(s.enableFileImport)
+	c.SetImportDir(s.importDir)
 	if err := c.Compile(file); err != nil {
 		return nil, err
 	}


Test patch:
diff --git a/testdata/cli/one.tengo b/testdata/cli/one.tengo
new file mode 100644
index 0000000..30ee366
--- /dev/null
+++ b/testdata/cli/one.tengo
@@ -0,0 +1,7 @@
+
+export {
+    fn: func(a) {
+        two := import("two/two")
+        return two.fn(a, "one")
+    }
+}
\ No newline at end of file
diff --git a/testdata/cli/test.tengo b/testdata/cli/test.tengo
new file mode 100755
index 0000000..41c221a
--- /dev/null
+++ b/testdata/cli/test.tengo
@@ -0,0 +1,15 @@
+#!/usr/bin/env tengo
+
+os := import("os")
+one := import("one")
+fmt := import("fmt")
+text := import("text")
+expected := ["test", "one", "two", "three", "four", "five"]
+expected = text.join(expected, " ")
+if v := one.fn("test"); v != expected {
+    fmt.printf("relative import test error:\n\texpected: %v\n\tgot     : %v\n",
+                expected, v)
+    os.exit(1)
+}
+args := text.join(os.args(), " ")
+fmt.println("ok\t", args)
diff --git a/testdata/cli/three.tengo b/testdata/cli/three.tengo
new file mode 100644
index 0000000..5f21a80
--- /dev/null
+++ b/testdata/cli/three.tengo
@@ -0,0 +1,6 @@
+export {
+    fn: func(a, b, c) {
+        four := import("./two/four/four.tengo")
+        return four.fn(a, b, c, "three")
+    }
+}
\ No newline at end of file
diff --git a/testdata/cli/two/five/five.tengo b/testdata/cli/two/five/five.tengo
new file mode 100644
index 0000000..dd5f7bd
--- /dev/null
+++ b/testdata/cli/two/five/five.tengo
@@ -0,0 +1,7 @@
+export {
+    fn: func(...args) {
+        text := import("text")
+        args = append(args, "five")
+        return text.join(args, " ")
+    }
+}
\ No newline at end of file
diff --git a/testdata/cli/two/four/four.tengo b/testdata/cli/two/four/four.tengo
new file mode 100644
index 0000000..59c79bc
--- /dev/null
+++ b/testdata/cli/two/four/four.tengo
@@ -0,0 +1,6 @@
+export {
+    fn: func(a, b, c, d) {
+        five := import("../five/five")
+        return five.fn(a, b, c, d, "four")
+    }
+}
\ No newline at end of file
diff --git a/testdata/cli/two/two.tengo b/testdata/cli/two/two.tengo
new file mode 100644
index 0000000..7fc9800
--- /dev/null
+++ b/testdata/cli/two/two.tengo
@@ -0,0 +1,6 @@
+export {
+    fn: func(a, b) {
+        three := import("../three")
+        return three.fn(a, b, "two")
+    }
+}
\ No newline at end of file
diff --git a/vm_test.go b/vm_test.go
index 4ffaed7..d451726 100644
--- a/vm_test.go
+++ b/vm_test.go
@@ -1531,7 +1531,23 @@ func TestFunction(t *testing.T) {
 		add2 := newAdder(2);
 		out = add2(5);
 		`, nil, 7)
+	expectRun(t, `
+		m := {a: 1}
+		for k,v in m {
+			func(){
+				out = k
+			}()
+		}
+		`, nil, "a")
 
+	expectRun(t, `
+		m := {a: 1}
+		for k,v in m {
+			func(){
+				out = v
+			}()
+		}
+		`, nil, 1)
 	// function as a argument
 	expectRun(t, `
 	add := func(a, b) { return a + b };