From 27551d5019d54e6718ba6c41c6c1bc6178a6e6f7 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Mon, 4 May 2026 21:38:29 +0530 Subject: [PATCH] feat: Add pmg setup edit command (#235) * feat: Add pmg setup edit command * fix: Code review fixes --- cmd/setup/edit.go | 89 ++++++++++++++++ cmd/setup/setup.go | 1 + internal/shellwords/shellwords.go | 104 +++++++++++++++++++ internal/shellwords/shellwords_test.go | 138 +++++++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 cmd/setup/edit.go create mode 100644 internal/shellwords/shellwords.go create mode 100644 internal/shellwords/shellwords_test.go diff --git a/cmd/setup/edit.go b/cmd/setup/edit.go new file mode 100644 index 0000000..e239124 --- /dev/null +++ b/cmd/setup/edit.go @@ -0,0 +1,89 @@ +package setup + +import ( + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/shellwords" + "github.com/spf13/cobra" +) + +func NewEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the PMG config file in your default editor", + Long: `Open the PMG config file in your default editor. + +The editor is resolved in this order: + 1. $VISUAL + 2. $EDITOR + 3. Platform default (vi on Unix, notepad on Windows) + +If the config file does not exist, a template is created first.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runEdit() + }, + } +} + +func runEdit() error { + cfg := config.Get() + path := cfg.ConfigFilePath() + + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + if err := config.WriteTemplateConfig(); err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + } else if err != nil { + return fmt.Errorf("failed to stat config file %q: %w", path, err) + } + + editor, err := resolveEditor() + if err != nil { + return err + } + + parts, err := shellwords.Split(editor) + if err != nil { + return fmt.Errorf("invalid editor command %q: %w", editor, err) + } + if len(parts) == 0 { + return fmt.Errorf("editor command is empty") + } + parts = append(parts, path) + + c := exec.Command(parts[0], parts[1:]...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("editor %q exited with error: %w", editor, err) + } + + return nil +} + +func resolveEditor() (string, error) { + if v := strings.TrimSpace(os.Getenv("VISUAL")); v != "" { + return v, nil + } + if v := strings.TrimSpace(os.Getenv("EDITOR")); v != "" { + return v, nil + } + + if runtime.GOOS == "windows" { + return "notepad", nil + } + + if _, err := exec.LookPath("vi"); err == nil { + return "vi", nil + } + + return "", fmt.Errorf("no editor found: set $VISUAL or $EDITOR") +} diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 798ec65..545062e 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -28,6 +28,7 @@ func NewSetupCommand() *cobra.Command { setupCmd.AddCommand(NewInstallCommand()) setupCmd.AddCommand(NewRemoveCommand()) setupCmd.AddCommand(NewInfoCommand()) + setupCmd.AddCommand(NewEditCommand()) return setupCmd } diff --git a/internal/shellwords/shellwords.go b/internal/shellwords/shellwords.go new file mode 100644 index 0000000..8a64272 --- /dev/null +++ b/internal/shellwords/shellwords.go @@ -0,0 +1,104 @@ +// Package shellwords provides POSIX-like splitting of shell command strings +// into argv tokens. It is intentionally minimal: it handles the cases that +// arise from environment variables like $VISUAL or $EDITOR, where users may +// quote paths with spaces or pass flags. It is not a full shell parser — it +// does not perform variable expansion, command substitution, or globbing. +package shellwords + +import ( + "fmt" + "strings" +) + +// Split splits s into tokens using POSIX-like rules: +// +// - Whitespace (space, tab, newline) separates tokens. +// - Single quotes preserve their contents verbatim; no escapes are honored. +// - Double quotes preserve whitespace; backslash escapes only ", \, $, `. +// - Outside quotes a backslash escapes the next character literally. +// - Empty quoted strings ("" or '') produce an empty token. +// +// Split returns an error for unterminated quoted strings or a trailing +// backslash with nothing to escape. +func Split(s string) ([]string, error) { + var ( + out []string + buf strings.Builder + inSingle bool + inDouble bool + escaped bool + hasToken bool + ) + + flush := func() { + if hasToken { + out = append(out, buf.String()) + buf.Reset() + hasToken = false + } + } + + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case escaped: + buf.WriteByte(c) + hasToken = true + escaped = false + case inSingle: + if c == '\'' { + inSingle = false + } else { + buf.WriteByte(c) + } + hasToken = true + case inDouble: + switch c { + case '"': + inDouble = false + case '\\': + if i+1 >= len(s) { + return nil, fmt.Errorf("dangling backslash") + } + next := s[i+1] + // Inside double quotes, backslash only escapes a few + // characters; otherwise it is literal. + if next == '"' || next == '\\' || next == '$' || next == '`' { + buf.WriteByte(next) + i++ + } else { + buf.WriteByte(c) + } + default: + buf.WriteByte(c) + } + hasToken = true + default: + switch c { + case '\'': + inSingle = true + hasToken = true + case '"': + inDouble = true + hasToken = true + case '\\': + escaped = true + case ' ', '\t', '\n': + flush() + default: + buf.WriteByte(c) + hasToken = true + } + } + } + + if inSingle || inDouble { + return nil, fmt.Errorf("unterminated quoted string") + } + if escaped { + return nil, fmt.Errorf("dangling backslash") + } + flush() + + return out, nil +} diff --git a/internal/shellwords/shellwords_test.go b/internal/shellwords/shellwords_test.go new file mode 100644 index 0000000..2ccd33c --- /dev/null +++ b/internal/shellwords/shellwords_test.go @@ -0,0 +1,138 @@ +package shellwords + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplit(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + { + name: "empty string", + input: "", + want: nil, + }, + { + name: "whitespace only", + input: " \t\n ", + want: nil, + }, + { + name: "single token", + input: "vi", + want: []string{"vi"}, + }, + { + name: "command with flag", + input: "code --wait", + want: []string{"code", "--wait"}, + }, + { + name: "collapses repeated whitespace", + input: "nvim -p \t -u NONE", + want: []string{"nvim", "-p", "-u", "NONE"}, + }, + { + name: "leading and trailing whitespace", + input: " vim ", + want: []string{"vim"}, + }, + { + name: "double-quoted path with spaces", + input: `"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" --wait`, + want: []string{ + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + "--wait", + }, + }, + { + name: "single-quoted path with spaces", + input: `'/usr/local/bin/my editor' -f`, + want: []string{"/usr/local/bin/my editor", "-f"}, + }, + { + name: "backslash-escaped space", + input: `/usr/local/bin/my\ editor -f`, + want: []string{"/usr/local/bin/my editor", "-f"}, + }, + { + name: "double quote inside double quotes via backslash", + input: `code -e "say \"hi\""`, + want: []string{"code", "-e", `say "hi"`}, + }, + { + name: "single quotes inside double quotes are literal", + input: `vim "it's fine"`, + want: []string{"vim", "it's fine"}, + }, + { + name: "double quotes inside single quotes are literal", + input: `vim 'a "quote" here'`, + want: []string{"vim", `a "quote" here`}, + }, + { + name: "backslash inside single quotes is literal", + input: `vim 'a\b'`, + want: []string{"vim", `a\b`}, + }, + { + name: "non-special backslash in double quotes is literal", + input: `vim "a\b"`, + want: []string{"vim", `a\b`}, + }, + { + name: "adjacent quoted segments concatenate into one token", + input: `vim "foo"'bar'baz`, + want: []string{"vim", "foobarbaz"}, + }, + { + name: "empty double-quoted token", + input: `cmd "" arg`, + want: []string{"cmd", "", "arg"}, + }, + { + name: "empty single-quoted token", + input: `cmd '' arg`, + want: []string{"cmd", "", "arg"}, + }, + { + name: "unterminated double quote", + input: `vim "abc`, + wantErr: true, + }, + { + name: "unterminated single quote", + input: `vim 'abc`, + wantErr: true, + }, + { + name: "trailing backslash outside quotes", + input: `vim \`, + wantErr: true, + }, + { + name: "trailing backslash inside double quotes", + input: `vim "abc\`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Split(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}