Skip to content

Commit

Permalink
feat: add Nushell support (#389)
Browse files Browse the repository at this point in the history
Implements #207
  • Loading branch information
colececil authored Jan 24, 2025
1 parent 8e7e0e6 commit f14428d
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/commands/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func envFlag(ctx *cli.Context) error {
return err
}

if len(sdkEnvs) == 0 {
if len(sdkEnvs) == 0 && shellName != "nushell" {
return nil
}

Expand Down
8 changes: 8 additions & 0 deletions docs/guides/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plugin-name>`
Expand Down
2 changes: 2 additions & 0 deletions internal/env/macos_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"strings"
)

const Newline = "\n"

type macosEnvManager struct {
envMap map[string]string
deletedEnvMap map[string]struct{}
Expand Down
2 changes: 2 additions & 0 deletions internal/env/windows_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import (
"golang.org/x/sys/windows/registry"
)

const Newline = "\r\n"

type windowsEnvManager struct {
key registry.Key
// $PATH
Expand Down
96 changes: 96 additions & 0 deletions internal/shell/nushell.go
Original file line number Diff line number Diff line change
@@ -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)
}
175 changes: 175 additions & 0 deletions internal/shell/nushell_test.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 7 additions & 1 deletion internal/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -39,7 +44,8 @@ func NewShell(name string) Shell {
return Fish
case "clink":
return Clink
case "nushell":
return Nushell
}
return nil

}

0 comments on commit f14428d

Please sign in to comment.