diff --git a/docs/introduction/how-to-run-abs-code.md b/docs/introduction/how-to-run-abs-code.md index 6e750591..9776ad6c 100644 --- a/docs/introduction/how-to-run-abs-code.md +++ b/docs/introduction/how-to-run-abs-code.md @@ -20,7 +20,10 @@ Afterwards, you can run ABS scripts with: $ abs path/to/scripts.abs ``` You can also run an executable abs script directly from bash - using a bash shebang line at the top of the script file: +using a bash shebang line at the top of the script file. + +In this example the abs executable is linked to `/usr/local/bin/abs` +and the abs script `~/bin/remote.abs` has its execute permissions set. ```bash $ cat ~/bin/remote.abs #! /usr/local/bin/abs @@ -80,9 +83,12 @@ by using the up and down arrow keys at the prompt. lines, but the saved history will only contain a single command when the previous command and the current command are the same. -The history file name and the maximum number of lines are -configurable through the OS environment. The default values are -`ABS_HISTORY_FILE="~/.abs_history"` and `ABS_MAX_HISTORY_LINES=1000`. +The history file name and the maximum number of history lines are +configurable through +1) the ABS environment (set by the ABS init file; see below) +2) the OS environment +3) The default values are `ABS_HISTORY_FILE="~/.abs_history"` +and `ABS_MAX_HISTORY_LINES=1000`. + If you wish to suppress the command line history completely, just set `ABS_MAX_HISTORY_LINES=0`. In this case the history file @@ -111,9 +117,73 @@ echo("hello") $ ``` +## ABS Init File + +When the ABS interpreter starts running, it will load an optional +ABS script as its init file. The ABS init file path can be +configured via the OS environment variable `ABS_INIT_FILE`. The +default value is `ABS_INIT_FILE=~/.absrc`. + +If the `ABS_INIT_FILE` exists, it will be evaluated before the +interpreter begins in both interactive REPL or script modes. +The result of all expressions evaluated in the init file become +part of the ABS global environment which are available to command +line expressions or script programs. + +Also, note that the `ABS_INTERACTIVE` global environment variable +is pre-set to `true` or `false` so that the init file can determine +which mode is running. This is useful if you wish to set the ABS REPL +command line prompt or history configuration variables in the init file. +This will preset the prompt and history parameters for the interactive +REPL (see [REPL Command History](#REPL_Command_History) above). + +### Configuring the ABS REPL Command Line Prompt +The ABS REPL command line prompt may be configured at start up using +`ABS_PROMPT_LIVE_PREFIX` and `ABS_PROMPT_PREFIX` variables from either +the ABS or OS environments. The default values are +`ABS_PROMPT_LIVE_PREFIX=false` and `ABS_PROMPT_PREFIX="⧐ "`. + +REPL "static prompt" mode will be configured if `ABS_PROMPT_PREFIX` +contains no live prompt `template string` or if +`ABS_PROMPT_LIVE_PREFIX=false`. The `static prompt` will be the +value of the `ABS_PROMPT_PREFIX` string (if present) or the default +prompt `"⧐ "`. Static prompt mode is the default for the REPL. + +REPL "live prompt" mode follows the current working directory +set by `cd()` when both `ABS_PROMPT_LIVE_PREFIX=true` and the +`ABS_PROMPT_PREFIX` variable contains a live prompt `template string`. + +A live prompt `template string` may contain the following +named placeholders: +* `{user}`: the current userId +* `{host}`: the local hostname +* `{dir}`: the current working directory following `cd()` + +For example, you can create a `bash`-style live prompt: +```bash +$ cat ~/.absrc +# ABS init script ~/.absrc +# For interactive REPL, override default prompt, history filename and size +if ABS_INTERACTIVE { + ABS_PROMPT_LIVE_PREFIX = true + ABS_PROMPT_PREFIX = "{user}@{host}:{dir}$ " + ABS_HISTORY_FILE = "~/.abs_hist" + ABS_MAX_HISTORY_LINES = 500 +} +$ abs +Hello user, welcome to the ABS (1.1.0) programming language! +Type 'quit' when you are done, 'help' if you get lost! +user@hostname:~/git/abs$ cwd = cd() +user@hostname:~$ `ls .absrc` +.absrc +user@hostname:~$ +``` + +Also see a `template ABS Init File` at [examples](https://github.com/abs-lang/abs/tree/master/examples/absrc.abs). + ## Why is abs interpreted? -ABS' goal is to be a portable, pragmatic, coincise, simple language: +ABS' goal is to be a portable, pragmatic, concise, simple language: great performance comes second. With this in mind, we made a deliberate choice to avoid diff --git a/docs/types/builtin-function.md b/docs/types/builtin-function.md index d987378e..5e61b07d 100644 --- a/docs/types/builtin-function.md +++ b/docs/types/builtin-function.md @@ -212,6 +212,72 @@ Halts the process for as many `ms` you specified: sleep(1000) # sleeps for 1 second ``` +### source(fileName) aka require(filename) + +Evaluates the ABS script `fileName` in the context of the ABS global +environment. The results of any expressions in the file become +available to other commands in the REPL command line or to other +scripts in the current script execution chain. + +This is most useful for creating `library functions` in a script +that can be used by many other scripts. Often the library functions +are loaded via the ABS Init File `~/.absrc`. See [ABS Init File](/introduction/how-to-run-abs-code). + +For example: +```bash +$ cat ~/abs/lib/library.abs +# Useful function library ~/abs/lib/library.abs +adder = f(n, i) { n + i } + +$ cat ~/.absrc +# ABS init file ~/.absrc +source("~/abs/lib/library.abs") + +$ abs +Hello user, welcome to the ABS (1.1.0) programming language! +Type 'quit' when you are done, 'help' if you get lost! +⧐ adder(1, 2) +3 +⧐ +``` + +In addition to source file inclusion in scripts, you can also use +`source()` in the interactive REPL to load a script being +debugged. When the loaded script completes, the REPL command line +will have access to all variables and functions evaluated in the +script. + +For example: +```bash +⧐ source("~/git/abs/tests/test-strings.abs") +... +===================== +>>> Testing split and join strings with expanded LFs: +s = split("a\nb\nc", "\n") +echo(s) +[a, b, c] +... +⧐ s +[a, b, c] +⧐ +``` + +Note well that nested source files must not create a circular +inclusion condition. You can configure the intended source file +inclusion depth using the `ABS_SOURCE_DEPTH` OS or ABS environment +variables. The default is `ABS_SOURCE_DEPTH=10`. This will prevent +a panic in the ABS interpreter if there is an unintended circular +source inclusion. + +For example an ABS Init File may contain: +```bash +ABS_SOURCE_DEPTH = 15 +source("~/path/to/abs/lib") +``` +This will limit the source inclusion depth to 15 levels for this +`source()` statement and will also apply to future `source()` +statements until changed. + ## Next That's about it for this section! diff --git a/evaluator/evaluator.go b/evaluator/evaluator.go index d4f75001..2ee49d03 100644 --- a/evaluator/evaluator.go +++ b/evaluator/evaluator.go @@ -26,6 +26,9 @@ var ( Fns map[string]*object.Builtin ) +// This program's global environment can be used by builtin's to modify the env +var globalEnv *object.Environment + // This program's lexer used for error location in Eval(program) var lex *lexer.Lexer @@ -44,6 +47,8 @@ func newError(tok token.Token, format string, a ...interface{}) *object.Error { // REPL and testing modules call this function to init the global lexer pointer for error location // NB. Eval(node, env) is recursive func BeginEval(program ast.Node, env *object.Environment, lexer *lexer.Lexer) object.Object { + // global environment + globalEnv = env // global lexer lex = lexer // run the evaluator diff --git a/evaluator/functions.go b/evaluator/functions.go index 58d2d6b6..637bdf70 100644 --- a/evaluator/functions.go +++ b/evaluator/functions.go @@ -4,6 +4,7 @@ import ( "bufio" "crypto/rand" "fmt" + "io/ioutil" "math" "math/big" "os" @@ -302,6 +303,17 @@ func getFns() map[string]*object.Builtin { Types: []string{object.NUMBER_OBJ}, Fn: sleepFn, }, + // source("fileName") + // aka require() + "source": &object.Builtin{ + Types: []string{object.STRING_OBJ}, + Fn: sourceFn, + }, + // require("fileName") -- alias for source() + "require": &object.Builtin{ + Types: []string{object.STRING_OBJ}, + Fn: sourceFn, + }, } } @@ -497,7 +509,7 @@ func intFn(tok token.Token, args ...object.Object) object.Object { return err } - return applyMathFunction(args[0], func(n float64) float64 { + return applyMathFunction(tok, args[0], func(n float64) float64 { return float64(int64(n)) }, "int") } @@ -523,7 +535,7 @@ func roundFn(tok token.Token, args ...object.Object) object.Object { decimal = float64(math.Pow(10, args[1].(*object.Number).Value)) } - return applyMathFunction(args[0], func(n float64) float64 { + return applyMathFunction(tok, args[0], func(n float64) float64 { return math.Round(n*decimal) / decimal }, "round") } @@ -536,7 +548,7 @@ func floorFn(tok token.Token, args ...object.Object) object.Object { return err } - return applyMathFunction(args[0], math.Floor, "floor") + return applyMathFunction(tok, args[0], math.Floor, "floor") } // ceil(string:"123.1") @@ -547,7 +559,7 @@ func ceilFn(tok token.Token, args ...object.Object) object.Object { return err } - return applyMathFunction(args[0], math.Ceil, "ceil") + return applyMathFunction(tok, args[0], math.Ceil, "ceil") } // Base function to do math operations. This is here @@ -555,7 +567,8 @@ func ceilFn(tok token.Token, args ...object.Object) object.Object { // between all math functions, for example: // - allowing to be called on strings as well ("1.23".ceil()) // - handling errors -func applyMathFunction(arg object.Object, fn func(float64) float64, fname string) object.Object { +// NB. callers must pass the token that is used for error line reporting +func applyMathFunction(tok token.Token, arg object.Object, fn func(float64) float64, fname string) object.Object { switch arg := arg.(type) { case *object.Number: return &object.Number{Token: tok, Value: float64(fn(arg.Value))} @@ -1408,3 +1421,81 @@ func sleepFn(tok token.Token, args ...object.Object) object.Object { return NULL } + +// source("fileName") +// aka require() +const ABS_SOURCE_DEPTH = "10" + +var sourceDepth, _ = strconv.Atoi(ABS_SOURCE_DEPTH) +var sourceLevel = 0 + +func sourceFn(tok token.Token, args ...object.Object) object.Object { + err := validateArgs(tok, "source", args, 1, [][]string{{object.STRING_OBJ}}) + if err != nil { + // reset the source level + sourceLevel = 0 + return err + } + + // get configured source depth if any + sourceDepthStr := util.GetEnvVar(globalEnv, "ABS_SOURCE_DEPTH", ABS_SOURCE_DEPTH) + sourceDepth, _ = strconv.Atoi(sourceDepthStr) + + // limit source file inclusion depth + if sourceLevel >= sourceDepth { + // reset the source level + sourceLevel = 0 + // use errObj.Message instead of errObj.Inspect() to avoid nested "ERROR: " prefixes + errObj := newError(tok, "maximum source file inclusion depth exceeded at %d levels", sourceDepth) + errObj = &object.Error{Message: errObj.Message} + return errObj + } + // mark this source level + sourceLevel++ + + // load the source file + fileName, _ := util.ExpandPath(args[0].Inspect()) + code, error := ioutil.ReadFile(fileName) + if error != nil { + // reset the source level + sourceLevel = 0 + // cannot read source file + return newError(tok, "cannot read source file: %s:\n%s", fileName, error.Error()) + } + // parse it + l := lexer.New(string(code)) + p := parser.New(l) + program := p.ParseProgram() + errors := p.Errors() + if len(errors) != 0 { + // reset the source level + sourceLevel = 0 + errMsg := fmt.Sprintf("%s", " parser errors:\n") + for _, msg := range errors { + errMsg += fmt.Sprintf("%s", "\t"+msg+"\n") + } + return newError(tok, "error found in source file: %s\n%s", fileName, errMsg) + } + // invoke BeginEval() passing in the sourced program, globalEnv, and our lexer + // we save the current global lexer and restore it after we return from BeginEval() + // NB. saving the lexer allows error line numbers to be relative to any nested source files + savedLexer := lex + evaluated := BeginEval(program, globalEnv, l) + lex = savedLexer + if evaluated != nil { + isError := evaluated.Type() == object.ERROR_OBJ + if isError { + // reset the source level + sourceLevel = 0 + // use errObj.Message instead of errObj.Inspect() to avoid nested "ERROR: " prefixes + evalErrMsg := evaluated.(*object.Error).Message + sourceErrMsg := newError(tok, "error found in source file: %s", fileName).Message + errObj := &object.Error{Message: fmt.Sprintf("%s\n\t%s", evalErrMsg, sourceErrMsg)} + return errObj + } + } + // restore this source level + sourceLevel-- + + return evaluated +} diff --git a/examples/absrc.abs b/examples/absrc.abs new file mode 100644 index 00000000..8d4989a6 --- /dev/null +++ b/examples/absrc.abs @@ -0,0 +1,12 @@ +## ABS init script ~/.absrc + +## For interactive REPL, override default prompt, history filename and size +# if ABS_INTERACTIVE { +# ABS_PROMPT_LIVE_PREFIX = true +# ABS_PROMPT_PREFIX = "{user}@{host}:{dir}$ " +# ABS_HISTORY_FILE = "~/.abs_history" +# ABS_MAX_HISTORY_LINES = 1000 +# } + +## source your function libraries here +# source("/path/to/abs/lib/library.abs") diff --git a/main.go b/main.go index 730acaeb..c8e35cf5 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,7 @@ package main import ( "fmt" - "io/ioutil" "os" - "os/user" - "strings" "github.com/abs-lang/abs/repl" ) @@ -14,34 +11,11 @@ var VERSION = "1.2.0" // The ABS interpreter func main() { - user, err := user.Current() - if err != nil { - panic(err) - } - args := os.Args - if len(args) == 2 && args[1] == "--version" { fmt.Println(VERSION) return } - - // if we're called without arguments, - // launch the REPL - if len(args) == 1 || strings.HasPrefix(args[1], "-") { - fmt.Printf("Hello %s, welcome to the ABS (%s) programming language!\n", user.Username, VERSION) - fmt.Printf("Type 'quit' when you're done, 'help' if you get lost!\n") - repl.Start(os.Stdin, os.Stdout) - return - } - - // let's parse our argument as a file - code, err := ioutil.ReadFile(args[1]) - - if err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } - - repl.Run(string(code), false) + // begin the REPL + repl.BeginRepl(args, VERSION) } diff --git a/repl/history.go b/repl/history.go index 3142bc88..28733580 100644 --- a/repl/history.go +++ b/repl/history.go @@ -29,24 +29,21 @@ const ( ABS_MAX_HISTORY_LINES = "1000" ) -// expand full path to ABS_HISTORY_FILE for current user and get ABS_MAX_HISTORY_LINES +// Expand full path to ABS_HISTORY_FILE for current user and get ABS_MAX_HISTORY_LINES +// 1) we look in the ABS global environment as these vars can be set by the ABS init file +// 2) we look in the OS environment +// 3) we use the constant defaults func getHistoryConfiguration() (string, int) { - // obtain any OS environment variables + // obtain any ABS global environment vars or OS environment vars // ABS_MAX_HISTORY_LINES - maxHistoryLines := os.Getenv("ABS_MAX_HISTORY_LINES") - if len(maxHistoryLines) == 0 { - maxHistoryLines = ABS_MAX_HISTORY_LINES - } - maxLines, ok := strconv.Atoi(maxHistoryLines) - if ok != nil { + maxHistoryLines := util.GetEnvVar(env, "ABS_MAX_HISTORY_LINES", ABS_MAX_HISTORY_LINES) + maxLines, err := strconv.Atoi(maxHistoryLines) + if err != nil { maxLines, _ = strconv.Atoi(ABS_MAX_HISTORY_LINES) fmt.Printf("ABS_MAX_HISTORY_LINES must be an integer: %s; using default: %d\n", maxHistoryLines, maxLines) } // ABS_HISTORY_FILE - historyFile := os.Getenv("ABS_HISTORY_FILE") - if len(historyFile) == 0 { - historyFile = ABS_HISTORY_FILE - } + historyFile := util.GetEnvVar(env, "ABS_HISTORY_FILE", ABS_HISTORY_FILE) if maxLines > 0 { // expand the ABS_HISTORY_FILE to the user's HomeDir filePath, err := util.ExpandPath(historyFile) diff --git a/repl/init.go b/repl/init.go new file mode 100644 index 00000000..fdfabcad --- /dev/null +++ b/repl/init.go @@ -0,0 +1,58 @@ +package repl + +import ( + "fmt" + "io/ioutil" + "os" + "os/user" + "strings" + + "github.com/abs-lang/abs/util" +) + +// support for ABS init file +const ABS_INIT_FILE = "~/.absrc" + +func getAbsInitFile(interactive bool) { + // get ABS_INIT_FILE from OS environment or default + initFile := os.Getenv("ABS_INIT_FILE") + if len(initFile) == 0 { + initFile = ABS_INIT_FILE + } + // expand the ABS_INIT_FILE to the user's HomeDir + filePath, err := util.ExpandPath(initFile) + if err != nil { + fmt.Printf("Unable to expand ABS init file path: %s\nError: %s\n", initFile, err.Error()) + os.Exit(99) + } + initFile = filePath + // read and eval the abs init file + code, err := ioutil.ReadFile(initFile) + if err != nil { + // abs init file is optional -- nothing to do here + return + } + Run(string(code), interactive) +} + +// support for user config of ABS REPL prompt string +const ABS_PROMPT_PREFIX = "⧐ " + +// format ABS_PROMPT_PREFIX = "{user}@{host}:{dir} $" +func formatLivePrefix(prefix string) string { + livePrefix := prefix + if strings.Contains(prefix, "{") { + userInfo, _ := user.Current() + user := userInfo.Username + host, _ := os.Hostname() + dir, _ := os.Getwd() + // shorten homedir to ~/ + homeDir := userInfo.HomeDir + dir = strings.Replace(dir, homeDir, "~", 1) + // format the livePrefix + livePrefix = strings.Replace(livePrefix, "{user}", user, 1) + livePrefix = strings.Replace(livePrefix, "{host}", host, 1) + livePrefix = strings.Replace(livePrefix, "{dir}", dir, 1) + } + return livePrefix +} diff --git a/repl/repl.go b/repl/repl.go index c83113fb..f1c9620a 100644 --- a/repl/repl.go +++ b/repl/repl.go @@ -3,12 +3,16 @@ package repl import ( "fmt" "io" + "io/ioutil" "os" + "os/user" + "strings" "github.com/abs-lang/abs/evaluator" "github.com/abs-lang/abs/lexer" "github.com/abs-lang/abs/object" "github.com/abs-lang/abs/parser" + "github.com/abs-lang/abs/util" prompt "github.com/c-bata/go-prompt" ) @@ -55,23 +59,40 @@ var LivePrefixState struct { } func changeLivePrefix() (string, bool) { - return LivePrefixState.LivePrefix, LivePrefixState.IsEnable + livePrefix := formatLivePrefix(LivePrefixState.LivePrefix) + return livePrefix, LivePrefixState.IsEnable } func Start(in io.Reader, out io.Writer) { // get history file only when interactive REPL is running historyFile, maxLines = getHistoryConfiguration() history = getHistory(historyFile, maxLines) + // get prompt prefix template string + promptPrefix := util.GetEnvVar(env, "ABS_PROMPT_PREFIX", ABS_PROMPT_PREFIX) + // get live prompt boolean + livePrompt := util.GetEnvVar(env, "ABS_PROMPT_LIVE_PREFIX", "false") + if livePrompt == "true" { + LivePrefixState.LivePrefix = promptPrefix + LivePrefixState.IsEnable = true + } else { + if promptPrefix != formatLivePrefix(promptPrefix) { + // we have a template string when livePrompt mode is turned off + // use default static prompt instead + promptPrefix = ABS_PROMPT_PREFIX + } + } + // create and start the command prompt run loop p := prompt.New( executor, completer, - prompt.OptionPrefix("⧐ "), + prompt.OptionPrefix(promptPrefix), prompt.OptionLivePrefix(changeLivePrefix), prompt.OptionTitle("abs-repl"), prompt.OptionHistory(history), ) p.Run() + // we get here on ^D from the prompt saveHistory(historyFile, maxLines, history) } @@ -156,3 +177,49 @@ func printParserErrors(errors []string) { fmt.Printf("%s", "\t"+msg+"\n") } } + +// BeginRepl (args) -- the REPL, both interactive and script modes begin here +// This allows us to prime the global env with ABS_INTERACTIVE = true/false, +// load the builtin Fns names for the use of command completion, and +// load the ABS_INIT_FILE into the global env +func BeginRepl(args []string, version string) { + // if we're called without arguments, this is interactive REPL, otherwise a script + var interactive bool + if len(args) == 1 || strings.HasPrefix(args[1], "-") { + interactive = true + env.Set("ABS_INTERACTIVE", evaluator.TRUE) + } else { + interactive = false + env.Set("ABS_INTERACTIVE", evaluator.FALSE) + } + + // get abs init file + // user may test ABS_INTERACTIVE to decide what code to run + getAbsInitFile(interactive) + + if interactive { + // preload the ABS global env with the builtin Fns names + for k, v := range evaluator.Fns { + env.Set(k, v) + } + // launch the interactive REPL + user, err := user.Current() + if err != nil { + panic(err) + } + fmt.Printf("Hello %s, welcome to the ABS (%s) programming language!\n", user.Username, version) + fmt.Printf("Type 'quit' when you're done, 'help' if you get lost!\n") + Start(os.Stdin, os.Stdout) + } else { + // this is a script + // let's parse our argument as a file and run it + code, err := ioutil.ReadFile(args[1]) + if err != nil { + fmt.Println(err.Error()) + os.Exit(99) + } + + Run(string(code), false) + } + +} diff --git a/tests/test-absrc.abs b/tests/test-absrc.abs new file mode 100644 index 00000000..da92aa1e --- /dev/null +++ b/tests/test-absrc.abs @@ -0,0 +1,8 @@ +## ABS init script +## For interactive REPL, set prompt and override default history filename and size +if ABS_INTERACTIVE { + ABS_PROMPT_LIVE_PREFIX = true + ABS_PROMPT_PREFIX = "{user}@{host}:{dir}$ " + ABS_HISTORY_FILE = "~/.abs_hist" + ABS_MAX_HISTORY_LINES = 500 +} diff --git a/tests/test-source-file-x.abs b/tests/test-source-file-x.abs new file mode 100644 index 00000000..d4de10ea --- /dev/null +++ b/tests/test-source-file-x.abs @@ -0,0 +1,2 @@ +# source the next level +source("tests/test-source-file-y.abs") diff --git a/tests/test-source-file-y.abs b/tests/test-source-file-y.abs new file mode 100644 index 00000000..e12fe279 --- /dev/null +++ b/tests/test-source-file-y.abs @@ -0,0 +1,4 @@ +# source an unknown file + +source("tests/test-source-file-z.abs") + diff --git a/tests/test-source-file.abs b/tests/test-source-file.abs new file mode 100644 index 00000000..cdf254cc --- /dev/null +++ b/tests/test-source-file.abs @@ -0,0 +1,14 @@ +# source some files + +# source("tests/test-hash-funcs.abs") +# echo("sourced hash h = %v", h) +# return [h, true] + +# src("tests/test-assign-index.abs") +# echo("sourced hash h = %v", h) +# echo("sourced array a = %v", a) + +# test recursion at given depth +ABS_SOURCE_DEPTH = 5 +source("tests/test-source-file-x.abs") + diff --git a/util/util.go b/util/util.go index 7051060b..511db3d8 100644 --- a/util/util.go +++ b/util/util.go @@ -1,9 +1,12 @@ package util import ( + "os" "os/user" "path/filepath" "strconv" + + "github.com/abs-lang/abs/object" ) // Checks whether the element e is in the @@ -35,3 +38,20 @@ func ExpandPath(path string) (string, error) { } return filepath.Join(usr.HomeDir, path[1:]), nil } + +// GetEnvVar (varName, defaultVal) +// Return the varName value from the ABS env, or OS env, or default value in that order +func GetEnvVar(env *object.Environment, varName, defaultVal string) string { + var ok bool + var value string + valueObj, ok := env.Get(varName) + if ok { + value = valueObj.Inspect() + } else { + value = os.Getenv(varName) + if len(value) == 0 { + value = defaultVal + } + } + return value +}