Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Nushell support #389

Merged
merged 1 commit into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

}