From 63921224961922e84f13b6f2d22f8e7262dd7999 Mon Sep 17 00:00:00 2001 From: Bradley Wojcik Date: Sun, 24 Nov 2024 22:08:43 -0500 Subject: [PATCH] feat: add symlink support --- src/config/unmarshal.go | 1 + src/core/init.go | 1 + src/shell/cmd.go | 14 +-- src/shell/link.go | 64 ++++++++++++ src/shell/link_test.go | 140 +++++++++++++++++++++++++++ src/shell/nu.go | 10 ++ src/shell/path.go | 5 +- src/shell/path_test.go | 3 +- src/shell/pwsh.go | 6 ++ src/shell/script.go | 5 +- src/shell/script_test.go | 3 +- src/shell/tcsh.go | 6 ++ src/shell/template.go | 13 ++- src/shell/zsh.go | 6 ++ website/docs/introduction.mdx | 2 + website/docs/setup/configuration.mdx | 7 ++ website/docs/setup/link.mdx | 32 ++++++ 17 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 src/shell/link.go create mode 100644 src/shell/link_test.go create mode 100644 website/docs/setup/link.mdx diff --git a/src/config/unmarshal.go b/src/config/unmarshal.go index a491375..6b0f463 100644 --- a/src/config/unmarshal.go +++ b/src/config/unmarshal.go @@ -17,6 +17,7 @@ type Aliae struct { Envs shell.Envs `yaml:"env"` Paths shell.Paths `yaml:"path"` Scripts shell.Scripts `yaml:"script"` + Links shell.Links `yaml:"link"` } type FuncMap []StringFunc diff --git a/src/core/init.go b/src/core/init.go index ae8417c..371ae3c 100644 --- a/src/core/init.go +++ b/src/core/init.go @@ -27,6 +27,7 @@ func Init(configPath, sh string, printOutput bool) string { aliae.Envs.Render() aliae.Paths.Render() aliae.Aliae.Render() + aliae.Links.Render() aliae.Scripts.Render() script := shell.DotFile.String() diff --git a/src/shell/cmd.go b/src/shell/cmd.go index f20804c..40635c0 100644 --- a/src/shell/cmd.go +++ b/src/shell/cmd.go @@ -1,5 +1,3 @@ -// doskey np=notepad++.exe $* - package shell const ( @@ -15,8 +13,7 @@ func (a *Alias) cmd() *Alias { } func cmdAliasPre() string { - return ` -local filename = os.tmpname() + return `local filename = os.tmpname() local macrofile = io.open(filename, "w+") ` } @@ -25,8 +22,7 @@ func cmdAliasPost() string { return ` macrofile:close() local _ = io.popen(string.format("doskey /macrofile=%s", filename)):close() -os.remove(filename) -` +os.remove(filename)` } func (e *Echo) cmd() *Echo { @@ -42,6 +38,12 @@ func (e *Env) cmd() *Env { return e } +func (l *Link) cmd() *Link { + template := `os.execute("{{ $source := (escapeString .Name) }}mklink {{ if isDir $source }}/d{{ else }}/h{{ end }} {{ $source }} {{ escapeString .Target }} > nul 2>&1")` + l.template = template + return l +} + func (p *Path) cmd() *Path { p.template = `os.setenv("PATH", "{{ escapeString .Value }};" .. os.getenv("PATH"))` return p diff --git a/src/shell/link.go b/src/shell/link.go new file mode 100644 index 0000000..827daf1 --- /dev/null +++ b/src/shell/link.go @@ -0,0 +1,64 @@ +package shell + +import ( + "github.com/jandedobbeleer/aliae/src/context" +) + +type Links []*Link + +type Link struct { + Name Template `yaml:"name"` + Target Template `yaml:"target"` + If If `yaml:"if"` + + template string +} + +func (l *Link) string() string { + switch context.Current.Shell { + case ZSH, BASH, FISH, XONSH: + return l.zsh().render() + case PWSH, POWERSHELL: + return l.pwsh().render() + case NU: + return l.nu().render() + case TCSH: + return l.tcsh().render() + case CMD: + return l.cmd().render() + default: + return "" + } +} + +func (l *Link) render() string { + script, err := parse(l.template, l) + if err != nil { + return err.Error() + } + + return script +} + +func (l Links) Render() { + if len(l) == 0 { + return + } + + first := true + for _, link := range l { + script := link.string() + if len(script) == 0 || link.If.Ignore() { + continue + } + + if first && DotFile.Len() > 0 { + DotFile.WriteString("\n") + } + + DotFile.WriteString("\n") + DotFile.WriteString(script) + + first = false + } +} diff --git a/src/shell/link_test.go b/src/shell/link_test.go new file mode 100644 index 0000000..92ab7ef --- /dev/null +++ b/src/shell/link_test.go @@ -0,0 +1,140 @@ +package shell + +import ( + "strings" + "testing" + + "github.com/jandedobbeleer/aliae/src/context" + "github.com/stretchr/testify/assert" +) + +func TestLinkCommand(t *testing.T) { + link := &Link{Name: "foo", Target: "bar"} + cases := []struct { + Case string + Shell string + Expected string + OS string + }{ + { + Case: "PWSH", + Shell: PWSH, + Expected: "New-Item -Path \"foo\" -ItemType SymbolicLink -Value \"bar\" -Force", + }, + { + Case: "CMD", + Shell: CMD, + Expected: `os.execute("mklink /h foo bar > nul 2>&1")`, + }, + { + Case: "FISH", + Shell: FISH, + Expected: "ln -sf bar foo", + }, + { + Case: "NU", + Shell: NU, + Expected: "ln -sf bar foo out+err>| ignore", + }, + { + Case: "NU Windows", + Shell: NU, + OS: context.WINDOWS, + Expected: "mklink /h foo bar out+err>| ignore", + }, + { + Case: "TCSH", + Shell: TCSH, + Expected: "ln -sf bar foo;", + }, + { + Case: "XONSH", + Shell: XONSH, + Expected: "ln -sf bar foo", + }, + { + Case: "ZSH", + Shell: ZSH, + Expected: `ln -sf bar foo`, + }, + { + Case: "BASH", + Shell: BASH, + Expected: `ln -sf bar foo`, + }, + } + + for _, tc := range cases { + link.template = "" + context.Current = &context.Runtime{Shell: tc.Shell, OS: tc.OS} + assert.Equal(t, tc.Expected, link.string(), tc.Case) + } +} + +func TestLinkRender(t *testing.T) { + cases := []struct { + Case string + Expected string + Links Links + }{ + { + Case: "Single link", + Links: Links{ + &Link{Name: "FOO", Target: "bar"}, + }, + Expected: "ln -sf bar FOO", + }, + { + Case: "Double link", + Links: Links{ + &Link{Name: "FOO", Target: "bar"}, + &Link{Name: "BAR", Target: "foo"}, + }, + Expected: `ln -sf bar FOO +ln -sf foo BAR`, + }, + { + Case: "Filtered out", + Links: Links{ + &Link{Name: "FOO", Target: "bar", If: `eq .Shell "fish"`}, + }, + }, + } + + for _, tc := range cases { + DotFile.Reset() + context.Current = &context.Runtime{Shell: BASH} + tc.Links.Render() + assert.Equal(t, tc.Expected, strings.TrimSpace(DotFile.String()), tc.Case) + } +} + +func TestLinkWithTemplate(t *testing.T) { + cases := []struct { + Case string + Target Template + Expected string + }{ + { + Case: "No template", + Target: "~/dotfiles/zshrc", + Expected: `ln -sf ~/dotfiles/zshrc /tmp/l`, + }, + { + Case: "Home in template", + Target: "{{ .Home }}/.aliae.yaml", + Expected: `ln -sf /Users/jan/.aliae.yaml /tmp/l`, + }, + { + Case: "Advanced template", + Target: "{{ .Home }}/go/bin/aliae{{ if eq .OS \"windows\" }}.exe{{ end }}", + Expected: `ln -sf /Users/jan/go/bin/aliae.exe /tmp/l`, + }, + } + + for _, tc := range cases { + link := &Link{Name: "/tmp/l", Target: tc.Target} + context.Current = &context.Runtime{Shell: BASH, Home: "/Users/jan", OS: context.WINDOWS} + assert.Equal(t, tc.Expected, link.string(), tc.Case) + } +} diff --git a/src/shell/nu.go b/src/shell/nu.go index f0b1183..388f9b2 100644 --- a/src/shell/nu.go +++ b/src/shell/nu.go @@ -45,6 +45,16 @@ func (e *Env) nu() *Env { return e } +func (l *Link) nu() *Link { + template := `ln -sf {{ .Target }} {{ .Name }} out+err>| ignore` + if context.Current.OS == context.WINDOWS { + template = `{{ $source := (escapeString .Name) }}mklink {{ if isDir $source }}/d{{ else }}/h{{ end }} {{ $source }} {{ escapeString .Target }} out+err>| ignore` + } + + l.template = template + return l +} + func (p *Path) nu() *Path { template := `$env.%s = ($env.%s | prepend {{ formatString .Value }})` pathName := "PATH" diff --git a/src/shell/path.go b/src/shell/path.go index 38c11b9..a90854b 100644 --- a/src/shell/path.go +++ b/src/shell/path.go @@ -100,13 +100,10 @@ func (p Paths) Render() { } if first && DotFile.Len() > 0 { - DotFile.WriteString("\n\n") - } - - if !first { DotFile.WriteString("\n") } + DotFile.WriteString("\n") DotFile.WriteString(script) first = false diff --git a/src/shell/path_test.go b/src/shell/path_test.go index 991b7ae..865e484 100644 --- a/src/shell/path_test.go +++ b/src/shell/path_test.go @@ -1,6 +1,7 @@ package shell import ( + "strings" "testing" "github.com/jandedobbeleer/aliae/src/context" @@ -228,7 +229,7 @@ $env:PATH = '/Users/jan/.tools/bin:' + $env:PATH`, } context.Current = &context.Runtime{Shell: tc.Shell, Path: &context.Path{}} tc.Paths.Render() - assert.Equal(t, tc.Expected, DotFile.String(), tc.Case) + assert.Equal(t, tc.Expected, strings.TrimSpace(DotFile.String()), tc.Case) } } diff --git a/src/shell/pwsh.go b/src/shell/pwsh.go index 2c6f95d..2081e12 100644 --- a/src/shell/pwsh.go +++ b/src/shell/pwsh.go @@ -72,6 +72,12 @@ func (e *Env) pwsh() *Env { return e } +func (l *Link) pwsh() *Link { + template := `New-Item -Path {{ formatString .Name }} -ItemType SymbolicLink -Value {{ formatString .Target }} -Force` + l.template = template + return l +} + func (p *Path) pwsh() *Path { template := fmt.Sprintf(`$env:PATH = '{{ .Value }}%s' + $env:PATH`, context.PathDelimiter()) p.template = template diff --git a/src/shell/script.go b/src/shell/script.go index 7268f10..418b8be 100644 --- a/src/shell/script.go +++ b/src/shell/script.go @@ -25,13 +25,10 @@ func (s Scripts) Render() { } if first && DotFile.Len() > 0 { - DotFile.WriteString("\n\n") - } - - if !first { DotFile.WriteString("\n") } + DotFile.WriteString("\n") DotFile.WriteString(scriptBlock) first = false diff --git a/src/shell/script_test.go b/src/shell/script_test.go index bb4b19f..f057c64 100644 --- a/src/shell/script_test.go +++ b/src/shell/script_test.go @@ -1,6 +1,7 @@ package shell import ( + "strings" "testing" "github.com/jandedobbeleer/aliae/src/context" @@ -76,6 +77,6 @@ func TestScriptRender(t *testing.T) { } context.Current = &context.Runtime{Shell: PWSH} tc.Scripts.Render() - assert.Equal(t, tc.Expected, DotFile.String(), tc.Case) + assert.Equal(t, tc.Expected, strings.TrimSpace(DotFile.String()), tc.Case) } } diff --git a/src/shell/tcsh.go b/src/shell/tcsh.go index 1274f61..6567049 100644 --- a/src/shell/tcsh.go +++ b/src/shell/tcsh.go @@ -17,6 +17,12 @@ func (e *Env) tcsh() *Env { return e } +func (l *Link) tcsh() *Link { + template := `ln -sf {{ .Target }} {{ .Name }};` + l.template = template + return l +} + func (p *Path) tcsh() *Path { p.template = `set path = ( {{ .Value }} $path );` return p diff --git a/src/shell/template.go b/src/shell/template.go index 6e1ad57..2843eda 100644 --- a/src/shell/template.go +++ b/src/shell/template.go @@ -56,6 +56,7 @@ func funcMap() template.FuncMap { "env": os.Getenv, "match": match, "hasCommand": hasCommand, + "isDir": isDir, } return template.FuncMap(funcMap) } @@ -121,7 +122,8 @@ func escapeString(variable interface{}) interface{} { switch v := variable.(type) { case Template: - return clean(string(v)) + value := v.String() + return clean(value) case string: return clean(v) default: @@ -142,3 +144,12 @@ func hasCommand(command string) bool { _, err := exec.LookPath(command) return err == nil } + +func isDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + + return info.IsDir() +} diff --git a/src/shell/zsh.go b/src/shell/zsh.go index d607f91..22fc201 100644 --- a/src/shell/zsh.go +++ b/src/shell/zsh.go @@ -42,6 +42,12 @@ func (e *Env) zsh() *Env { return e } +func (l *Link) zsh() *Link { + template := `ln -sf {{ .Target }} {{ .Name }}` + l.template = template + return l +} + func (p *Path) zsh() *Path { template := fmt.Sprintf(`export PATH="{{ .Value }}%s$PATH"`, context.PathDelimiter()) p.template = template diff --git a/website/docs/introduction.mdx b/website/docs/introduction.mdx index 025835b..3d946b1 100644 --- a/website/docs/introduction.mdx +++ b/website/docs/introduction.mdx @@ -17,6 +17,7 @@ It allows you to use a single, template enabled `YAML` [configuration][config] t - [Environment variable][env] - [PATH entry][path] - [Script][script] +- [Symbolic Link][link] ## I want in! @@ -29,4 +30,5 @@ for your platform and have a look at the alias [configuration][config] page to g [env]: setup/env.mdx [path]: setup/path.mdx [script]: setup/script.mdx +[link]: setup/link.mdx diff --git a/website/docs/setup/configuration.mdx b/website/docs/setup/configuration.mdx index 412ab53..89d6791 100644 --- a/website/docs/setup/configuration.mdx +++ b/website/docs/setup/configuration.mdx @@ -65,6 +65,11 @@ script: - value: | eval "$(oh-my-posh init {{ .Shell }})" if: match .Shell "bash" "zsh" +link: + - name: ~/.aliae.yaml + target: ~/dotfiles/aliae.yaml + - name: ~/.zshrc + target: $DOTFILES/config/zsh/zshrc ``` You can find out more about the configuration options below. @@ -74,8 +79,10 @@ You can find out more about the configuration options below. - [Environment variable][env] - [PATH entry][path] - [Script][script] +- [Symbolic link][link] [alias]: setup/alias.mdx [env]: setup/env.mdx [path]: setup/path.mdx [script]: setup/script.mdx +[link]: setup/link.mdx diff --git a/website/docs/setup/link.mdx b/website/docs/setup/link.mdx new file mode 100644 index 0000000..f6787aa --- /dev/null +++ b/website/docs/setup/link.mdx @@ -0,0 +1,32 @@ +--- +id: link +title: Symbolic Link +sidebar_label: 🔗 Symbolic Link +--- + +Create symlinks to files and directories. Useful for dotfiles. + +### Syntax + +```yaml +link: + - name: {{ .Home }}/.aliae.yaml + target: {{ .Home }}/dotfiles/aliae.yaml + - name: {{ .Home }}/.zshrc + target: {{ .Env.DOTFILES }}/config/zsh/zshrc + - name: {{ .Home }}/Brewfile + value: /some/location/Brewfile + if: eq .OS "darwin" +``` + +### Link + +| Name | Type | Description | +| -------- | -------- | ------------------------------------------------------------------------------ | +| `name` | `string` | the link name, supports [templating][templates] | +| `target` | `string` | the name of the file or directory to link to, supports [templating][templates] | +| `if` | `string` | golang [template][go-text-template] conditional statement, see [if][if] | + +[go-text-template]: https://golang.org/pkg/text/template/ +[if]: if.mdx +[templates]: templates.mdx