diff --git a/.gitignore b/.gitignore index 36f5ee90..f68ec44d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ -bin -doc/repl/glj.wasm +/*.glj +/*.clj +/.cache/ +/.clj-kondo/ +/.lsp/ +/bin/ +/doc/repl/glj.wasm +/GNUmakefile diff --git a/README.md b/README.md index 44091a45..9d387b83 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,16 @@ $ glj server.glj Server starting on :8080... ``` +### Environment Variables + +Glojure recognizes the following environment variables: + +- **`GLJPATH`** + + Colon-separated list of directories to search for `.glj` libraries. + This allows you to organize Glojure libraries in a custom manner. + Note: Glojure will always search the current directory for `.glj` libraries. + ### Embedding Glojure in Go Applications You can also embed Glojure as a scripting language within your Go applications. diff --git a/internal/deps/embed.go b/internal/deps/embed.go index efe1f7f1..5a8b97c3 100644 --- a/internal/deps/embed.go +++ b/internal/deps/embed.go @@ -67,7 +67,7 @@ func (d *Deps) Embed() error { fmt.Fprintf(b, "func init() {\n") for _, pkg := range d.Pkgs { fsName := mungePath(pkg) + "FS" - fmt.Fprintf(b, "\truntime.AddLoadPath(subfs(%s, %q))\n", fsName, pkg) + fmt.Fprintf(b, "\truntime.AddLoadPath(subfs(%s, %q), %q)\n", fsName, pkg, pkg) } fmt.Fprintf(b, "}\n") diff --git a/pkg/gljmain/gljmain.go b/pkg/gljmain/gljmain.go index cd2c1ca0..57e0c82f 100644 --- a/pkg/gljmain/gljmain.go +++ b/pkg/gljmain/gljmain.go @@ -2,6 +2,7 @@ package gljmain import ( "bufio" + "flag" "fmt" "log" "os" @@ -16,6 +17,84 @@ import ( "github.com/glojurelang/glojure/pkg/runtime" ) +// Args represents the parsed command line arguments +type Args struct { + Mode string // "repl", "version", "help", "eval", "file" + Expression string // for eval mode + Filename string // for file mode + CommandArgs []string // remaining arguments after parsing +} + +// parseArgs parses command line arguments and returns an Args struct +func parseArgs(args []string) (*Args, error) { + fs := flag.NewFlagSet("glj", flag.ExitOnError) + + var ( + helpFlag = fs.Bool("help", false, "Show this help message") + versionFlag = fs.Bool("version", false, "Show version information") + evalFlag = fs.String("e", "", "Evaluate expression from command line") + ) + + fs.Bool("h", false, "Show this help message (shorthand)") + + if err := fs.Parse(args); err != nil { + return nil, err + } + + if *helpFlag || fs.Lookup("h").Value.String() == "true" { + return &Args{Mode: "help"}, nil + } + + if *versionFlag { + return &Args{Mode: "version"}, nil + } + + if *evalFlag != "" { + return &Args{ + Mode: "eval", + Expression: *evalFlag, + CommandArgs: fs.Args(), + }, nil + } + + remainingArgs := fs.Args() + if len(remainingArgs) > 0 { + return &Args{ + Mode: "file", + Filename: remainingArgs[0], + CommandArgs: remainingArgs[1:], + }, nil + } + + return &Args{Mode: "repl"}, nil +} + +// setupLoadPaths configures the library search path +func setupLoadPaths() { + // Add GLJPATH directories to front of load path if set + loadPaths := os.Getenv("GLJPATH") + if loadPaths != "" { + paths := strings.Split(loadPaths, ":") + for i := len(paths) - 1; i >= 0; i-- { + path := paths[i] + if path != "" { + runtime.PrependLoadPathString(path) + } + } + } + + // Add current directory to end of load path + runtime.AddLoadPathString(".") + + // Update the *glojure-load-path* dynamic var with the current load path + if core := lang.FindNamespace(lang.NewSymbol("glojure.core")); core != nil { + loadPathVar := core.FindInternedVar(lang.NewSymbol("*glojure-load-path*")) + if loadPathVar != nil { + loadPathVar.BindRoot(lang.Seq(runtime.GetLoadPath())) + } + } +} + func printHelp() { fmt.Printf(`Glojure v%s @@ -23,8 +102,12 @@ Usage: glj [options] [file] Options: -e Evaluate expression from command line - -h, --help Show this help message + --version Show version information + -h, --help Show this help message + +Environment Variables: + GLJPATH PATH of directories for .glj libraries Examples: glj # Start REPL @@ -38,31 +121,37 @@ For more information, visit: https://github.com/glojurelang/glojure } func Main(args []string) { - runtime.AddLoadPath(os.DirFS(".")) + parsedArgs, err := parseArgs(args) + if err != nil { + log.Fatal(err) + } - if len(args) == 0 { + // Setup library search paths + setupLoadPaths() + + switch parsedArgs.Mode { + case "repl": repl.Start() - } else if args[0] == "--version" { + case "version": fmt.Printf("glojure v%s\n", runtime.VERSION) return - } else if args[0] == "--help" || args[0] == "-h" { + case "help": printHelp() return - } else if args[0] == "-e" { + case "eval": // Evaluate expression from command line - if len(args) < 2 { - log.Fatal("glj: -e requires an expression") - } - expr := args[1] env := lang.GlobalEnv // Set command line args (everything after -e and the expression) core := lang.FindNamespace(lang.NewSymbol("glojure.core")) - core.FindInternedVar(lang.NewSymbol("*command-line-args*")).BindRoot(lang.Seq(args[2:])) + core.FindInternedVar(lang.NewSymbol("*command-line-args*")). + BindRoot(lang.Seq(parsedArgs.CommandArgs)) - rdr := reader.New(strings.NewReader(expr), reader.WithGetCurrentNS(func() *lang.Namespace { - return env.CurrentNamespace() - })) + rdr := reader.New( + strings.NewReader(parsedArgs.Expression), + reader.WithGetCurrentNS(func() *lang.Namespace { + return env.CurrentNamespace() + })) var lastResult interface{} for { val, err := rdr.ReadOne() @@ -82,20 +171,23 @@ func Main(args []string) { if !lang.IsNil(lastResult) { fmt.Println(lang.PrintString(lastResult)) } - } else { + case "file": // Execute file - file, err := os.Open(args[0]) + file, err := os.Open(parsedArgs.Filename) if err != nil { log.Fatal(err) } env := lang.GlobalEnv core := lang.FindNamespace(lang.NewSymbol("glojure.core")) - core.FindInternedVar(lang.NewSymbol("*command-line-args*")).BindRoot(lang.Seq(args[1:])) + core.FindInternedVar(lang.NewSymbol("*command-line-args*")). + BindRoot(lang.Seq(parsedArgs.CommandArgs)) - rdr := reader.New(bufio.NewReader(file), reader.WithGetCurrentNS(func() *lang.Namespace { - return env.CurrentNamespace() - })) + rdr := reader.New( + bufio.NewReader(file), + reader.WithGetCurrentNS(func() *lang.Namespace { + return env.CurrentNamespace() + })) for { val, err := rdr.ReadOne() if err == reader.ErrEOF { diff --git a/pkg/runtime/envinit.go b/pkg/runtime/envinit.go index 153114de..f6e5529b 100644 --- a/pkg/runtime/envinit.go +++ b/pkg/runtime/envinit.go @@ -155,6 +155,12 @@ func NewEnvironment(opts ...EvalOption) lang.Environment { versionVar.BindRoot(ParseVersion(VERSION)) } + // Set the glojure load path + loadPathVar := core.FindInternedVar(lang.NewSymbol("*glojure-load-path*")) + if loadPathVar != nil { + loadPathVar.BindRoot(lang.Seq(GetLoadPath())) + } + return env } diff --git a/pkg/runtime/environment.go b/pkg/runtime/environment.go index f79cc29c..b8372a62 100644 --- a/pkg/runtime/environment.go +++ b/pkg/runtime/environment.go @@ -64,6 +64,7 @@ func newEnvironment(ctx context.Context, stdout, stderr io.Writer) *environment "print-dup", "read-eval", "glojure-version", + "glojure-load-path", } { coreNS.InternWithValue(lang.NewSymbol("*"+dyn+"*"), nil, true).SetDynamic() } diff --git a/pkg/runtime/rtcompat.go b/pkg/runtime/rtcompat.go index 7f848693..f4c4bb8f 100644 --- a/pkg/runtime/rtcompat.go +++ b/pkg/runtime/rtcompat.go @@ -1,9 +1,11 @@ package runtime import ( + "embed" "fmt" "io" "io/fs" + "os" "reflect" "strings" "sync" @@ -16,19 +18,83 @@ import ( . "github.com/glojurelang/glojure/pkg/lang" ) +// loadPathEntry represents a single entry in the load path +type loadPathEntry struct { + fs fs.FS + name string +} + var ( RT = &RTMethods{} - loadPath = []fs.FS{stdlib.StdLib} + loadPath = []loadPathEntry{{fs: stdlib.StdLib, name: ""}} loadPathLock sync.Mutex ) -// AddLoadPath adds a filesystem to the load path. -func AddLoadPath(fs fs.FS) { +// AddLoadPath adds a filesystem with a name to the end of the load path. +func AddLoadPath(fs fs.FS, name string) { + loadPathLock.Lock() + defer loadPathLock.Unlock() + + loadPath = append(loadPath, loadPathEntry{fs: fs, name: name}) +} + +// AddLoadPathString adds a directory path to the end of the load path. +func AddLoadPathString(path string) { + AddLoadPath(os.DirFS(path), path) +} + +// PrependLoadPathString adds a directory path to the front of the load path. +func PrependLoadPathString(path string) { + loadPathLock.Lock() + defer loadPathLock.Unlock() + + loadPath = append([]loadPathEntry{{fs: os.DirFS(path), name: path}}, loadPath...) +} + +// GetLoadPath returns a copy of the current load path as a slice of strings. +// Each string represents a filesystem path that can be used by Glojure. +func GetLoadPath() []string { loadPathLock.Lock() defer loadPathLock.Unlock() - loadPath = append(loadPath, fs) + result := make([]string, len(loadPath)) + for i, entry := range loadPath { + if entry.name != "" { + result[i] = entry.name + } else { + // Fallback to the old logic for unnamed filesystems + switch v := entry.fs.(type) { + case embed.FS: + result[i] = "" + default: + if name := getFSName(v); name != "" { + result[i] = name + } else { + result[i] = fmt.Sprintf("<%v>", v) + } + } + } + } + return result +} + +// getFSName attempts to extract a meaningful name from a filesystem. +// This is a helper function for GetLoadPath. +func getFSName(fsys fs.FS) string { + // Try to read the root directory to see if it's a named filesystem + if entries, err := fs.ReadDir(fsys, "."); err == nil && len(entries) > 0 { + // If it's a subdirectory filesystem, try to get the parent name + if subFS, ok := fsys.(interface{ Name() string }); ok { + return subFS.Name() + } + // For embedded filesystems, we might be able to infer the name + // from the directory structure + if len(entries) == 1 && entries[0].IsDir() { + return entries[0].Name() + } + } + return "" } // RT is a struct with methods that map to Clojure's RT class' static @@ -138,8 +204,8 @@ func (rt *RTMethods) Load(scriptBase string) { loadPathLock.Lock() lp := loadPath loadPathLock.Unlock() - for _, fs := range lp { - buf, err = readFile(fs, filename) + for _, entry := range lp { + buf, err = readFile(entry.fs, filename) if err == nil { break } diff --git a/test/glojure/test_glojure/cli.glj b/test/glojure/test_glojure/cli.glj index e6d78300..3c635c1f 100644 --- a/test/glojure/test_glojure/cli.glj +++ b/test/glojure/test_glojure/cli.glj @@ -31,6 +31,20 @@ (first (str/split-lines out)) (throw (Exception. (str "Failed to find glj bin: " err)))))) +(deftest help-flag-test + (test-that + "glj --help flag works correctly" + (let [[out err] (run-cli-cmd glj "--help")] + (is (re-matches + #"(?s).*Glojure v\d+\.\d+\.\d+.*Usage: glj.*Options:.*-e.*--version.*-h.*--help.*Examples:.*" + out) + "Command should output help information") + (is (re-matches + #"(?s).*Environment Variables:.*GLJPATH.*PATH of directories for \.glj libraries.*" + out) + "Help should document GLJPATH environment variable") + (is (empty? err) "Command should not return an error")))) + (deftest e-flag-test (test-that "glj -e flag works correctly" @@ -46,32 +60,4 @@ "Command should output version") (is (empty? err) "Command should not return an error")))) -(deftest glojure-version-test - (test-that - "*glojure-version* should be set correctly" - (let [[out err] (run-cli-cmd glj "-e" "*glojure-version*")] - (is (= out "{:major 0, :minor 3, :incremental 0, :qualifier nil}\n") - "Version should match expected format") - (is (empty? err) "Command should not return an error")))) - -(deftest help-flag-test - (test-that - "glj --help flag works correctly" - (let [[out err] (run-cli-cmd glj "--help")] - (is (re-matches - #"(?s).*Glojure v0\.3\.0.*Usage: glj.*Options:.*-e.*-h.*--help.*--version.*Examples:.*" - out) - "Command should output help information") - (is (empty? err) "Command should not return an error")))) - -(deftest short-help-flag-test - (test-that - "glj -h flag works correctly" - (let [[out err] (run-cli-cmd glj "-h")] - (is (re-matches - #"(?s).*Glojure v0\.3\.0.*Usage: glj.*Options:.*-e.*-h.*--help.*--version.*Examples:.*" - out) - "Command should output help information") - (is (empty? err) "Command should not return an error")))) - (run-tests) diff --git a/test/glojure/test_glojure/loadpath.glj b/test/glojure/test_glojure/loadpath.glj new file mode 100644 index 00000000..3f88ad3e --- /dev/null +++ b/test/glojure/test_glojure/loadpath.glj @@ -0,0 +1,21 @@ +(ns glojure.test-glojure.load-path + (:use glojure.test)) + +(deftest LoadPath + (is (some? *glojure-load-path*) "glojure-load-path dynamic var exists") + (is (seq? *glojure-load-path*) "glojure-load-path is a sequence") + (is (> (count *glojure-load-path*) 0) "glojure-load-path has elements") + (is (some? (first *glojure-load-path*)) "glojure-load-path first element exists") + + (is (some #(= % "") *glojure-load-path*) "glojure-load-path contains stdlib") + + (let [original-path *glojure-load-path*] + (binding [*glojure-load-path* ["test-path"]] + (is (= *glojure-load-path* ["test-path"]) "binding works correctly") + (is (= (count *glojure-load-path*) 1) "binding changes count")) + (is (= *glojure-load-path* original-path) "binding is restored")) + + (doseq [path *glojure-load-path*] + (is (string? path) (str "Path element is not a string: " path)))) + +(run-tests)