From f14428dff45ade050d0e1e1a67575f2f22c36a9f Mon Sep 17 00:00:00 2001 From: Cole Cecil Date: Thu, 23 Jan 2025 19:15:49 -0600 Subject: [PATCH] feat: add Nushell support (#389) Implements #207 --- README.md | 3 + cmd/commands/env.go | 2 +- docs/guides/quick-start.md | 8 ++ internal/env/macos_env.go | 2 + internal/env/windows_env.go | 2 + internal/shell/nushell.go | 96 ++++++++++++++++++ internal/shell/nushell_test.go | 175 +++++++++++++++++++++++++++++++++ internal/shell/shell.go | 8 +- 8 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 internal/shell/nushell.go create mode 100644 internal/shell/nushell_test.go diff --git a/README.md b/README.md index 54dedfd7..64f18c16 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ if (-not (Test-Path -Path $PROFILE)) { New-Item -Type File -Path $PROFILE -Force # Or Install cmder: https://github.com/cmderdev/cmder/releases # 2. Find script path: clink info | findstr scripts # 3. copy internal/shell/clink_vfox.lua to script path + +# For Nushell: +vfox activate nushell | save --append $nu.config-path ``` > Remember to restart your shell to apply the changes. diff --git a/cmd/commands/env.go b/cmd/commands/env.go index 2920e95a..49e4cafb 100644 --- a/cmd/commands/env.go +++ b/cmd/commands/env.go @@ -126,7 +126,7 @@ func envFlag(ctx *cli.Context) error { return err } - if len(sdkEnvs) == 0 { + if len(sdkEnvs) == 0 && shellName != "nushell" { return nil } diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index 71c6465a..5dc5baa1 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -129,6 +129,14 @@ y 3. Restart Clink or Cmder ::: +::: details Nushell + +```shell +vfox activate nushell | save --append $nu.config-path +``` + +::: + ## 3. Add a plugin **Command**: `vfox add ` diff --git a/internal/env/macos_env.go b/internal/env/macos_env.go index eca66d93..cf4daebd 100644 --- a/internal/env/macos_env.go +++ b/internal/env/macos_env.go @@ -24,6 +24,8 @@ import ( "strings" ) +const Newline = "\n" + type macosEnvManager struct { envMap map[string]string deletedEnvMap map[string]struct{} diff --git a/internal/env/windows_env.go b/internal/env/windows_env.go index 47b14eb5..fce28da1 100644 --- a/internal/env/windows_env.go +++ b/internal/env/windows_env.go @@ -30,6 +30,8 @@ import ( "golang.org/x/sys/windows/registry" ) +const Newline = "\r\n" + type windowsEnvManager struct { key registry.Key // $PATH diff --git a/internal/shell/nushell.go b/internal/shell/nushell.go new file mode 100644 index 00000000..aec059c0 --- /dev/null +++ b/internal/shell/nushell.go @@ -0,0 +1,96 @@ +package shell + +import ( + "encoding/json" + "fmt" + "github.com/version-fox/vfox/internal/env" + "path/filepath" +) + +type nushell struct{} + +var Nushell = nushell{} + +const nushellConfig = env.Newline + + "# vfox configuration" + env.Newline + + "export-env {" + env.Newline + + " def --env updateVfoxEnvironment [] {" + env.Newline + + " let envData = (^'{{.SelfPath}}' env -s nushell | from json)" + env.Newline + + " load-env $envData.envsToSet" + env.Newline + + " hide-env ...$envData.envsToUnset" + env.Newline + + " }" + env.Newline + + " $env.config = ($env.config | upsert hooks.pre_prompt {" + env.Newline + + " let currentValue = ($env.config | get -i hooks.pre_prompt)" + env.Newline + + " if $currentValue == null {" + env.Newline + + " [{updateVfoxEnvironment}]" + env.Newline + + " } else {" + env.Newline + + " $currentValue | append {updateVfoxEnvironment}" + env.Newline + + " }" + env.Newline + + " })" + env.Newline + + " $env.__VFOX_SHELL = 'nushell'" + env.Newline + + " $env.__VFOX_PID = $nu.pid" + env.Newline + + " ^'{{.SelfPath}}' env --cleanup | ignore" + env.Newline + + " updateVfoxEnvironment" + env.Newline + + "}" + env.Newline + +// Activate implements shell.Activate by returning a script to be placed in the Nushell configuration file. This script +// does the following: +// +// 1. Sets up a [pre_prompt hook] to update the environment variables when needed. +// 2. Initializes the __VFOX_SHELL and __VFOX_PID environment variables. +// 3. Runs the vfox cleanup task. +// 4. Updates the environment variables. +// +// [pre_prompt hook]: https://www.nushell.sh/book/hooks.html +func (n nushell) Activate() (string, error) { + return nushellConfig, nil +} + +// nushellExportData is used to create a JSON representation of the environment variables to be set and unset. +type nushellExportData struct { + EnvsToSet map[string]any `json:"envsToSet"` + EnvsToUnset []string `json:"envsToUnset"` +} + +// Export implements shell.Export by creating a JSON representation of the environment variables to be set and unset. +// Nushell can then convert this JSON string to a [record] using the [from json] command, so it can load and unload the +// environment variables using the [load-env] and [hide-env] commands. +// +// This approach is required for Nushell because it does not support eval-like functionality. For more background +// information on this, see the article [How Nushell Code Gets Run]. +// +// [record]: https://www.nushell.sh/lang-guide/chapters/types/basic_types/record.html +// [from json]: https://www.nushell.sh/commands/docs/from_json.html +// [load-env]: https://www.nushell.sh/commands/docs/load-env.html +// [hide-env]: https://www.nushell.sh/commands/docs/hide-env.html +// [How Nushell Code Gets Run]: https://www.nushell.sh/book/how_nushell_code_gets_run.html +func (n nushell) Export(envs env.Vars) string { + exportData := nushellExportData{ + EnvsToSet: make(map[string]any), + EnvsToUnset: make([]string, 0), + } + + for key, value := range envs { + if key == "PATH" { // Convert from string to list. + if value == nil { + value = new(string) + } + pathEntries := filepath.SplitList(*value) + exportData.EnvsToSet[key] = pathEntries + } else { + if value == nil { + exportData.EnvsToUnset = append(exportData.EnvsToUnset, key) + } else { + exportData.EnvsToSet[key] = *value + } + } + } + + exportJson, err := json.Marshal(exportData) + if err != nil { + fmt.Printf("Failed to marshal export data: %s\n", err) + return "" + } + + return string(exportJson) +} diff --git a/internal/shell/nushell_test.go b/internal/shell/nushell_test.go new file mode 100644 index 00000000..e5e1a80d --- /dev/null +++ b/internal/shell/nushell_test.go @@ -0,0 +1,175 @@ +package shell + +import ( + "bytes" + "encoding/json" + "github.com/version-fox/vfox/internal/env" + "os" + "reflect" + "runtime" + "slices" + "testing" + "text/template" +) + +func TestActivate(t *testing.T) { + var newline string + if runtime.GOOS == "windows" { + newline = "\r\n" + } else { + newline = "\n" + } + selfPath := "/path/to/vfox" + want := newline + + "# vfox configuration" + newline + + "export-env {" + newline + + " def --env updateVfoxEnvironment [] {" + newline + + " let envData = (^'" + selfPath + "' env -s nushell | from json)" + newline + + " load-env $envData.envsToSet" + newline + + " hide-env ...$envData.envsToUnset" + newline + + " }" + newline + + " $env.config = ($env.config | upsert hooks.pre_prompt {" + newline + + " let currentValue = ($env.config | get -i hooks.pre_prompt)" + newline + + " if $currentValue == null {" + newline + + " [{updateVfoxEnvironment}]" + newline + + " } else {" + newline + + " $currentValue | append {updateVfoxEnvironment}" + newline + + " }" + newline + + " })" + newline + + " $env.__VFOX_SHELL = 'nushell'" + newline + + " $env.__VFOX_PID = $nu.pid" + newline + + " ^'" + selfPath + "' env --cleanup | ignore" + newline + + " updateVfoxEnvironment" + newline + + "}" + newline + + n := nushell{} + gotTemplate, err := n.Activate() + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + parsedTemplate, err := template.New("activate").Parse(gotTemplate) + if err != nil { + t.Errorf("Unexpected error parsing template: %v", err) + return + } + + var buffer bytes.Buffer + err = parsedTemplate.Execute(&buffer, struct{ SelfPath string }{selfPath}) + if err != nil { + t.Errorf("Unexpected error executing template: %v", err) + return + } + + got := buffer.String() + if got != want { + t.Errorf("Output mismatch:\n\ngot=\n%v\n\nwant=\n%v", got, want) + } +} + +func TestExport(t *testing.T) { + sep := string(os.PathListSeparator) + + tests := []struct { + name string + envs env.Vars + want nushellExportData + }{ + { + "Empty", + env.Vars{}, + nushellExportData{ + EnvsToSet: make(map[string]any), + EnvsToUnset: make([]string, 0)}, + }, + { + "SingleEnv", + env.Vars{"FOO": newString("bar")}, + nushellExportData{ + EnvsToSet: map[string]any{"FOO": "bar"}, + EnvsToUnset: make([]string, 0), + }, + }, + { + "MultipleEnvs", + env.Vars{"FOO": newString("bar"), "BAZ": newString("qux")}, + nushellExportData{ + EnvsToSet: map[string]any{"FOO": "bar", "BAZ": "qux"}, + EnvsToUnset: make([]string, 0), + }, + }, + { + "UnsetEnv", + env.Vars{"FOO": nil}, + nushellExportData{ + EnvsToSet: make(map[string]any), + EnvsToUnset: []string{"FOO"}, + }, + }, + { + "MixedEnvs", + env.Vars{"FOO": newString("bar"), "BAZ": nil}, + nushellExportData{ + EnvsToSet: map[string]any{"FOO": "bar"}, + EnvsToUnset: []string{"BAZ"}, + }, + }, + { + "MultipleUnsetEnvs", + env.Vars{"FOO": nil, "BAZ": nil}, + nushellExportData{ + EnvsToSet: make(map[string]any), + EnvsToUnset: []string{"FOO", "BAZ"}, + }, + }, + { + "PathEnv", + env.Vars{"PATH": newString("/path1" + sep + "/path2")}, + nushellExportData{ + EnvsToSet: map[string]any{"PATH": []any{"/path1", "/path2"}}, + EnvsToUnset: make([]string, 0), + }, + }, + { + "PathAndOtherEnv", + env.Vars{ + "PATH": newString("/path1" + sep + "/path2" + sep + "/path3"), + "FOO": newString("bar"), + "BAZ": nil, + }, + nushellExportData{ + EnvsToSet: map[string]any{"PATH": []any{"/path1", "/path2", "/path3"}, "FOO": "bar"}, + EnvsToUnset: []string{"BAZ"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runExportTest(t, test.envs, test.want) + }) + } +} + +func runExportTest(t *testing.T, envs env.Vars, want nushellExportData) { + n := nushell{} + got := n.Export(envs) + var gotData nushellExportData + err := json.Unmarshal([]byte(got), &gotData) + if err != nil { + t.Errorf("%s: error unmarshaling export data - %v", t.Name(), err) + return + } + + slices.Sort(want.EnvsToUnset) + slices.Sort(gotData.EnvsToUnset) + if !reflect.DeepEqual(gotData, want) { + t.Errorf("%s: export data mismatch - want %v, got %v", t.Name(), want, gotData) + } +} + +func newString(s string) *string { + return &s +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 3e3de4d3..89a2bfb7 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -23,7 +23,12 @@ import ( ) type Shell interface { + // Activate generates a shell script to be placed in the shell's configuration file, which will set up initial + // environment variables and set a hook to update the environment variables when needed. Activate() (string, error) + + // Export generates a string that can be used by the shell to set or unset the given environment variables. (The + // input specifies environment variables to be unset by giving them a nil value.) Export(envs env.Vars) string } @@ -39,7 +44,8 @@ func NewShell(name string) Shell { return Fish case "clink": return Clink + case "nushell": + return Nushell } return nil - }