diff --git a/cmd/configure.go b/cmd/configure.go index ca0edab3..12e6195b 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -15,7 +15,7 @@ var configureCmd = &cobra.Command{ func configure(cmd *cobra.Command, args []string) (err error) { editor := config.Conf.General.Editor - return editFile(editor, configFile) + return editFile(editor, configFile, 0) } func init() { diff --git a/cmd/edit.go b/cmd/edit.go index 0929a955..37f59dda 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -23,7 +23,7 @@ func edit(cmd *cobra.Command, args []string) (err error) { // file content before editing before := fileContent(snippetFile) - err = editFile(editor, snippetFile) + err = editFile(editor, snippetFile, 0) if err != nil { return } diff --git a/cmd/list.go b/cmd/list.go index 75ed3e02..779f35f5 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -37,7 +37,7 @@ func list(cmd *cobra.Command, args []string) error { for _, snippet := range snippets.Snippets { if config.Flag.OneLine { description := runewidth.FillRight(runewidth.Truncate(snippet.Description, col, "..."), col) - command := runewidth.Truncate(snippet.Command, 100-4-col, "...") + command := snippet.Command // make sure multiline command printed as oneline command = strings.Replace(command, "\n", "\\n", -1) fmt.Fprintf(color.Output, "%s : %s\n", diff --git a/cmd/new.go b/cmd/new.go index 5e15e3d6..28021d17 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "os" + "path/filepath" + "runtime" "strings" "github.com/chzyer/readline" @@ -27,7 +29,7 @@ func CanceledError() error { return errors.New("canceled") } -func scan(message string, out io.Writer, in io.ReadCloser, allowEmpty bool) (string, error) { +func scan(prompt string, out io.Writer, in io.ReadCloser, allowEmpty bool) (string, error) { f, err := os.CreateTemp("", "pet-") if err != nil { return "", err @@ -36,13 +38,13 @@ func scan(message string, out io.Writer, in io.ReadCloser, allowEmpty bool) (str tempFile := f.Name() l, err := readline.NewEx(&readline.Config{ - Stdout: out, - Stdin: in, - Prompt: message, - HistoryFile: tempFile, - InterruptPrompt: "^C", - EOFPrompt: "exit", - + Stdout: out, + Stdin: in, + Prompt: prompt, + HistoryFile: tempFile, + InterruptPrompt: "^C", + EOFPrompt: "exit", + VimMode: false, HistorySearchFold: true, }) @@ -75,6 +77,109 @@ func scan(message string, out io.Writer, in io.ReadCloser, allowEmpty bool) (str return "", CanceledError() } +// States of scanMultiLine state machine +const ( + start = iota + lastLineNotEmpty + lastLineEmpty +) + +func scanMultiLine(prompt string, secondMessage string, out io.Writer, in io.ReadCloser) (string, error) { + tempFile := "/tmp/pet.tmp" + if runtime.GOOS == "windows" { + tempDir := os.Getenv("TEMP") + tempFile = filepath.Join(tempDir, "pet.tmp") + } + l, err := readline.NewEx(&readline.Config{ + Stdout: out, + Stdin: in, + Prompt: prompt, + HistoryFile: tempFile, + InterruptPrompt: "^C", + EOFPrompt: "exit", + VimMode: false, + HistorySearchFold: true, + }) + if err != nil { + return "", err + } + defer l.Close() + + state := start + multiline := "" + for { + line, err := l.Readline() + if err == readline.ErrInterrupt { + if len(line) == 0 { + break + } else { + continue + } + } else if err == io.EOF { + break + } + switch state { + case start: + if line == "" { + continue + } + multiline += line + state = lastLineNotEmpty + l.SetPrompt(secondMessage) + case lastLineNotEmpty: + if line == "" { + state = lastLineEmpty + continue + } + multiline += "\n" + line + case lastLineEmpty: + if line == "" { + return multiline, nil + } + multiline += "\n" + line + state = lastLineNotEmpty + } + } + return "", errors.New("canceled") +} + +// createAndEditSnippet creates and saves a given snippet, then opens the +// configured editor to edit the snippet file at startLine. +func createAndEditSnippet(newSnippet snippet.SnippetInfo, snippets snippet.Snippets, startLine int) error { + snippets.Snippets = append(snippets.Snippets, newSnippet) + if err := snippets.Save(); err != nil { + return err + } + + // Open snippet for editing + snippetFile := config.Conf.General.SnippetFile + editor := config.Conf.General.Editor + err := editFile(editor, snippetFile, startLine) + if err != nil { + return err + } + + if config.Conf.Gist.AutoSync { + return petSync.AutoSync(snippetFile) + } + + return nil +} + +func countSnippetLines() int { + // Count lines in snippet file + f, err := os.Open(config.Conf.General.SnippetFile) + if err != nil { + panic("Error reading snippet file") + } + lineCount, err := CountLines(f) + if err != nil { + panic("Error counting lines in snippet file") + } + + return lineCount +} + func new(cmd *cobra.Command, args []string) (err error) { var command string var description string @@ -85,11 +190,31 @@ func new(cmd *cobra.Command, args []string) (err error) { return err } + lineCount := countSnippetLines() + if len(args) > 0 { command = strings.Join(args, " ") fmt.Fprintf(color.Output, "%s %s\n", color.HiYellowString("Command>"), command) } else { - command, err = scan(color.HiYellowString("Command> "), os.Stdout, os.Stdin, false) + if config.Flag.UseMultiLine { + command, err = scanMultiLine( + color.YellowString("Command> "), + color.YellowString(".......> "), + os.Stdout, os.Stdin, + ) + } else if config.Flag.UseEditor { + // Create and save empty snippet + newSnippet := snippet.SnippetInfo{ + Description: description, + Command: command, + Tag: tags, + } + + return createAndEditSnippet(newSnippet, snippets, lineCount+3) + + } else { + command, err = scan(color.HiYellowString("Command> "), os.Stdout, os.Stdin, false) + } if err != nil { return err } @@ -138,4 +263,8 @@ func init() { RootCmd.AddCommand(newCmd) newCmd.Flags().BoolVarP(&config.Flag.Tag, "tag", "t", false, `Display tag prompt (delimiter: space)`) + newCmd.Flags().BoolVarP(&config.Flag.UseMultiLine, "multiline", "m", false, + `Can enter multiline snippet (Double \n to quit)`) + newCmd.Flags().BoolVarP(&config.Flag.UseEditor, "editor", "e", false, + `Use editor to create snippet`) } diff --git a/cmd/new_test.go b/cmd/new_test.go index 3a760564..49598650 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -47,9 +47,9 @@ func TestScan(t *testing.T) { func TestScan_EmptyStringWithAllowEmpty(t *testing.T) { message := "Enter something: " - input := "\n" // Simulated user input - want := "" // Expected output - expectedError := error(nil) + input := "\n" // Simulated user input + want := "" // Expected output + expectedError := error(nil) // Should not error // Create a buffer for output var outputBuffer bytes.Buffer @@ -61,7 +61,7 @@ func TestScan_EmptyStringWithAllowEmpty(t *testing.T) { // Check if the input was printed got := result - // Check if the result matches the expected result + // Check if the result is empty if want != got { t.Errorf("Expected result %q, but got %q", want, got) } @@ -88,7 +88,6 @@ func TestScan_EmptyStringWithoutAllowEmpty(t *testing.T) { // Check if the input was printed got := result - // Check if the result matches the expected result if want != got { t.Errorf("Expected result %q, but got %q", want, got) @@ -99,3 +98,32 @@ func TestScan_EmptyStringWithoutAllowEmpty(t *testing.T) { t.Errorf("Expected error %v, but got %v", expectedError, err) } } + +func TestScanMultiLine_ExitsOnTwoEmptyLines(t *testing.T) { + prompt := "Enter something: " + secondPrompt := "whatever" + + input := "test\nnewline here\nand another;\n\n\n" // Simulated user input + want := "test\nnewline here\nand another;" // Expected output + expectedError := error(nil) + + // Create a buffer for output + var outputBuffer bytes.Buffer + // Create a mock ReadCloser for input + inputReader := &MockReadCloser{strings.NewReader(input)} + + result, err := scanMultiLine(prompt, secondPrompt, &outputBuffer, inputReader) + + // Check if the input was printed + got := result + + // Check if the result matches the expected result + if want != got { + t.Errorf("Expected result %q, but got %q", want, got) + } + + // Check if the error matches the expected error + if err != expectedError { + t.Errorf("Expected error %v, but got %v", expectedError, err) + } +} diff --git a/cmd/util.go b/cmd/util.go index 2e25fb72..086cbdb6 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -3,7 +3,9 @@ package cmd import ( "bytes" "fmt" + "io" "os" + "strconv" "strings" "github.com/fatih/color" @@ -12,15 +14,17 @@ import ( "github.com/knqyf263/pet/snippet" ) -func editFile(command, file string) error { - command += " " + file +func editFile(command, file string, startingLine int) error { + // Note that this works for most unix editors (nano, vi, vim, etc) + // TODO: Remove for other kinds of editors - this is only for UX + command += " +" + strconv.Itoa(startingLine) + " " + file return run(command, os.Stdin, os.Stdout) } func filter(options []string, tag string) (commands []string, err error) { var snippets snippet.Snippets if err := snippets.Load(); err != nil { - return commands, fmt.Errorf("Load snippet failed: %v", err) + return commands, fmt.Errorf("load snippet failed: %v", err) } if 0 < len(tag) { @@ -90,3 +94,23 @@ func filter(options []string, tag string) (commands []string, err error) { } return commands, nil } + +// CountLines returns the number of lines in a certain buffer +func CountLines(r io.Reader) (int, error) { + buf := make([]byte, 32*1024) + count := 0 + lineSep := []byte{'\n'} + + for { + c, err := r.Read(buf) + count += bytes.Count(buf[:c], lineSep) + + switch { + case err == io.EOF: + return count, nil + + case err != nil: + return count, err + } + } +} diff --git a/config/config.go b/config/config.go index 60145b33..8990cc5e 100644 --- a/config/config.go +++ b/config/config.go @@ -7,7 +7,7 @@ import ( "path/filepath" "runtime" - "github.com/BurntSushi/toml" + "github.com/pelletier/go-toml" "github.com/pkg/errors" ) @@ -69,21 +69,24 @@ var Flag FlagConfig // FlagConfig is a struct of flag type FlagConfig struct { - Debug bool - Query string - FilterTag string - Command bool - Delimiter string - OneLine bool - Color bool - Tag bool + Debug bool + Query string + FilterTag string + Command bool + Delimiter string + OneLine bool + Color bool + Tag bool + UseMultiLine bool + UseEditor bool } // Load loads a config toml func (cfg *Config) Load(file string) error { _, err := os.Stat(file) if err == nil { - _, err := toml.DecodeFile(file, cfg) + f, err := os.ReadFile(file) + err = toml.Unmarshal(f, cfg) if err != nil { return err } diff --git a/go.mod b/go.mod index 1c999144..b8151fca 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/knqyf263/pet go 1.20 require ( - github.com/BurntSushi/toml v0.3.0 github.com/atotto/clipboard v0.1.4 github.com/briandowns/spinner v0.0.0-20170614154858-48dbb65d7bd5 github.com/chzyer/logex v1.1.10 // indirect @@ -12,7 +11,10 @@ require ( github.com/fatih/color v1.7.0 github.com/google/go-github v15.0.0+incompatible github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/mattn/go-colorable v0.0.9 // indirect + github.com/mattn/go-isatty v0.0.3 // indirect github.com/mattn/go-runewidth v0.0.10 + github.com/pelletier/go-toml v1.8.1 github.com/pkg/errors v0.8.0 github.com/spf13/cobra v0.0.3 github.com/spf13/pflag v1.0.1 // indirect @@ -38,8 +40,6 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.1 // indirect github.com/hashicorp/go-retryablehttp v0.6.8 // indirect github.com/lucasb-eyer/go-colorful v1.0.3 // indirect - github.com/mattn/go-colorable v0.0.9 // indirect - github.com/mattn/go-isatty v0.0.3 // indirect github.com/rivo/uniseg v0.1.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.15.0 // indirect diff --git a/go.sum b/go.sum index 9a82fced..786007dc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/BurntSushi/toml v0.3.0 h1:e1/Ivsx3Z0FVTV0NSOv/aVgbUWyQuzj7DDnFblkRvsY= -github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -47,6 +45,8 @@ github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= +github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/snippet/snippet.go b/snippet/snippet.go index b7cfe8ee..0635f5b1 100644 --- a/snippet/snippet.go +++ b/snippet/snippet.go @@ -6,8 +6,8 @@ import ( "os" "sort" - "github.com/BurntSushi/toml" "github.com/knqyf263/pet/config" + "github.com/pelletier/go-toml" ) type Snippets struct { @@ -16,7 +16,7 @@ type Snippets struct { type SnippetInfo struct { Description string `toml:"description"` - Command string `toml:"command"` + Command string `toml:"command" multiline:"true"` Tag []string `toml:"tag"` Output string `toml:"output"` } @@ -27,9 +27,11 @@ func (snippets *Snippets) Load() error { if _, err := os.Stat(snippetFile); os.IsNotExist(err) { return nil } - if _, err := toml.DecodeFile(snippetFile, snippets); err != nil { - return fmt.Errorf("Failed to load snippet file. %v", err) + f, err := os.ReadFile(snippetFile) + if err != nil { + return fmt.Errorf("failed to load snippet file. %v", err) } + toml.Unmarshal(f, snippets) snippets.Order() return nil } @@ -38,10 +40,10 @@ func (snippets *Snippets) Load() error { func (snippets *Snippets) Save() error { snippetFile := config.Conf.General.SnippetFile f, err := os.Create(snippetFile) - defer f.Close() if err != nil { - return fmt.Errorf("Failed to save snippet file. err: %s", err) + return fmt.Errorf("failed to save snippet file. err: %s", err) } + defer f.Close() return toml.NewEncoder(f).Encode(snippets) } @@ -50,7 +52,7 @@ func (snippets *Snippets) ToString() (string, error) { var buffer bytes.Buffer err := toml.NewEncoder(&buffer).Encode(snippets) if err != nil { - return "", fmt.Errorf("Failed to convert struct to TOML string: %v", err) + return "", fmt.Errorf("failed to convert struct to TOML string: %v", err) } return buffer.String(), nil }