diff --git a/.github/ISSUE_TEMPLATE b/.github/ISSUE_TEMPLATE new file mode 100644 index 00000000..183450da --- /dev/null +++ b/.github/ISSUE_TEMPLATE @@ -0,0 +1,9 @@ +## Description of the problem or steps to reproduce + +## Specifications + +You can use `micro -version` to get the commit hash. + +Commit hash: +OS: +Terminal: diff --git a/README.md b/README.md index dffe98af..1f37c4d5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ To see more screenshots of micro, showcasing all of the default colorschemes, se * Copy and paste with the system clipboard * Small and simple * Easily configurable +* Macros * Common editor things such as undo/redo, line numbers, unicode support... Although not yet implemented, I hope to add more features such as autocompletion ([#174](https://github.com/zyedidia/micro/issues/174)), and multiple cursors ([#5](https://github.com/zyedidia/micro/issues/5)) in the future. diff --git a/cmd/micro/actions.go b/cmd/micro/actions.go index 3ce6fc08..f5b2830d 100644 --- a/cmd/micro/actions.go +++ b/cmd/micro/actions.go @@ -721,6 +721,8 @@ func (v *View) Save(usePlugin bool) bool { if v.Buf.Path == "" { filename, canceled := messenger.Prompt("Filename: ", "Save", NoCompletion) if !canceled { + // the filename might or might not be quoted, so unquote first then join the strings. + filename = strings.Join(SplitCommandArgs(filename), " ") v.Buf.Path = filename v.Buf.Name = filename } else { @@ -1008,11 +1010,14 @@ func (v *View) OpenFile(usePlugin bool) bool { return false } - if v.CanClose("Continue? (y,n,s) ", 'y', 'n', 's') { + if v.CanClose() { filename, canceled := messenger.Prompt("File to open: ", "Open", FileCompletion) if canceled { return false } + // the filename might or might not be quoted, so unquote first then join the strings. + filename = strings.Join(SplitCommandArgs(filename), " ") + home, _ := homedir.Dir() filename = strings.Replace(filename, "~", home, 1) file, err := ioutil.ReadFile(filename) @@ -1308,7 +1313,7 @@ func (v *View) Quit(usePlugin bool) bool { } // Make sure not to quit if there are unsaved changes - if v.CanClose("Quit anyway? (y,n,s) ", 'y', 'n', 's') { + if v.CanClose() { v.CloseBuffer() if len(tabs[curTab].views) > 1 { v.splitNode.Delete() @@ -1354,7 +1359,7 @@ func (v *View) QuitAll(usePlugin bool) bool { closeAll := true for _, tab := range tabs { for _, v := range tab.views { - if !v.CanClose("Quit anyway? (y,n,s) ", 'y', 'n', 's') { + if !v.CanClose() { closeAll = false } } @@ -1476,6 +1481,62 @@ func (v *View) PreviousSplit(usePlugin bool) bool { return false } +var curMacro []interface{} +var recordingMacro bool + +func (v *View) ToggleMacro(usePlugin bool) bool { + if usePlugin && !PreActionCall("ToggleMacro", v) { + return false + } + + recordingMacro = !recordingMacro + + if recordingMacro { + curMacro = []interface{}{} + messenger.Message("Recording") + } else { + messenger.Message("Stopped recording") + } + + if usePlugin { + return PostActionCall("ToggleMacro", v) + } + return true +} + +func (v *View) PlayMacro(usePlugin bool) bool { + if usePlugin && !PreActionCall("PlayMacro", v) { + return false + } + + for _, action := range curMacro { + switch t := action.(type) { + case rune: + // Insert a character + if v.Cursor.HasSelection() { + v.Cursor.DeleteSelection() + v.Cursor.ResetSelection() + } + v.Buf.Insert(v.Cursor.Loc, string(t)) + v.Cursor.Right() + + for _, pl := range loadedPlugins { + _, err := Call(pl+".onRune", string(t), v) + if err != nil && !strings.HasPrefix(err.Error(), "function does not exist") { + TermMessage(err) + } + } + case func(*View, bool) bool: + t(v, true) + } + } + + if usePlugin { + return PostActionCall("PlayMacro", v) + } + return true +} + // None is no action func None() bool { return false diff --git a/cmd/micro/bindings.go b/cmd/micro/bindings.go index 67d40e0d..da6328be 100644 --- a/cmd/micro/bindings.go +++ b/cmd/micro/bindings.go @@ -79,6 +79,8 @@ var bindingActions = map[string]func(*View, bool) bool{ "NextTab": (*View).NextTab, "NextSplit": (*View).NextSplit, "PreviousSplit": (*View).PreviousSplit, + "ToggleMacro": (*View).ToggleMacro, + "PlayMacro": (*View).PlayMacro, // This was changed to InsertNewline but I don't want to break backwards compatibility "InsertEnter": (*View).InsertNewline, @@ -408,6 +410,8 @@ func DefaultBindings() map[string]string { "CtrlQ": "Quit", "CtrlE": "CommandMode", "CtrlW": "NextSplit", + "CtrlU": "ToggleMacro", + "CtrlJ": "PlayMacro", // Emacs-style keybindings "Alt-f": "WordRight", diff --git a/cmd/micro/command.go b/cmd/micro/command.go index a47910a0..02b5cf0b 100644 --- a/cmd/micro/command.go +++ b/cmd/micro/command.go @@ -226,7 +226,7 @@ func Bind(args []string) { // Run runs a shell command in the background func Run(args []string) { // Run a shell command in the background (openTerm is false) - HandleShellCommand(strings.Join(args, " "), false, true) + HandleShellCommand(JoinCommandArgs(args...), false, true) } // Quit closes the main view @@ -243,40 +243,20 @@ func Save(args []string) { // Replace runs search and replace func Replace(args []string) { - // This is a regex to parse the replace expression - // We allow no quotes if there are no spaces, but if you want to search - // for or replace an expression with spaces, you can add double quotes - r := regexp.MustCompile(`"[^"\\]*(?:\\.[^"\\]*)*"|[^\s]*`) - replaceCmd := r.FindAllString(strings.Join(args, " "), -1) - if len(replaceCmd) < 2 { + if len(args) < 2 { // We need to find both a search and replace expression messenger.Error("Invalid replace statement: " + strings.Join(args, " ")) return } var flags string - if len(replaceCmd) == 3 { + if len(args) == 3 { // The user included some flags - flags = replaceCmd[2] + flags = args[2] } - search := string(replaceCmd[0]) - replace := string(replaceCmd[1]) - - // If the search and replace expressions have quotes, we need to remove those - if strings.HasPrefix(search, `"`) && strings.HasSuffix(search, `"`) { - search = search[1 : len(search)-1] - } - if strings.HasPrefix(replace, `"`) && strings.HasSuffix(replace, `"`) { - replace = replace[1 : len(replace)-1] - } - - // We replace all escaped double quotes to real double quotes - search = strings.Replace(search, `\"`, `"`, -1) - replace = strings.Replace(replace, `\"`, `"`, -1) - // Replace some things so users can actually insert newlines and tabs in replacements - replace = strings.Replace(replace, "\\n", "\n", -1) - replace = strings.Replace(replace, "\\t", "\t", -1) + search := string(args[0]) + replace := string(args[1]) regex, err := regexp.Compile(search) if err != nil { @@ -347,8 +327,8 @@ func Replace(args []string) { // RunShellCommand executes a shell command and returns the output/error func RunShellCommand(input string) (string, error) { - inputCmd := strings.Split(input, " ")[0] - args := strings.Split(input, " ")[1:] + inputCmd := SplitCommandArgs(input)[0] + args := SplitCommandArgs(input)[1:] cmd := exec.Command(inputCmd, args...) outputBytes := &bytes.Buffer{} @@ -364,7 +344,7 @@ func RunShellCommand(input string) (string, error) { // The openTerm argument specifies whether a terminal should be opened (for viewing output // or interacting with stdin) func HandleShellCommand(input string, openTerm bool, waitToFinish bool) string { - inputCmd := strings.Split(input, " ")[0] + inputCmd := SplitCommandArgs(input)[0] if !openTerm { // Simply run the command in the background and notify the user when it's done messenger.Message("Running...") @@ -389,7 +369,7 @@ func HandleShellCommand(input string, openTerm bool, waitToFinish bool) string { screen.Fini() screen = nil - args := strings.Split(input, " ")[1:] + args := SplitCommandArgs(input)[1:] // Set up everything for the command var outputBuf bytes.Buffer @@ -431,12 +411,12 @@ func HandleShellCommand(input string, openTerm bool, waitToFinish bool) string { // HandleCommand handles input from the user func HandleCommand(input string) { - inputCmd := strings.Split(input, " ")[0] - args := strings.Split(input, " ")[1:] + args := SplitCommandArgs(input) + inputCmd := args[0] if _, ok := commands[inputCmd]; !ok { messenger.Error("Unknown command ", inputCmd) } else { - commands[inputCmd].action(args) + commands[inputCmd].action(args[1:]) } } diff --git a/cmd/micro/highlighter.go b/cmd/micro/highlighter.go index 8f6a6c38..43ea289a 100644 --- a/cmd/micro/highlighter.go +++ b/cmd/micro/highlighter.go @@ -85,6 +85,7 @@ var preInstalledSynFiles = []string{ "nanorc", "nginx", "ocaml", + "pascal", "patch", "peg", "perl", diff --git a/cmd/micro/messenger.go b/cmd/micro/messenger.go index 99f411b8..2e0a59ea 100644 --- a/cmd/micro/messenger.go +++ b/cmd/micro/messenger.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "strconv" - "strings" "github.com/zyedidia/clipboard" "github.com/zyedidia/tcell" @@ -206,7 +205,7 @@ func (m *Messenger) Prompt(prompt, historyType string, completionTypes ...Comple response, canceled = m.response, false m.history[historyType][len(m.history[historyType])-1] = response case tcell.KeyTab: - args := strings.Split(m.response, " ") + args := SplitCommandArgs(m.response) currentArgNum := len(args) - 1 currentArg := args[currentArgNum] var completionType Completion @@ -241,10 +240,7 @@ func (m *Messenger) Prompt(prompt, historyType string, completionTypes ...Comple } if chosen != "" { - if len(args) > 1 { - chosen = " " + chosen - } - m.response = strings.Join(args[:len(args)-1], " ") + chosen + m.response = JoinCommandArgs(append(args[:len(args)-1], chosen)...) m.cursorx = Count(m.response) } } diff --git a/cmd/micro/util.go b/cmd/micro/util.go index 9bb019d1..8d68dbdc 100644 --- a/cmd/micro/util.go +++ b/cmd/micro/util.go @@ -1,8 +1,11 @@ package main import ( + "bytes" "os" "path/filepath" + "reflect" + "runtime" "strconv" "strings" "time" @@ -217,3 +220,77 @@ func Abs(n int) int { } return n } + +// FuncName returns the name of a given function object +func FuncName(i interface{}) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} + +// SplitCommandArgs seperates multiple command arguments which may be quoted. +// The returned slice contains at least one string +func SplitCommandArgs(input string) []string { + var result []string + curArg := new(bytes.Buffer) + inQuote := false + escape := false + + appendResult := func() { + str := curArg.String() + inQuote = false + escape = false + if strings.HasPrefix(str, `"`) && strings.HasSuffix(str, `"`) { + if unquoted, err := strconv.Unquote(str); err == nil { + str = unquoted + } + } + result = append(result, str) + curArg.Reset() + } + + for _, r := range input { + if r == ' ' && !inQuote { + appendResult() + } else { + curArg.WriteRune(r) + + if r == '"' && !inQuote { + inQuote = true + } else { + if inQuote && !escape { + if r == '"' { + inQuote = false + } + if r == '\\' { + escape = true + continue + } + } + } + } + + escape = false + } + appendResult() + return result +} + +// JoinCommandArgs joins multiple command arguments and quote the strings if needed. +func JoinCommandArgs(args ...string) string { + buf := new(bytes.Buffer) + first := true + for _, arg := range args { + if first { + first = false + } else { + buf.WriteRune(' ') + } + quoted := strconv.Quote(arg) + if quoted[1:len(quoted)-1] != arg || strings.ContainsRune(arg, ' ') { + buf.WriteString(quoted) + } else { + buf.WriteString(arg) + } + } + + return buf.String() +} diff --git a/cmd/micro/util_test.go b/cmd/micro/util_test.go index e37a3237..e3d417d5 100644 --- a/cmd/micro/util_test.go +++ b/cmd/micro/util_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "reflect" + "testing" +) func TestNumOccurences(t *testing.T) { var tests = []struct { @@ -63,3 +66,48 @@ func TestIsWordChar(t *testing.T) { t.Errorf("IsWordChar(\n)) = true") } } + +func TestJoinAndSplitCommandArgs(t *testing.T) { + tests := []struct { + Query []string + Wanted string + }{ + {[]string{`test case`}, `"test case"`}, + {[]string{`quote "test"`}, `"quote \"test\""`}, + {[]string{`slash\\\ test`}, `"slash\\\\\\ test"`}, + {[]string{`path 1`, `path\" 2`}, `"path 1" "path\\\" 2"`}, + {[]string{`foo`}, `foo`}, + {[]string{`foo\"bar`}, `"foo\\\"bar"`}, + {[]string{``}, ``}, + {[]string{`"`}, `"\""`}, + {[]string{`a`, ``}, `a `}, + {[]string{``, ``, ``, ``}, ` `}, + {[]string{"\n"}, `"\n"`}, + {[]string{"foo\tbar"}, `"foo\tbar"`}, + } + + for i, test := range tests { + if result := JoinCommandArgs(test.Query...); test.Wanted != result { + t.Errorf("JoinCommandArgs failed at Test %d\nGot: %q", i, result) + } + + if result := SplitCommandArgs(test.Wanted); !reflect.DeepEqual(test.Query, result) { + t.Errorf("SplitCommandArgs failed at Test %d\nGot: `%q`", i, result) + } + } + + splitTests := []struct { + Query string + Wanted []string + }{ + {`"hallo""Welt"`, []string{`"hallo""Welt"`}}, + {`\"`, []string{`\"`}}, + {`"\"`, []string{`"\"`}}, + } + + for i, test := range splitTests { + if result := SplitCommandArgs(test.Query); !reflect.DeepEqual(test.Wanted, result) { + t.Errorf("SplitCommandArgs failed at Split-Test %d\nGot: `%q`", i, result) + } + } +} diff --git a/cmd/micro/view.go b/cmd/micro/view.go index d1d3c810..4efa1687 100644 --- a/cmd/micro/view.go +++ b/cmd/micro/view.go @@ -182,16 +182,15 @@ func (v *View) ScrollDown(n int) { // CanClose returns whether or not the view can be closed // If there are unsaved changes, the user will be asked if the view can be closed // causing them to lose the unsaved changes -// The message is what to print after saying "You have unsaved changes. " -func (v *View) CanClose(msg string, responses ...rune) bool { +func (v *View) CanClose() bool { if v.Buf.IsModified { - char, canceled := messenger.LetterPrompt("You have unsaved changes. "+msg, responses...) + char, canceled := messenger.LetterPrompt("Save changes to "+v.Buf.Name+" before closing? (y,n,esc) ", 'y', 'n') if !canceled { if char == 'y' { - return true - } else if char == 's' { v.Save(true) return true + } else if char == 'n' { + return true } } } else { @@ -231,7 +230,7 @@ func (v *View) CloseBuffer() { // ReOpen reloads the current buffer func (v *View) ReOpen() { - if v.CanClose("Continue? (y,n,s) ", 'y', 'n', 's') { + if v.CanClose() { screen.Clear() v.Buf.ReOpen() v.Relocate() @@ -340,6 +339,10 @@ func (v *View) HandleEvent(event tcell.Event) { TermMessage(err) } } + + if recordingMacro { + curMacro = append(curMacro, e.Rune()) + } } else { for key, actions := range bindings { if e.Key() == key.keyCode { @@ -352,6 +355,12 @@ func (v *View) HandleEvent(event tcell.Event) { relocate = false for _, action := range actions { relocate = action(v, true) || relocate + funcName := FuncName(action) + if funcName != "main.(*View).ToggleMacro" && funcName != "main.(*View).PlayMacro" { + if recordingMacro { + curMacro = append(curMacro, action) + } + } } } } diff --git a/runtime/help/keybindings.md b/runtime/help/keybindings.md index 6588ff6c..28af66e7 100644 --- a/runtime/help/keybindings.md +++ b/runtime/help/keybindings.md @@ -63,6 +63,8 @@ you can rebind them to your liking. "CtrlQ": "Quit", "CtrlE": "CommandMode", "CtrlW": "NextSplit", + "CtrlU": "ToggleMacro", + "CtrlJ": "PlayMacro", // Emacs-style keybindings "Alt-f": "WordRight", @@ -177,6 +179,8 @@ PreviousTab NextTab NextSplit PreviousSplit +ToggleMacro +PlayMacro ``` Here is the list of all possible keys you can bind: diff --git a/runtime/syntax/README.md b/runtime/syntax/README.md index d4d3c6c9..3a6a5290 100644 --- a/runtime/syntax/README.md +++ b/runtime/syntax/README.md @@ -27,6 +27,7 @@ Here is a list of the files that have been converted to properly use colorscheme * rust * java * javascript +* pascal * python * ruby * sh diff --git a/runtime/syntax/pascal.micro b/runtime/syntax/pascal.micro new file mode 100644 index 00000000..b48697c6 --- /dev/null +++ b/runtime/syntax/pascal.micro @@ -0,0 +1,24 @@ +syntax "pascal" "\.pas$" + +# color identifier "\b[\pL_][\pL_\pN]*\b" + +color comment "//.*" +color comment start="\(\*" end="\*\)" +color comment start="({)(?:[^$])" end="}" + +color special start="asm" end="end" + +color type "\b(?i:(string|ansistring|widestring|shortstring|char|ansichar|widechar|boolean|byte|shortint|word|smallint|longword|cardinal|longint|integer|int64|single|currency|double|extended))\b" + +color statement "\b(?i:(and|asm|array|begin|break|case|const|constructor|continue|destructor|div|do|downto|else|end|file|for|function|goto|if|implementation|in|inline|interface|label|mod|not|object|of|on|operator|or|packed|procedure|program|record|repeat|resourcestring|set|shl|shr|then|to|type|unit|until|uses|var|while|with|xor))\b" +color statement "\b(?i:(as|class|dispose|except|exit|exports|finalization|finally|inherited|initialization|is|library|new|on|out|property|raise|self|threadvar|try))\b" +color statement "\b(?i:(absolute|abstract|alias|assembler|cdecl|cppdecl|default|export|external|forward|generic|index|local|name|nostackframe|oldfpccall|override|pascal|private|protected|public|published|read|register|reintroduce|safecall|softfloat|specialize|stdcall|virtual|write))\b" + +color constant "\b(?i:(false|true|nil))\b" +color constant "\$[0-9A-Fa-f]+" "\b[+-]?[0-9]+([.]?[0-9]+)?(?i:e[+-]?[0-9]+)?" + +color constant.string "'(?:[^']+|'')*'" + + + +color preproc start="{\$" end="}" \ No newline at end of file diff --git a/runtime/syntax/php.micro b/runtime/syntax/php.micro index 2ae7deb5..9208a634 100644 --- a/runtime/syntax/php.micro +++ b/runtime/syntax/php.micro @@ -3,7 +3,7 @@ color default start="<\?(php|=)?" end="\?>" color special "([a-zA-Z0-9_-]+)\(" -color identifier "(var|class|goto|extends|function|echo|case|break|default|exit|switch|foreach|endforeach|while|const|static|extends|as|require|include|require_once|include_once|define|do|continue|declare|goto|print|in|interface|[E|e]xception|array|int|string|bool)[\s|\)]" +color identifier "(var|class|goto|extends|function|echo|case|break|default|exit|switch|foreach|endforeach|while|const|static|extends|as|require|include|require_once|include_once|define|do|continue|declare|goto|print|in|trait|interface|[E|e]xception|array|int|string|bool|iterable|void)[\s|\)]" color identifier "[a-zA-Z\\]+::" @@ -27,4 +27,4 @@ color default "[\(|\)|/|+|-|\*|\[|,|;]" color constant.string "('.*?'|\".*?\")" color comment start="/\*" end="\*/" -color comment "(#.*|//.*)$" \ No newline at end of file +color comment "(#.*|//.*)$"