diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..33224b4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.{coffee,json}] +indent_style = space +indent_size = 2 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..2231100 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Ex-Mode Contributing Guidelines + +Current Maintainers: + +- [@jazzpi](https://github.com/jazzpi) + +This project is accepting new maintainers. Interested parties should open a new issue titled `New Maintainer Request`. + +## Pull Requests + +- If the PR *fixes* or should result in the closure of any issues, use the `fixes #` or `closes #` syntax to ensure issue will +close when your PR is merged +- All pull-requests that fix a bug or add a new feature *must* have accompanying tests before they will be merged. If you want +to speed up the merge of your PR, please contribute these tests + - *note*: if you submit a PR but are unsure how to write tests, please begin your PR title with `[needs tests]` +- Please use the [pull request template](PULL_REQUEST_TEMPLATE.md) as a guide for submitting your PR. + +## Issues + +- Be aware of the responsibilities of `ex-mode` vs `vim-mode` +- If you have identified a bug we would welcome any Pull Requests that either: + - Fix the issue + - Create failing tests to confirm the bug diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1b3c90e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +Fixes # . + +Changes Proposed in this Pull Request: + +- foo +- bar +- baz + +I have written tests for: + +[](Remove the `[]()` to uncomment the appropriate lines) + +[](- New features introduced) +[](- Bugs fixed) +[](- Neither (I'm just enhancing tests!)) diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e8e09d4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,45 @@ +## Project specific config ### +language: generic + +env: + global: + - APM_TEST_PACKAGES="vim-mode-plus" + - ATOM_LINT_WITH_BUNDLED_NODE="true" + + matrix: + - ATOM_CHANNEL=stable + - ATOM_CHANNEL=beta + +os: + - linux + +dist: trusty + +### Generic setup follows ### +script: + - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh + - chmod u+x build-package.sh + - ./build-package.sh + +notifications: + email: + on_success: never + on_failure: change + +branches: + only: + - master + +git: + depth: 10 + +sudo: false + +addons: + apt: + packages: + - build-essential + - git + - libgnome-keyring-dev + - libsecret-1-dev + - fakeroot diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b7578..30a0532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,84 @@ -## next - C-C-C-Combo Edition -* Alert on unknown command +## (unpublished) + +* Fix `:enew` not working ([#215](https://github.com/lloeki/ex-mode/pull/215)) + +## 0.18.0 + +* Add Gdefault support ([#191](https://github.com/lloeki/ex-mode/pull/191)) +* Add :sort command ([#190](https://github.com/lloeki/ex-mode/pull/190)) + +## 0.17.0 + +* Add support for Atom 1.19 ([#185](https://github.com/lloeki/ex-mode/pull/185)) +* Added support for canceling ex-mode with Ctrl-C ([#186](https://github.com/lloeki/ex-mode/pull/186)) + +## 0.16.0 + +* Support for Atom 1.18 and 1.19 ([#184](https://github.com/lloeki/ex-mode/pull/184)) + +## 0.15.0 +* `vim-mode-plus` support! + - Add keybinding (@jmarianer) + - Support `:substitute` command (@mkiken) + - Support marks + - Use `vim-mode-plus` in specs + +## 0.14.0 +* Support `:tabonly` (@jmarianer) +* Fix `:x` closing Atom instead of the current pane + +## 0.13.1 +* Limit addresses to the last line +* Fix autocompleting a non existent directory (@mcnicholls) + +## 0.13.0 +* Added basic support for visual marks (e.g. `:'<,'>s/foo/bar`) +* Added `smartcase` option to `:set` +* Fixed using marks as addresses (e.g. `:'a,.delete`) +* Fixed search not working without a closing delimiter (e.g. `:/foo`) + +## 0.12.0 +* Added file and command autocomplete (@stuartquin) +* Added `splitbelow` and `splitright` options to `:set` +* Fixed the editor not updating when saving a new file with `:w` or `:saveas` + +## 0.11.0 + +* Stop using non-standard Promise.defer (fixes issue with `:w`) (@AsaAyers) + +## 0.9.0 + +* Added support for yank commands, ex `:1,10y` (@posgarou) +* Added contributor guidelines, including a pull request template +* Added ability to control splitting with `splitright`, and `splitbelow` (@dragonxwang) + +### Fixes + +* delete commands now add text to clipboard, ex `:1,4d` + +## 0.8.0 +* Don't allow :s delimiters not allowed by vim (@jacwah) +* Backspace over empty `:` now cancels ex-mode (@shamrin) +* Added option to register alias keys in atom init config (@GertjanReynaert) +* Allow `:substitute` to replace empty with an empty string and replacing the last search item (@jazzpi) +* Added `:wall`, `:quitall` and `:wqall` commands (@caiocutrim) +* Added `:saveas` command (@bakert) + +## 0.6.0 +* No project/multiple projects paths (uses first one) +* Support for :set +* Fixes + +## 0.5.0 +* Comply with upcoming Atom API 1.0 +* Added `:d` +* Fixes + +## 0.4.1 - C-C-C-Combo Edition +* Added ex command parser, including ranges * Added `:wq` +* Added `:s` +* Alert on unknown or invalid command ## 0.3.0 - Extrovert Edition * Register new commands from the outside world diff --git a/README.md b/README.md old mode 100644 new mode 100755 index b3084de..ea4a068 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ ex-mode for Atom's vim-mode ## Use -Install both [vim-mode](https://github.com/atom/vim-mode) and ex-mode. Type `:` in command mode. Enter `w` or `write`. +Install both [vim-mode-plus](https://github.com/t9md/atom-vim-mode-plus) (or +the deprecated `vim-mode`) and ex-mode. Type `:` in command mode. Enter `w` or +`write`. ## Extend @@ -18,7 +20,44 @@ atom.packages.onDidActivatePackage (pack) -> Ex.registerCommand 'z', -> console.log("Zzzzzz...") ``` -See `lib/ex.coffee` for some examples commands. Contributions are very welcome! +You can also add aliases: + +```coffee +atom.packages.onDidActivatePackage (pack) -> + if pack.name == 'ex-mode' + Ex = pack.mainModule.provideEx() + Ex.registerAlias 'WQ', 'wq' + Ex.registerAlias 'Wq', 'wq' +``` + +## Existing commands + +This is the baseline list of commands supported in `ex-mode`. + +| Command | Operation | +| --------------------------------------- | ---------------------------------- | +| `q/quit/tabc/tabclose` | Close active tab | +| `qall/quitall` | Close all tabs | +| `tabe/tabedit/tabnew` | Open new tab | +| `e/edit/tabe/tabedit/tabnew ` | Edit given file | +| `tabn/tabnext` | Go to next tab | +| `tabp/tabprevious` | Go to previous tab | +| `tabo/tabonly` | Close other tabs | +| `w/write` | Save active tab | +| `w/write/saveas ` | Save as | +| `wall/wa` | Save all tabs | +| `sp/split` | Split window | +| `sp/split ` | Open file in split window | +| `s/substitute` | Substitute regular expression in active line | +| `vsp/vsplit` | Vertical split window | +| `vsp/vsplit ` | Open file in vertical split window | +| `delete` | Cut active line | +| `yank` | Copy active line | +| `set ` | Set options | +| `sort` | Sort all lines in file | +| `sort ` | Sort lines in line range | + +See `lib/ex.coffee` for the implementations of these commands. Contributions are very welcome! ## Status diff --git a/keymaps/ex-mode.cson b/keymaps/ex-mode.cson index 5abddd0..a071efd 100644 --- a/keymaps/ex-mode.cson +++ b/keymaps/ex-mode.cson @@ -7,5 +7,10 @@ # For more detailed documentation see # https://atom.io/docs/latest/advanced/keymaps +'atom-text-editor.vim-mode-plus:not(.insert-mode)': + ':': 'ex-mode:open' +'atom-text-editor.ex-mode-editor': + 'ctrl-c': 'ex-mode:close' + 'ctrl-[': 'ex-mode:close' 'atom-text-editor.vim-mode:not(.insert-mode)': ':': 'ex-mode:open' diff --git a/lib/autocomplete.coffee b/lib/autocomplete.coffee new file mode 100644 index 0000000..6775bac --- /dev/null +++ b/lib/autocomplete.coffee @@ -0,0 +1,80 @@ +fs = require 'fs' +path = require 'path' +os = require 'os' +Ex = require './ex' + +module.exports = +class AutoComplete + constructor: (commands) -> + @commands = commands + @resetCompletion() + + resetCompletion: () -> + @autoCompleteIndex = 0 + @autoCompleteText = null + @completions = [] + + expandTilde: (filePath) -> + if filePath.charAt(0) == '~' + return os.homedir() + filePath.slice(1) + else + return filePath + + getAutocomplete: (text) -> + if !@autoCompleteText + @autoCompleteText = text + + parts = @autoCompleteText.split(' ') + cmd = parts[0] + + if parts.length > 1 + filePath = parts.slice(1).join(' ') + return @getCompletion(() => @getFilePathCompletion(cmd, filePath)) + else + return @getCompletion(() => @getCommandCompletion(cmd)) + + filterByPrefix: (commands, prefix) -> + commands.sort().filter((f) => f.startsWith(prefix)) + + getCompletion: (completeFunc) -> + if @completions.length == 0 + @completions = completeFunc() + + complete = '' + if @completions.length + complete = @completions[@autoCompleteIndex % @completions.length] + @autoCompleteIndex++ + + # Only one result so lets return this directory + if complete.endsWith('/') && @completions.length == 1 + @resetCompletion() + + return complete + + getCommandCompletion: (command) -> + return @filterByPrefix(@commands, command) + + getFilePathCompletion: (command, filePath) -> + filePath = @expandTilde(filePath) + + if filePath.endsWith(path.sep) + basePath = path.dirname(filePath + '.') + baseName = '' + else + basePath = path.dirname(filePath) + baseName = path.basename(filePath) + + try + basePathStat = fs.statSync(basePath) + if basePathStat.isDirectory() + files = fs.readdirSync(basePath) + return @filterByPrefix(files, baseName).map((f) => + filePath = path.join(basePath, f) + if fs.lstatSync(filePath).isDirectory() + return command + ' ' + filePath + path.sep + else + return command + ' ' + filePath + ) + return [] + catch err + return [] diff --git a/lib/command-error.coffee b/lib/command-error.coffee new file mode 100644 index 0000000..c9861e9 --- /dev/null +++ b/lib/command-error.coffee @@ -0,0 +1,5 @@ +class CommandError + constructor: (@message) -> + @name = 'Command Error' + +module.exports = CommandError diff --git a/lib/command.coffee b/lib/command.coffee index 851f859..15e74fe 100644 --- a/lib/command.coffee +++ b/lib/command.coffee @@ -1,22 +1,201 @@ ExViewModel = require './ex-view-model' Ex = require './ex' - -class CommandError - constructor: (@message) -> - @name = 'Command Error' +Find = require './find' +CommandError = require './command-error' class Command constructor: (@editor, @exState) -> - @viewModel = new ExViewModel(@) + @selections = @exState.getSelections() + @viewModel = new ExViewModel(@, Object.keys(@selections).length > 0) + + parseAddr: (str, cursor) -> + row = cursor.getBufferRow() + if str is '.' + addr = row + else if str is '$' + # Lines are 0-indexed in Atom, but 1-indexed in vim. + # The two ways of getting length let us support Atom 1.19's new buffer + # implementation (https://github.com/atom/atom/pull/14435) and still + # support 1.18 and below + buffer = @editor.getBuffer() + addr = (buffer.getLineCount?() ? buffer.lines.length) - 1 + else if str[0] in ["+", "-"] + addr = row + @parseOffset(str) + else if not isNaN(str) + addr = parseInt(str) - 1 + else if str[0] is "'" # Parse Mark... + unless @vimState? + throw new CommandError("Couldn't get access to vim-mode.") + mark = @vimState.mark.marks[str[1]] + unless mark? + throw new CommandError("Mark #{str} not set.") + addr = mark.getEndBufferPosition().row + else if str[0] is "/" + str = str[1...] + if str[str.length-1] is "/" + str = str[...-1] + addr = Find.scanEditor(str, @editor, cursor.getCurrentLineBufferRange().end)[0] + unless addr? + throw new CommandError("Pattern not found: #{str}") + addr = addr.start.row + else if str[0] is "?" + str = str[1...] + if str[str.length-1] is "?" + str = str[...-1] + addr = Find.scanEditor(str, @editor, cursor.getCurrentLineBufferRange().start, true)[0] + unless addr? + throw new CommandError("Pattern not found: #{str[1...-1]}") + addr = addr.start.row + + return addr + + parseOffset: (str) -> + if str.length is 0 + return 0 + if str.length is 1 + o = 1 + else + o = parseInt(str[1..]) + if str[0] is '+' + return o + else + return -o execute: (input) -> - return unless input.characters.length > 0 - [command, args...] = input.characters.split(" ") + @vimState = @exState.globalExState.vim?.getEditorState(@editor) + # Command line parsing (mostly) following the rules at + # http://pubs.opengroup.org/onlinepubs/9699919799/utilities + # /ex.html#tag_20_40_13_03 - func = Ex.singleton()[command] - if func? - func(args...) + # Steps 1/2: Leading blanks and colons are ignored. + cl = input.characters + cl = cl.replace(/^(:|\s)*/, '') + return unless cl.length > 0 + + # Step 3: If the first character is a ", ignore the rest of the line + if cl[0] is '"' + return + + # Step 4: Address parsing + # see comment in parseAddr about line length + buffer = @editor.getBuffer() + lastLine = (buffer.getLineCount?() ? buffer.lines.length) - 1 + if cl[0] is '%' + range = [0, lastLine] + cl = cl[1..] else - throw new CommandError("#{input.characters}") + addrPattern = ///^ + (?: # First address + ( + \.| # Current line + \$| # Last line + \d+| # n-th line + '[\[\]<>'`"^.(){}a-zA-Z]| # Marks + /.*?(?:[^\\]/|$)| # Regex + \?.*?(?:[^\\]\?|$)| # Backwards search + [+-]\d* # Current line +/- a number of lines + )((?:\s*[+-]\d*)*) # Line offset + )? + (?:, # Second address + ( # Same as first address + \.| + \$| + \d+| + '[\[\]<>'`"^.(){}a-zA-Z]| + /.*?[^\\]/| + \?.*?[^\\]\?| + [+-]\d* + )((?:\s*[+-]\d*)*) + )? + /// -module.exports = {Command, CommandError} + [match, addr1, off1, addr2, off2] = cl.match(addrPattern) + + cursor = @editor.getLastCursor() + + # Special case: run command on selection. This can't be handled by simply + # parsing the mark since vim-mode doesn't set it (and it would be fairly + # useless with multiple selections) + if addr1 is "'<" and addr2 is "'>" + runOverSelections = true + else + runOverSelections = false + if addr1? + address1 = @parseAddr(addr1, cursor) + else + # If no addr1 is given (,+3), assume it is '.' + address1 = cursor.getBufferRow() + if off1? + address1 += @parseOffset(off1) + + address1 = 0 if address1 is -1 + address1 = lastLine if address1 > lastLine + + if address1 < 0 + throw new CommandError('Invalid range') + + if addr2? + address2 = @parseAddr(addr2, cursor) + if off2? + address2 += @parseOffset(off2) + + address2 = 0 if address2 is -1 + address2 = lastLine if address2 > lastLine + + if address2 < 0 + throw new CommandError('Invalid range') + + if address2 < address1 + throw new CommandError('Backwards range given') + + range = [address1, if address2? then address2 else address1] + cl = cl[match?.length..] + + # Step 5: Leading blanks are ignored + cl = cl.trimLeft() + + # Step 6a: If no command is specified, go to the last specified address + if cl.length is 0 + @editor.setCursorBufferPosition([range[1], 0]) + return + + # Ignore steps 6b and 6c since they only make sense for print commands and + # print doesn't make sense + + # Ignore step 7a since flags are only useful for print + + # Step 7b: :k is equal to :mark - only a-zA-Z is + # in vim-mode for now + if cl.length is 2 and cl[0] is 'k' and /[a-z]/i.test(cl[1]) + command = 'mark' + args = cl[1] + else if not /[a-z]/i.test(cl[0]) + command = cl[0] + args = cl[1..] + else + [m, command, args] = cl.match(/^(\w+)(.*)/) + + # If the command matches an existing one exactly, execute that one + unless (func = Ex.singleton()[command])? + # Step 8: Match command against existing commands + matching = (name for name, val of Ex.singleton() when \ + name.indexOf(command) is 0) + + matching.sort() + + command = matching[0] + + func = Ex.singleton()[command] + + if func? + if runOverSelections + for id, selection of @selections + bufferRange = selection.getBufferRange() + range = [bufferRange.start.row, bufferRange.end.row] + func({ range, args, @vimState, @exState, @editor }) + else + func({ range, args, @vimState, @exState, @editor }) + else + throw new CommandError("Not an editor command: #{input.characters}") + +module.exports = Command diff --git a/lib/ex-command-mode-input-view.coffee b/lib/ex-command-mode-input-view.coffee deleted file mode 100644 index a31f04c..0000000 --- a/lib/ex-command-mode-input-view.coffee +++ /dev/null @@ -1,59 +0,0 @@ -{View} = require 'space-pen' -{TextEditorView} = require 'atom-space-pen-views' - -module.exports = -class ExCommandModeInputView extends View - @content: -> - @div class: 'command-mode-input', => - @div class: 'editor-container', outlet: 'editorContainer', => - @subview 'editor', new TextEditorView(mini: true) - - initialize: (@viewModel, opts = {})-> - if opts.class? - @editorContainer.addClass opts.class - - if opts.hidden - @editorContainer.addClass 'hidden-input' - - @singleChar = opts.singleChar - @defaultText = opts.defaultText ? '' - - @panel = atom.workspace.addBottomPanel(item: this, priority: 100) - - @focus() - @handleEvents() - - handleEvents: -> - if @singleChar? - @editor.find('input').on 'textInput', @autosubmit - @editor.on 'core:confirm', @confirm - @editor.on 'core:cancel', @cancel - @editor.find('input').on 'blur', @cancel - - stopHandlingEvents: -> - if @singleChar? - @editor.find('input').off 'textInput', @autosubmit - @editor.off 'core:confirm', @confirm - @editor.off 'core:cancel', @cancel - @editor.find('input').off 'blur', @cancel - - autosubmit: (event) => - @editor.setText(event.originalEvent.data) - @confirm() - - confirm: => - @value = @editor.getText() or @defaultText - @viewModel.confirm(@) - @remove() - - focus: => - @editorContainer.find('.editor').focus() - - cancel: (e) => - @viewModel.cancel(@) - @remove() - - remove: => - @stopHandlingEvents() - atom.workspace.getActivePane().activate() - @panel.destroy() diff --git a/lib/ex-mode.coffee b/lib/ex-mode.coffee index 243477a..d8fc766 100644 --- a/lib/ex-mode.coffee +++ b/lib/ex-mode.coffee @@ -30,6 +30,33 @@ module.exports = ExMode = provideEx: -> registerCommand: Ex.registerCommand.bind(Ex) + registerAlias: Ex.registerAlias.bind(Ex) consumeVim: (vim) -> @vim = vim + @globalExState.setVim(vim) + + consumeVimModePlus: (vim) -> + this.consumeVim(vim) + + config: + splitbelow: + title: 'Split below' + description: 'when splitting, split from below' + type: 'boolean' + default: 'false' + splitright: + title: 'Split right' + description: 'when splitting, split from right' + type: 'boolean' + default: 'false' + gdefault: + title: 'Gdefault' + description: 'When on, the ":substitute" flag \'g\' is default on' + type: 'boolean' + default: 'false' + onlyCloseBuffers: + title: 'Only close buffers' + description: 'When on, quitall only closes all buffers, not entire Atom instance' + type: 'boolean' + default: 'false' diff --git a/lib/ex-normal-mode-input-element.coffee b/lib/ex-normal-mode-input-element.coffee new file mode 100644 index 0000000..915a5be --- /dev/null +++ b/lib/ex-normal-mode-input-element.coffee @@ -0,0 +1,71 @@ +class ExCommandModeInputElement extends HTMLDivElement + createdCallback: -> + @className = "command-mode-input" + + @editorContainer = document.createElement("div") + @editorContainer.className = "editor-container" + + @appendChild(@editorContainer) + + initialize: (@viewModel, opts = {}) -> + if opts.class? + @editorContainer.classList.add(opts.class) + + if opts.hidden + @editorContainer.style.height = "0px" + + @editorElement = document.createElement "atom-text-editor" + @editorElement.classList.add('editor') # Consider this deprecated! + @editorElement.classList.add('ex-mode-editor') + @editorElement.getModel().setMini(true) + @editorElement.setAttribute('mini', '') + @editorContainer.appendChild(@editorElement) + + @singleChar = opts.singleChar + @defaultText = opts.defaultText ? '' + + @panel = atom.workspace.addBottomPanel(item: this, priority: 100) + + @focus() + @handleEvents() + + this + + handleEvents: -> + if @singleChar? + @editorElement.getModel().getBuffer().onDidChange (e) => + @confirm() if e.newText + else + atom.commands.add(@editorElement, 'editor:newline', @confirm.bind(this)) + atom.commands.add(@editorElement, 'core:backspace', @backspace.bind(this)) + + atom.commands.add(@editorElement, 'core:confirm', @confirm.bind(this)) + atom.commands.add(@editorElement, 'core:cancel', @cancel.bind(this)) + atom.commands.add(@editorElement, 'ex-mode:close', @cancel.bind(this)) + atom.commands.add(@editorElement, 'blur', @cancel.bind(this)) + + backspace: -> + # pressing backspace over empty `:` should cancel ex-mode + @cancel() unless @editorElement.getModel().getText().length + + confirm: -> + @value = @editorElement.getModel().getText() or @defaultText + @viewModel.confirm(this) + @removePanel() + + focus: -> + @editorElement.focus() + + cancel: (e) -> + @viewModel.cancel(this) + @removePanel() + + removePanel: -> + atom.workspace.getActivePane().activate() + @panel.destroy() + +module.exports = +document.registerElement("ex-command-mode-input" + extends: "div", + prototype: ExCommandModeInputElement.prototype +) diff --git a/lib/ex-state.coffee b/lib/ex-state.coffee index 7aff344..89fbeeb 100644 --- a/lib/ex-state.coffee +++ b/lib/ex-state.coffee @@ -1,6 +1,7 @@ {Emitter, Disposable, CompositeDisposable} = require 'event-kit' -{Command, CommandError} = require './command' +Command = require './command' +CommandError = require './command-error' class ExState constructor: (@editorElement, @globalExState) -> @@ -33,6 +34,9 @@ class ExState onDidFailToExecute: (fn) -> @emitter.on('failed-to-execute', fn) + onDidProcessOpStack: (fn) -> + @emitter.on('processed-op-stack', fn) + pushOperations: (operations) -> @opStack.push operations @@ -44,9 +48,9 @@ class ExState processOpStack: -> [command, input] = @opStack if input.characters.length > 0 + @history.unshift command try command.execute(input) - @history.unshift command catch e if (e instanceof CommandError) atom.notifications.addError("Command error: #{e.message}") @@ -54,5 +58,15 @@ class ExState else throw e @clearOpStack() + @emitter.emit('processed-op-stack') + + # Returns all non-empty selections + getSelections: -> + filtered = {} + for id, selection of @editor.getSelections() + unless selection.isEmpty() + filtered[id] = selection + + return filtered module.exports = ExState diff --git a/lib/ex-view-model.coffee b/lib/ex-view-model.coffee index 4f9675d..08dd9f6 100644 --- a/lib/ex-view-model.coffee +++ b/lib/ex-view-model.coffee @@ -1,20 +1,41 @@ {ViewModel, Input} = require './view-model' +AutoComplete = require './autocomplete' +Ex = require './ex' module.exports = class ExViewModel extends ViewModel - constructor: (@exCommand) -> + constructor: (@exCommand, withSelection) -> super(@exCommand, class: 'command') @historyIndex = -1 - @view.editor.on('core:move-up', @increaseHistoryEx) - @view.editor.on('core:move-down', @decreaseHistoryEx) + if withSelection + @view.editorElement.getModel().setText("'<,'>") + + @view.editorElement.addEventListener('keydown', @tabAutocomplete) + atom.commands.add(@view.editorElement, 'core:move-up', @increaseHistoryEx) + atom.commands.add(@view.editorElement, 'core:move-down', @decreaseHistoryEx) + + @autoComplete = new AutoComplete(Ex.getCommands()) restoreHistory: (index) -> - @view.editor.setText(@history(index).value) + @view.editorElement.getModel().setText(@history(index).value) history: (index) -> @exState.getExHistoryItem(index) + tabAutocomplete: (event) => + if event.keyCode == 9 + event.stopPropagation() + event.preventDefault() + + completed = @autoComplete.getAutocomplete(@view.editorElement.getModel().getText()) + if completed + @view.editorElement.getModel().setText(completed) + + return false + else + @autoComplete.resetCompletion() + increaseHistoryEx: => if @history(@historyIndex + 1)? @historyIndex += 1 @@ -24,7 +45,7 @@ class ExViewModel extends ViewModel if @historyIndex <= 0 # get us back to a clean slate @historyIndex = -1 - @view.editor.setText('') + @view.editorElement.getModel().setText('') else @historyIndex -= 1 @restoreHistory(@historyIndex) diff --git a/lib/ex.coffee b/lib/ex.coffee index 024f04c..b262023 100644 --- a/lib/ex.coffee +++ b/lib/ex.coffee @@ -1,28 +1,119 @@ path = require 'path' +CommandError = require './command-error' +fs = require 'fs-plus' +VimOption = require './vim-option' +_ = require 'underscore-plus' +atom + +defer = () -> + deferred = {} + deferred.promise = new Promise((resolve, reject) -> + deferred.resolve = resolve + deferred.reject = reject + ) + return deferred + trySave = (func) -> - deferred = Promise.defer() + deferred = defer() try - func() - deferred.resolve() + response = func() + + if response instanceof Promise + response.then -> + deferred.resolve() + else + deferred.resolve() catch error if error.message.endsWith('is a directory') atom.notifications.addWarning("Unable to save file: #{error.message}") - else if error.code is 'EACCES' and error.path? - atom.notifications.addWarning("Unable to save file: Permission denied '#{error.path}'") - else if error.code in ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST'] and error.path? - atom.notifications.addWarning("Unable to save file '#{error.path}'", detail: error.message) - else if error.code is 'EROFS' and error.path? - atom.notifications.addWarning("Unable to save file: Read-only file system '#{error.path}'") - else if errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message) + else if error.path? + if error.code is 'EACCES' + atom.notifications + .addWarning("Unable to save file: Permission denied '#{error.path}'") + else if error.code in ['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST'] + atom.notifications.addWarning("Unable to save file '#{error.path}'", + detail: error.message) + else if error.code is 'EROFS' + atom.notifications.addWarning( + "Unable to save file: Read-only file system '#{error.path}'") + else if (errorMatch = + /ENOTDIR, not a directory '([^']+)'/.exec(error.message)) fileName = errorMatch[1] - atom.notifications.addWarning("Unable to save file: A directory in the path '#{fileName}' could not be written to") + atom.notifications.addWarning("Unable to save file: A directory in the "+ + "path '#{fileName}' could not be written to") else throw error deferred.promise +saveAs = (filePath, editor) -> + fs.writeFileSync(filePath, editor.getText()) + +getFullPath = (filePath) -> + filePath = fs.normalize(filePath) + + if path.isAbsolute(filePath) + filePath + else if atom.project.getPaths().length == 0 + path.join(fs.normalize('~'), filePath) + else + path.join(atom.project.getPaths()[0], filePath) + +replaceGroups = (groups, string) -> + replaced = '' + escaped = false + while (char = string[0])? + string = string[1..] + if char is '\\' and not escaped + escaped = true + else if /\d/.test(char) and escaped + escaped = false + group = groups[parseInt(char)] + group ?= '' + replaced += group + else + escaped = false + replaced += char + + replaced + +getSearchTerm = (term, modifiers = {'g': true}) -> + + escaped = false + hasc = false + hasC = false + term_ = term + term = '' + for char in term_ + if char is '\\' and not escaped + escaped = true + term += char + else + if char is 'c' and escaped + hasc = true + term = term[...-1] + else if char is 'C' and escaped + hasC = true + term = term[...-1] + else if char isnt '\\' + term += char + escaped = false + + if hasC + modifiers['i'] = false + if (not hasC and not term.match('[A-Z]') and \ + atom.config.get('vim-mode.useSmartcaseForSearch')) or hasc + modifiers['i'] = true + + modFlags = Object.keys(modifiers).filter((key) -> modifiers[key]).join('') + + try + new RegExp(term, modFlags) + catch + new RegExp(_.escapeRegExp(term), modFlags) + class Ex @singleton: => @ex ||= new Ex @@ -30,24 +121,43 @@ class Ex @registerCommand: (name, func) => @singleton()[name] = func + @registerAlias: (alias, name) => + @singleton()[alias] = (args) => @singleton()[name](args) + + @getCommands: () => + Object.keys(Ex.singleton()).concat(Object.keys(Ex.prototype)).filter((cmd, index, list) -> + list.indexOf(cmd) == index + ) + quit: -> atom.workspace.getActivePane().destroyActiveItem() + quitall: -> + if !atom.config.get('ex-mode.onlyCloseBuffers') + atom.close() + else + atom.workspace.getTextEditors().forEach (editor) -> + editor.destroy() + q: => @quit() - tabedit: (filePaths...) -> - pane = atom.workspace.getActivePane() - if filePaths? and filePaths.length > 0 - for file in filePaths - do -> atom.workspace.openURIInPane file, pane + qall: => @quitall() + + tabedit: (args) => + if args.args.trim() isnt '' + @edit(args) else - atom.workspace.openURIInPane('', pane) + @tabnew(args) - tabe: (filePaths...) => @tabedit(filePaths...) + tabe: (args) => @tabedit(args) - tabnew: (filePaths...) => @tabedit(filePaths...) + tabnew: (args) => + if args.args.trim() is '' + atom.workspace.open() + else + @tabedit(args) - tabclose: => @quit() + tabclose: (args) => @quit(args) tabc: => @tabclose() @@ -63,81 +173,300 @@ class Ex tabp: => @tabprevious() - edit: (filePath) => @tabedit(filePath) if filePath? + tabonly: -> + tabBar = atom.workspace.getPanes()[0] + tabBarElement = atom.views.getView(tabBar).querySelector(".tab-bar") + tabBarElement.querySelector(".right-clicked") && tabBarElement.querySelector(".right-clicked").classList.remove("right-clicked") + tabBarElement.querySelector(".active").classList.add("right-clicked") + atom.commands.dispatch(tabBarElement, 'tabs:close-other-tabs') + tabBarElement.querySelector(".active").classList.remove("right-clicked") - e: (filePath) => @edit(filePath) + tabo: => @tabonly() - enew: => @edit() - - write: (filePath) -> - deferred = Promise.defer() - - projectPath = atom.project.getPath() - pane = atom.workspace.getActivePane() - editor = atom.workspace.getActiveEditor() - if atom.workspace.getActiveTextEditor().getPath() isnt undefined - if filePath? - editorPath = editor.getPath() - fullPath = if path.isAbsolute(filePath) - filePath - else - path.join(projectPath, filePath) - trySave(-> editor.saveAs(fullPath)) - .then -> - deferred.resolve() - editor.buffer.setPath(editorPath) - else - trySave(-> editor.save()) - .then deferred.resolve + edit: ({ range, args, editor }) -> + filePath = args.trim() + if filePath[0] is '!' + force = true + filePath = filePath[1..].trim() else - if filePath? - fullPath = if path.isAbsolute(filePath) - filePath - else - path.join(projectPath, filePath) - trySave(-> editor.saveAs(fullPath)) - .then deferred.resolve + force = false + + if editor.isModified() and not force + throw new CommandError('No write since last change (add ! to override)') + if filePath.indexOf(' ') isnt -1 + throw new CommandError('Only one file name allowed') + + if filePath.length isnt 0 + fullPath = getFullPath(filePath) + if fullPath is editor.getPath() + editor.getBuffer().reload() else - fullPath = atom.showSaveDialogSync() - if fullPath? - trySave(-> editor.saveAs(fullPath)) - .then deferred.resolve + atom.workspace.open(fullPath) + else + if editor.getPath()? + editor.getBuffer().reload() + else + throw new CommandError('No file name') - deferred.promise + e: (args) => @edit(args) - w: (filePath) => - @write(filePath) + enew: -> + atom.workspace.open() - wq: (filePath) => - @write(filePath).then => @quit() - - x: => @wq() + write: ({ range, args, editor, saveas }) -> + saveas ?= false + filePath = args + if filePath[0] is '!' + force = true + filePath = filePath[1..] + else + force = false - wa: -> + filePath = filePath.trim() + if filePath.indexOf(' ') isnt -1 + throw new CommandError('Only one file name allowed') + + deferred = defer() + + editor = atom.workspace.getActiveTextEditor() + + # Case 1; path is provided + if filePath.length isnt 0 + fullPath = getFullPath filePath + + # Only write when it does not exist or we have a force flag set. + if force or not fs.existsSync(fullPath) + editor.saveAs(fullPath) + return deferred.promise + + throw new CommandError("File exists (add ! to override)") + + # Case 2; no path provided, call editor save. + editor = atom.workspace.getActiveTextEditor() + + # Does the current buffer exist? + if editor.getPath()? and fs.existsSync(editor.getPath()) + trySave(-> editor.save()).then(deferred.promise) + else + # Cant see what the better API is but Pane.saveActiveItemAs() is the only call + # I could find that states it will ask the user. + trySave(-> atom.workspace.getActivePane().saveActiveItemAs()).then(deferred.promise) + + return deferred.promise + + wall: -> atom.workspace.saveAll() - split: (filePaths...) -> + w: (args) => + @write(args) + + wq: (args) => + @write(args).then(=> @quit()) + + wa: => + @wall() + + wqall: => + @wall() + @quitall() + + wqa: => + @wqall() + + xall: => + @wqall() + + xa: => + @wqall() + + saveas: (args) => + args.saveas = true + @write(args) + + xit: (args) => @wq(args) + + x: (args) => @xit(args) + + split: ({ range, args }) -> + args = args.trim() + filePaths = args.split(' ') + filePaths = undefined if filePaths.length is 1 and filePaths[0] is '' pane = atom.workspace.getActivePane() - if filePaths? and filePaths.length > 0 - newPane = pane.splitUp() - for file in filePaths - do -> - atom.workspace.openURIInPane file, newPane + if atom.config.get('ex-mode.splitbelow') + if filePaths? and filePaths.length > 0 + newPane = pane.splitDown() + for file in filePaths + do -> + atom.workspace.openURIInPane file, newPane + else + pane.splitDown(copyActiveItem: true) else - pane.splitUp(copyActiveItem: true) + if filePaths? and filePaths.length > 0 + newPane = pane.splitUp() + for file in filePaths + do -> + atom.workspace.openURIInPane file, newPane + else + pane.splitUp(copyActiveItem: true) - sp: (filePaths...) => @split(filePaths...) - vsplit: (filePaths...) -> + sp: (args) => @split(args) + + substitute: ({ range, args, editor, vimState }) -> + args_ = args.trimLeft() + delim = args_[0] + if /[a-z1-9\\"|]/i.test(delim) + throw new CommandError( + "Regular expressions can't be delimited by alphanumeric characters, '\\', '\"' or '|'") + args_ = args_[1..] + escapeChars = {t: '\t', n: '\n', r: '\r'} + parsed = ['', '', ''] + parsing = 0 + escaped = false + while (char = args_[0])? + args_ = args_[1..] + if char is delim + if not escaped + parsing++ + if parsing > 2 + throw new CommandError('Trailing characters') + else + parsed[parsing] = parsed[parsing][...-1] + else if char is '\\' and not escaped + parsed[parsing] += char + escaped = true + else if parsing == 1 and escaped and escapeChars[char]? + parsed[parsing] += escapeChars[char] + escaped = false + else + escaped = false + parsed[parsing] += char + + [pattern, substition, flags] = parsed + if pattern is '' + if vimState.getSearchHistoryItem? + # vim-mode + pattern = vimState.getSearchHistoryItem() + else if vimState.searchHistory? + # vim-mode-plus + pattern = vimState.searchHistory.get('prev') + + if not pattern? + atom.beep() + throw new CommandError('No previous regular expression') + else + if vimState.pushSearchHistory? + # vim-mode + vimState.pushSearchHistory(pattern) + else if vimState.searchHistory? + # vim-mode-plus + vimState.searchHistory.save(pattern) + + try + flagsObj = {} + flags.split('').forEach((flag) -> flagsObj[flag] = true) + # gdefault option + if atom.config.get('ex-mode.gdefault') + flagsObj.g = !flagsObj.g + patternRE = getSearchTerm(pattern, flagsObj) + catch e + if e.message.indexOf('Invalid flags supplied to RegExp constructor') is 0 + throw new CommandError("Invalid flags: #{e.message[45..]}") + else if e.message.indexOf('Invalid regular expression: ') is 0 + throw new CommandError("Invalid RegEx: #{e.message[27..]}") + else + throw e + + editor.transact -> + for line in [range[0]..range[1]] + editor.scanInBufferRange( + patternRE, + [[line, 0], [line + 1, 0]], + ({match, replace}) -> + replace(replaceGroups(match[..], substition)) + ) + + s: (args) => @substitute(args) + + vsplit: ({ range, args }) -> + args = args.trim() + filePaths = args.split(' ') + filePaths = undefined if filePaths.length is 1 and filePaths[0] is '' pane = atom.workspace.getActivePane() - if filePaths? and filePaths.length > 0 - newPane = pane.splitLeft() - for file in filePaths - do -> - atom.workspace.openURIInPane file, newPane + if atom.config.get('ex-mode.splitright') + if filePaths? and filePaths.length > 0 + newPane = pane.splitRight() + for file in filePaths + do -> + atom.workspace.openURIInPane file, newPane + else + pane.splitRight(copyActiveItem: true) else - pane.splitLeft(copyActiveItem: true) + if filePaths? and filePaths.length > 0 + newPane = pane.splitLeft() + for file in filePaths + do -> + atom.workspace.openURIInPane file, newPane + else + pane.splitLeft(copyActiveItem: true) - vsp: (filePaths...) => @vsplit(filePaths...) + vsp: (args) => @vsplit(args) + + delete: ({ range }) -> + range = [[range[0], 0], [range[1] + 1, 0]] + editor = atom.workspace.getActiveTextEditor() + + text = editor.getTextInBufferRange(range) + atom.clipboard.write(text) + + editor.buffer.setTextInRange(range, '') + + yank: ({ range }) -> + range = [[range[0], 0], [range[1] + 1, 0]] + txt = atom.workspace.getActiveTextEditor().getTextInBufferRange(range) + atom.clipboard.write(txt); + + set: ({ range, args }) -> + args = args.trim() + if args == "" + throw new CommandError("No option specified") + options = args.split(' ') + for option in options + do -> + if option.includes("=") + nameValPair = option.split("=") + if (nameValPair.length != 2) + throw new CommandError("Wrong option format. [name]=[value] format is expected") + optionName = nameValPair[0] + optionValue = nameValPair[1] + optionProcessor = VimOption.singleton()[optionName] + if not optionProcessor? + throw new CommandError("No such option: #{optionName}") + optionProcessor(optionValue) + else + optionProcessor = VimOption.singleton()[option] + if not optionProcessor? + throw new CommandError("No such option: #{option}") + optionProcessor() + + sort: ({ range }) => + editor = atom.workspace.getActiveTextEditor() + sortingRange = [[]] + + # If no range is provided, the entire file should be sorted. + isMultiLine = range[1] - range[0] > 1 + if isMultiLine + sortingRange = [[range[0], 0], [range[1] + 1, 0]] + else + sortingRange = [[0, 0], [editor.getLastBufferRow(), 0]] + + # Store every bufferedRow string in an array. + textLines = [] + for lineIndex in [sortingRange[0][0]..sortingRange[1][0] - 1] + textLines.push(editor.lineTextForBufferRow(lineIndex)) + + # Sort the array and join them together with newlines for writing back to the file. + sortedText = _.sortBy(textLines).join('\n') + '\n' + editor.buffer.setTextInRange(sortingRange, sortedText) module.exports = Ex diff --git a/lib/find.coffee b/lib/find.coffee new file mode 100644 index 0000000..60e98b8 --- /dev/null +++ b/lib/find.coffee @@ -0,0 +1,85 @@ +_ = require 'underscore-plus' + +getSearchTerm = (term, modifiers = {'g': true}) -> + + escaped = false + hasc = false + hasC = false + term_ = term + term = '' + for char in term_ + if char is '\\' and not escaped + escaped = true + term += char + else + if char is 'c' and escaped + hasc = true + term = term[...-1] + else if char is 'C' and escaped + hasC = true + term = term[...-1] + else if char isnt '\\' + term += char + escaped = false + + if hasC + modifiers['i'] = false + if (not hasC and not term.match('[A-Z]') and \ + atom.config.get("vim-mode:useSmartcaseForSearch")) or hasc + modifiers['i'] = true + + modFlags = Object.keys(modifiers).filter((key) -> modifiers[key]).join('') + + try + new RegExp(term, modFlags) + catch + new RegExp(_.escapeRegExp(term), modFlags) + +module.exports = { + findInBuffer : (buffer, pattern) -> + found = [] + buffer.scan(new RegExp(pattern, 'g'), (obj) -> found.push obj.range) + return found + + findNextInBuffer : (buffer, curPos, pattern) -> + found = @findInBuffer(buffer, pattern) + more = (i for i in found when i.compare([curPos, curPos]) is 1) + if more.length > 0 + return more[0].start.row + else if found.length > 0 + return found[0].start.row + else + return null + + findPreviousInBuffer : (buffer, curPos, pattern) -> + found = @findInBuffer(buffer, pattern) + less = (i for i in found when i.compare([curPos, curPos]) is -1) + if less.length > 0 + return less[less.length - 1].start.row + else if found.length > 0 + return found[found.length - 1].start.row + else + return null + + # Returns an array of ranges of all occurences of `term` in `editor`. + # The array is sorted so that the first occurences after the cursor come + # first (and the search wraps around). If `reverse` is true, the array is + # reversed so that the first occurence before the cursor comes first. + scanEditor: (term, editor, position, reverse = false) -> + [rangesBefore, rangesAfter] = [[], []] + editor.scan getSearchTerm(term), ({range}) -> + if reverse + isBefore = range.start.compare(position) < 0 + else + isBefore = range.start.compare(position) <= 0 + + if isBefore + rangesBefore.push(range) + else + rangesAfter.push(range) + + if reverse + rangesAfter.concat(rangesBefore).reverse() + else + rangesAfter.concat(rangesBefore) +} diff --git a/lib/global-ex-state.coffee b/lib/global-ex-state.coffee index 5325113..be51b85 100644 --- a/lib/global-ex-state.coffee +++ b/lib/global-ex-state.coffee @@ -1,4 +1,5 @@ class GlobalExState commandHistory: [] + setVim: (@vim) -> module.exports = GlobalExState diff --git a/lib/view-model.coffee b/lib/view-model.coffee index 7568aac..742d751 100644 --- a/lib/view-model.coffee +++ b/lib/view-model.coffee @@ -1,18 +1,22 @@ -ExCommandModeInputView = require './ex-command-mode-input-view' +ExNormalModeInputElement = require './ex-normal-mode-input-element' class ViewModel constructor: (@command, opts={}) -> {@editor, @exState} = @command - @view = new ExCommandModeInputView(@, opts) - @editor.commandModeInputView = @view + @view = new ExNormalModeInputElement().initialize(@, opts) + @editor.normalModeInputView = @view @exState.onDidFailToExecute => @view.remove() + @done = false confirm: (view) -> @exState.pushOperations(new Input(@view.value)) + @done = true cancel: (view) -> - @exState.pushOperations(new Input('')) + unless @done + @exState.pushOperations(new Input('')) + @done = true class Input constructor: (@characters) -> diff --git a/lib/vim-option.coffee b/lib/vim-option.coffee new file mode 100644 index 0000000..223395f --- /dev/null +++ b/lib/vim-option.coffee @@ -0,0 +1,65 @@ +class VimOption + @singleton: => + @option ||= new VimOption + + list: => + atom.config.set("editor.showInvisibles", true) + + nolist: => + atom.config.set("editor.showInvisibles", false) + + number: => + atom.config.set("editor.showLineNumbers", true) + + nu: => + @number() + + nonumber: => + atom.config.set("editor.showLineNumbers", false) + + nonu: => + @nonumber() + + splitright: => + atom.config.set("ex-mode.splitright", true) + + spr: => + @splitright() + + nosplitright: => + atom.config.set("ex-mode.splitright", false) + + nospr: => + @nosplitright() + + splitbelow: => + atom.config.set("ex-mode.splitbelow", true) + + sb: => + @splitbelow() + + nosplitbelow: => + atom.config.set("ex-mode.splitbelow", false) + + nosb: => + @nosplitbelow() + + smartcase: => + atom.config.set("vim-mode.useSmartcaseForSearch", true) + + scs: => + @smartcase() + + nosmartcase: => + atom.config.set("vim-mode.useSmartcaseForSearch", false) + + noscs: => + @nosmartcase() + + gdefault: => + atom.config.set("ex-mode.gdefault", true) + + nogdefault: => + atom.config.set("ex-mode.gdefault", false) + +module.exports = VimOption diff --git a/package.json b/package.json index 471ae38..cda6de9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ex-mode", "main": "./lib/ex-mode", - "version": "0.3.1", + "version": "0.18.0", "description": "Ex for Atom's vim-mode", "activationCommands": { "atom-workspace": "ex-mode:open" @@ -9,19 +9,25 @@ "repository": "https://github.com/lloeki/ex-mode", "license": "MIT", "engines": { - "atom": ">=0.174.0 <2.0.0" + "atom": ">=0.200.0 <2.0.0" }, "dependencies": { "underscore-plus": "1.x", "event-kit": "^0.7.2", "space-pen": "^5.1.1", - "atom-space-pen-views": "^2.0.4" + "atom-space-pen-views": "^2.0.4", + "fs-plus": "^2.2.8" }, "consumedServices": { "vim-mode": { "versions": { "^0.1.0": "consumeVim" } + }, + "vim-mode-plus": { + "versions": { + "^0.1.0": "consumeVimModePlus" + } } }, "providedServices": { @@ -31,5 +37,8 @@ "0.20.0": "provideEx" } } + }, + "devDependencies": { + "node-uuid": "^1.4.2" } } diff --git a/spec/autocomplete-spec.coffee b/spec/autocomplete-spec.coffee new file mode 100644 index 0000000..000e9c2 --- /dev/null +++ b/spec/autocomplete-spec.coffee @@ -0,0 +1,100 @@ +fs = require 'fs-plus' +path = require 'path' +os = require 'os' +uuid = require 'node-uuid' + +helpers = require './spec-helper' +AutoComplete = require '../lib/autocomplete' + +describe "autocomplete functionality", -> + beforeEach -> + @autoComplete = new AutoComplete(['taba', 'tabb', 'tabc']) + @testDir = path.join(os.tmpdir(), "atom-ex-mode-spec-#{uuid.v4()}") + @nonExistentTestDir = path.join(os.tmpdir(), "atom-ex-mode-spec-#{uuid.v4()}") + @testFile1 = path.join(@testDir, "atom-ex-testfile-a.txt") + @testFile2 = path.join(@testDir, "atom-ex-testfile-b.txt") + + runs => + fs.makeTreeSync(@testDir) + fs.closeSync(fs.openSync(@testFile1, 'w')); + fs.closeSync(fs.openSync(@testFile2, 'w')); + spyOn(@autoComplete, 'resetCompletion').andCallThrough() + spyOn(@autoComplete, 'getFilePathCompletion').andCallThrough() + spyOn(@autoComplete, 'getCommandCompletion').andCallThrough() + + afterEach -> + fs.removeSync(@testDir) + + describe "autocomplete commands", -> + beforeEach -> + @completed = @autoComplete.getAutocomplete('tab') + + it "returns taba", -> + expect(@completed).toEqual('taba') + + it "calls command function", -> + expect(@autoComplete.getCommandCompletion.callCount).toBe(1) + + describe "autocomplete commands, then autoComplete again", -> + beforeEach -> + @completed = @autoComplete.getAutocomplete('tab') + @completed = @autoComplete.getAutocomplete('tab') + + it "returns tabb", -> + expect(@completed).toEqual('tabb') + + it "calls command function", -> + expect(@autoComplete.getCommandCompletion.callCount).toBe(1) + + describe "autocomplete directory", -> + beforeEach -> + filePath = path.join(os.tmpdir(), 'atom-ex-mode-spec-') + @completed = @autoComplete.getAutocomplete('tabe ' + filePath) + + it "returns testDir", -> + expected = 'tabe ' + @testDir + path.sep + expect(@completed).toEqual(expected) + + it "clears autocomplete", -> + expect(@autoComplete.resetCompletion.callCount).toBe(1) + + describe "autocomplete directory, then autocomplete again", -> + beforeEach -> + filePath = path.join(os.tmpdir(), 'atom-ex-mode-spec-') + @completed = @autoComplete.getAutocomplete('tabe ' + filePath) + @completed = @autoComplete.getAutocomplete(@completed) + + it "returns test file 1", -> + expect(@completed).toEqual('tabe ' + @testFile1) + + it "lists files twice", -> + expect(@autoComplete.getFilePathCompletion.callCount).toBe(2) + + describe "autocomplete full directory, then autocomplete again", -> + beforeEach -> + filePath = path.join(@testDir, 'a') + @completed = @autoComplete.getAutocomplete('tabe ' + filePath) + @completed = @autoComplete.getAutocomplete(@completed) + + it "returns test file 2", -> + expect(@completed).toEqual('tabe ' + @testFile2) + + it "lists files once", -> + expect(@autoComplete.getFilePathCompletion.callCount).toBe(1) + + describe "autocomplete non existent directory", -> + beforeEach -> + @completed = @autoComplete.getAutocomplete('tabe ' + @nonExistentTestDir) + + it "returns no completions", -> + expected = ''; + expect(@completed).toEqual(expected) + + describe "autocomplete existing file as directory", -> + beforeEach -> + filePath = @testFile1 + path.sep + @completed = @autoComplete.getAutocomplete('tabe ' + filePath) + + it "returns no completions", -> + expected = ''; + expect(@completed).toEqual(expected) diff --git a/spec/ex-commands-spec.coffee b/spec/ex-commands-spec.coffee new file mode 100644 index 0000000..29f211e --- /dev/null +++ b/spec/ex-commands-spec.coffee @@ -0,0 +1,1019 @@ +fs = require 'fs-plus' +path = require 'path' +os = require 'os' +uuid = require 'node-uuid' +helpers = require './spec-helper' + +ExClass = require('../lib/ex') +Ex = ExClass.singleton() + +describe "the commands", -> + [editor, editorElement, vimState, exState, dir, dir2] = [] + projectPath = (fileName) -> path.join(dir, fileName) + beforeEach -> + vimMode = atom.packages.loadPackage('vim-mode-plus') + exMode = atom.packages.loadPackage('ex-mode') + waitsForPromise -> + activationPromise = exMode.activate() + helpers.activateExMode() + activationPromise + + runs -> + spyOn(exMode.mainModule.globalExState, 'setVim').andCallThrough() + + waitsForPromise -> + vimMode.activate() + + waitsFor -> + exMode.mainModule.globalExState.setVim.calls.length > 0 + + runs -> + dir = path.join(os.tmpdir(), "atom-ex-mode-spec-#{uuid.v4()}") + dir2 = path.join(os.tmpdir(), "atom-ex-mode-spec-#{uuid.v4()}") + fs.makeTreeSync(dir) + fs.makeTreeSync(dir2) + atom.project.setPaths([dir, dir2]) + + helpers.getEditorElement (element) -> + atom.commands.dispatch(element, "ex-mode:open") + atom.commands.dispatch(element.getModel().normalModeInputView.editorElement, + "core:cancel") + editorElement = element + editor = editorElement.getModel() + vimState = vimMode.mainModule.getEditorState(editor) + exState = exMode.mainModule.exStates.get(editor) + vimState.resetNormalMode() + editor.setText("abc\ndef\nabc\ndef") + + afterEach -> + fs.removeSync(dir) + fs.removeSync(dir2) + + keydown = (key, options={}) -> + options.element ?= editorElement + helpers.keydown(key, options) + + normalModeInputKeydown = (key, opts = {}) -> + editor.normalModeInputView.editorElement.getModel().setText(key) + + submitNormalModeInputText = (text) -> + commandEditor = editor.normalModeInputView.editorElement + commandEditor.getModel().setText(text) + atom.commands.dispatch(commandEditor, "core:confirm") + + openEx = -> + atom.commands.dispatch(editorElement, "ex-mode:open") + + describe "as a motion", -> + beforeEach -> + editor.setCursorBufferPosition([0, 0]) + + it "moves the cursor to a specific line", -> + openEx() + submitNormalModeInputText '2' + + expect(editor.getCursorBufferPosition()).toEqual [1, 0] + + it "moves to the second address", -> + openEx() + submitNormalModeInputText '1,3' + + expect(editor.getCursorBufferPosition()).toEqual [2, 0] + + it "works with offsets", -> + openEx() + submitNormalModeInputText '2+1' + expect(editor.getCursorBufferPosition()).toEqual [2, 0] + + openEx() + submitNormalModeInputText '-2' + expect(editor.getCursorBufferPosition()).toEqual [0, 0] + + it "limits to the last line", -> + openEx() + submitNormalModeInputText '10' + expect(editor.getCursorBufferPosition()).toEqual [3, 0] + editor.setCursorBufferPosition([0, 0]) + + openEx() + submitNormalModeInputText '3,10' + expect(editor.getCursorBufferPosition()).toEqual [3, 0] + editor.setCursorBufferPosition([0, 0]) + + openEx() + submitNormalModeInputText '$+1000' + expect(editor.getCursorBufferPosition()).toEqual [3, 0] + editor.setCursorBufferPosition([0, 0]) + + it "goes to the first line with address 0", -> + editor.setCursorBufferPosition([2, 0]) + openEx() + submitNormalModeInputText '0' + expect(editor.getCursorBufferPosition()).toEqual [0, 0] + + editor.setCursorBufferPosition([2, 0]) + openEx() + submitNormalModeInputText '0,0' + expect(editor.getCursorBufferPosition()).toEqual [0, 0] + + it "doesn't move when the address is the current line", -> + openEx() + submitNormalModeInputText '.' + expect(editor.getCursorBufferPosition()).toEqual [0, 0] + + openEx() + submitNormalModeInputText ',' + expect(editor.getCursorBufferPosition()).toEqual [0, 0] + + it "moves to the last line", -> + openEx() + submitNormalModeInputText '$' + expect(editor.getCursorBufferPosition()).toEqual [3, 0] + + it "moves to a mark's line", -> + keydown('l') + keydown('m') + normalModeInputKeydown 'a' + keydown('j') + openEx() + submitNormalModeInputText "'a" + expect(editor.getCursorBufferPosition()).toEqual [0, 0] + + it "moves to a specified search", -> + openEx() + submitNormalModeInputText '/def' + expect(editor.getCursorBufferPosition()).toEqual [1, 0] + + editor.setCursorBufferPosition([2, 0]) + openEx() + submitNormalModeInputText '?def' + expect(editor.getCursorBufferPosition()).toEqual [1, 0] + + editor.setCursorBufferPosition([3, 0]) + openEx() + submitNormalModeInputText '/ef' + expect(editor.getCursorBufferPosition()).toEqual [1, 0] + + describe ":write", -> + describe "when editing a new file", -> + beforeEach -> + editor.getBuffer().setText('abc\ndef') + + it "opens the save dialog", -> + spyOn(atom, 'showSaveDialogSync') + openEx() + submitNormalModeInputText('write') + expect(atom.showSaveDialogSync).toHaveBeenCalled() + + it "saves when a path is specified in the save dialog", -> + filePath = projectPath('write-from-save-dialog') + spyOn(atom, 'showSaveDialogSync').andReturn(filePath) + openEx() + submitNormalModeInputText('write') + expect(fs.existsSync(filePath)).toBe(true) + expect(fs.readFileSync(filePath, 'utf-8')).toEqual('abc\ndef') + expect(editor.isModified()).toBe(false) + + it "saves when a path is specified in the save dialog", -> + spyOn(atom, 'showSaveDialogSync').andReturn(undefined) + spyOn(fs, 'writeFileSync') + openEx() + submitNormalModeInputText('write') + expect(fs.writeFileSync.calls.length).toBe(0) + + describe "when editing an existing file", -> + filePath = '' + i = 0 + + beforeEach -> + i++ + filePath = projectPath("write-#{i}") + editor.setText('abc\ndef') + editor.saveAs(filePath) + + it "saves the file", -> + editor.setText('abc') + openEx() + submitNormalModeInputText('write') + expect(fs.readFileSync(filePath, 'utf-8')).toEqual('abc') + expect(editor.isModified()).toBe(false) + + describe "with a specified path", -> + newPath = '' + + beforeEach -> + newPath = path.relative(dir, "#{filePath}.new") + editor.getBuffer().setText('abc') + openEx() + + afterEach -> + submitNormalModeInputText("write #{newPath}") + newPath = path.resolve(dir, fs.normalize(newPath)) + expect(fs.existsSync(newPath)).toBe(true) + expect(fs.readFileSync(newPath, 'utf-8')).toEqual('abc') + expect(editor.isModified()).toBe(true) + fs.removeSync(newPath) + + it "saves to the path", -> + + it "expands .", -> + newPath = path.join('.', newPath) + + it "expands ..", -> + newPath = path.join('..', newPath) + + it "expands ~", -> + newPath = path.join('~', newPath) + + it "throws an error with more than one path", -> + openEx() + submitNormalModeInputText('write path1 path2') + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: Only one file name allowed' + ) + + describe "when the file already exists", -> + existsPath = '' + + beforeEach -> + existsPath = projectPath('write-exists') + fs.writeFileSync(existsPath, 'abc') + + afterEach -> + fs.removeSync(existsPath) + + it "throws an error if the file already exists", -> + openEx() + submitNormalModeInputText("write #{existsPath}") + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: File exists (add ! to override)' + ) + expect(fs.readFileSync(existsPath, 'utf-8')).toEqual('abc') + + it "writes if forced with :write!", -> + openEx() + submitNormalModeInputText("write! #{existsPath}") + expect(atom.notifications.notifications).toEqual([]) + expect(fs.readFileSync(existsPath, 'utf-8')).toEqual('abc\ndef') + + describe ":wall", -> + it "saves all", -> + spyOn(atom.workspace, 'saveAll') + openEx() + submitNormalModeInputText('wall') + expect(atom.workspace.saveAll).toHaveBeenCalled() + + describe ":saveas", -> + describe "when editing a new file", -> + beforeEach -> + editor.getBuffer().setText('abc\ndef') + + it "opens the save dialog", -> + spyOn(atom, 'showSaveDialogSync') + openEx() + submitNormalModeInputText('saveas') + expect(atom.showSaveDialogSync).toHaveBeenCalled() + + it "saves when a path is specified in the save dialog", -> + filePath = projectPath('saveas-from-save-dialog') + spyOn(atom, 'showSaveDialogSync').andReturn(filePath) + openEx() + submitNormalModeInputText('saveas') + expect(fs.existsSync(filePath)).toBe(true) + expect(fs.readFileSync(filePath, 'utf-8')).toEqual('abc\ndef') + + it "saves when a path is specified in the save dialog", -> + spyOn(atom, 'showSaveDialogSync').andReturn(undefined) + spyOn(fs, 'writeFileSync') + openEx() + submitNormalModeInputText('saveas') + expect(fs.writeFileSync.calls.length).toBe(0) + + describe "when editing an existing file", -> + filePath = '' + i = 0 + + beforeEach -> + i++ + filePath = projectPath("saveas-#{i}") + editor.setText('abc\ndef') + editor.saveAs(filePath) + + it "complains if no path given", -> + editor.setText('abc') + openEx() + submitNormalModeInputText('saveas') + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: Argument required' + ) + + describe "with a specified path", -> + newPath = '' + + beforeEach -> + newPath = path.relative(dir, "#{filePath}.new") + editor.getBuffer().setText('abc') + openEx() + + afterEach -> + submitNormalModeInputText("saveas #{newPath}") + newPath = path.resolve(dir, fs.normalize(newPath)) + expect(fs.existsSync(newPath)).toBe(true) + expect(fs.readFileSync(newPath, 'utf-8')).toEqual('abc') + expect(editor.isModified()).toBe(false) + fs.removeSync(newPath) + + it "saves to the path", -> + + it "expands .", -> + newPath = path.join('.', newPath) + + it "expands ..", -> + newPath = path.join('..', newPath) + + it "expands ~", -> + newPath = path.join('~', newPath) + + it "throws an error with more than one path", -> + openEx() + submitNormalModeInputText('saveas path1 path2') + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: Only one file name allowed' + ) + + describe "when the file already exists", -> + existsPath = '' + + beforeEach -> + existsPath = projectPath('saveas-exists') + fs.writeFileSync(existsPath, 'abc') + + afterEach -> + fs.removeSync(existsPath) + + it "throws an error if the file already exists", -> + openEx() + submitNormalModeInputText("saveas #{existsPath}") + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: File exists (add ! to override)' + ) + expect(fs.readFileSync(existsPath, 'utf-8')).toEqual('abc') + + it "writes if forced with :saveas!", -> + openEx() + submitNormalModeInputText("saveas! #{existsPath}") + expect(atom.notifications.notifications).toEqual([]) + expect(fs.readFileSync(existsPath, 'utf-8')).toEqual('abc\ndef') + + describe ":quit", -> + pane = null + beforeEach -> + waitsForPromise -> + pane = atom.workspace.getActivePane() + spyOn(pane, 'destroyActiveItem').andCallThrough() + atom.workspace.open() + + it "closes the active pane item if not modified", -> + openEx() + submitNormalModeInputText('quit') + expect(pane.destroyActiveItem).toHaveBeenCalled() + expect(pane.getItems().length).toBe(1) + + describe "when the active pane item is modified", -> + beforeEach -> + editor.getBuffer().setText('def') + + it "opens the prompt to save", -> + spyOn(pane, 'promptToSaveItem') + openEx() + submitNormalModeInputText('quit') + expect(pane.promptToSaveItem).toHaveBeenCalled() + + describe ":quitall", -> + it "closes Atom", -> + spyOn(atom, 'close') + openEx() + submitNormalModeInputText('quitall') + expect(atom.close).toHaveBeenCalled() + + describe ":tabclose", -> + it "acts as an alias to :quit", -> + spyOn(Ex, 'tabclose').andCallThrough() + spyOn(Ex, 'quit').andCallThrough() + openEx() + submitNormalModeInputText('tabclose') + expect(Ex.quit).toHaveBeenCalledWith(Ex.tabclose.calls[0].args...) + + describe ":tabnext", -> + pane = null + beforeEach -> + waitsForPromise -> + pane = atom.workspace.getActivePane() + atom.workspace.open().then -> atom.workspace.open() + .then -> atom.workspace.open() + + it "switches to the next tab", -> + pane.activateItemAtIndex(1) + openEx() + submitNormalModeInputText('tabnext') + expect(pane.getActiveItemIndex()).toBe(2) + + it "wraps around", -> + pane.activateItemAtIndex(pane.getItems().length - 1) + openEx() + submitNormalModeInputText('tabnext') + expect(pane.getActiveItemIndex()).toBe(0) + + describe ":tabprevious", -> + pane = null + beforeEach -> + waitsForPromise -> + pane = atom.workspace.getActivePane() + atom.workspace.open().then -> atom.workspace.open() + .then -> atom.workspace.open() + + it "switches to the previous tab", -> + pane.activateItemAtIndex(1) + openEx() + submitNormalModeInputText('tabprevious') + expect(pane.getActiveItemIndex()).toBe(0) + + it "wraps around", -> + pane.activateItemAtIndex(0) + openEx() + submitNormalModeInputText('tabprevious') + expect(pane.getActiveItemIndex()).toBe(pane.getItems().length - 1) + + describe ":wq", -> + beforeEach -> + spyOn(Ex, 'write').andCallThrough() + spyOn(Ex, 'quit') + + it "writes the file, then quits", -> + spyOn(atom, 'showSaveDialogSync').andReturn(projectPath('wq-1')) + openEx() + submitNormalModeInputText('wq') + expect(Ex.write).toHaveBeenCalled() + # Since `:wq` only calls `:quit` after `:write` is finished, we need to + # wait a bit for the `:quit` call to occur + waitsFor((-> Ex.quit.wasCalled), "the :quit command to be called", 100) + + it "doesn't quit when the file is new and no path is specified in the save dialog", -> + spyOn(atom, 'showSaveDialogSync').andReturn(undefined) + openEx() + submitNormalModeInputText('wq') + expect(Ex.write).toHaveBeenCalled() + wasNotCalled = false + # FIXME: This seems dangerous, but setTimeout somehow doesn't work. + setImmediate((-> + wasNotCalled = not Ex.quit.wasCalled)) + waitsFor((-> wasNotCalled), 100) + + it "passes the file name", -> + openEx() + submitNormalModeInputText('wq wq-2') + expect(Ex.write) + .toHaveBeenCalled() + expect(Ex.write.calls[0].args[0].args.trim()).toEqual('wq-2') + waitsFor((-> Ex.quit.wasCalled), "the :quit command to be called", 100) + + describe ":xit", -> + it "acts as an alias to :wq", -> + spyOn(Ex, 'wq') + openEx() + submitNormalModeInputText('xit') + expect(Ex.wq).toHaveBeenCalled() + + describe ":x", -> + it "acts as an alias to :xit", -> + spyOn(Ex, 'xit') + openEx() + submitNormalModeInputText('x') + expect(Ex.xit).toHaveBeenCalled() + + describe ":wqall", -> + it "calls :wall, then :quitall", -> + spyOn(Ex, 'wall') + spyOn(Ex, 'quitall') + openEx() + submitNormalModeInputText('wqall') + expect(Ex.wall).toHaveBeenCalled() + expect(Ex.quitall).toHaveBeenCalled() + + describe ":edit", -> + describe "without a file name", -> + it "reloads the file from the disk", -> + filePath = projectPath("edit-1") + editor.getBuffer().setText('abc') + editor.saveAs(filePath) + fs.writeFileSync(filePath, 'def') + openEx() + submitNormalModeInputText('edit') + # Reloading takes a bit + waitsFor((-> editor.getText() is 'def'), + "the editor's content to change", 100) + + it "doesn't reload when the file has been modified", -> + filePath = projectPath("edit-2") + editor.getBuffer().setText('abc') + editor.saveAs(filePath) + editor.getBuffer().setText('abcd') + fs.writeFileSync(filePath, 'def') + openEx() + submitNormalModeInputText('edit') + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: No write since last change (add ! to override)') + isntDef = false + setImmediate(-> isntDef = editor.getText() isnt 'def') + waitsFor((-> isntDef), "the editor's content not to change", 50) + + it "reloads when the file has been modified and it is forced", -> + filePath = projectPath("edit-3") + editor.getBuffer().setText('abc') + editor.saveAs(filePath) + editor.getBuffer().setText('abcd') + fs.writeFileSync(filePath, 'def') + openEx() + submitNormalModeInputText('edit!') + expect(atom.notifications.notifications.length).toBe(0) + waitsFor((-> editor.getText() is 'def') + "the editor's content to change", 50) + + it "throws an error when editing a new file", -> + editor.getBuffer().reload() + openEx() + submitNormalModeInputText('edit') + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: No file name') + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText('edit!') + expect(atom.notifications.notifications[1].message).toEqual( + 'Command error: No file name') + + describe "with a file name", -> + beforeEach -> + spyOn(atom.workspace, 'open') + editor.getBuffer().reload() + + it "opens the specified path", -> + filePath = projectPath('edit-new-test') + openEx() + submitNormalModeInputText("edit #{filePath}") + expect(atom.workspace.open).toHaveBeenCalledWith(filePath) + + it "opens a relative path", -> + openEx() + submitNormalModeInputText('edit edit-relative-test') + expect(atom.workspace.open).toHaveBeenCalledWith( + projectPath('edit-relative-test')) + + it "throws an error if trying to open more than one file", -> + openEx() + submitNormalModeInputText('edit edit-new-test-1 edit-new-test-2') + expect(atom.workspace.open.callCount).toBe(0) + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: Only one file name allowed') + + describe ":tabedit", -> + it "acts as an alias to :edit if supplied with a path", -> + spyOn(Ex, 'tabedit').andCallThrough() + spyOn(Ex, 'edit') + openEx() + submitNormalModeInputText('tabedit tabedit-test') + expect(Ex.edit).toHaveBeenCalledWith(Ex.tabedit.calls[0].args...) + + it "acts as an alias to :tabnew if not supplied with a path", -> + spyOn(Ex, 'tabedit').andCallThrough() + spyOn(Ex, 'tabnew') + openEx() + submitNormalModeInputText('tabedit ') + expect(Ex.tabnew) + .toHaveBeenCalledWith(Ex.tabedit.calls[0].args...) + + describe ":tabnew", -> + it "opens a new tab", -> + spyOn(atom.workspace, 'open') + openEx() + submitNormalModeInputText('tabnew') + expect(atom.workspace.open).toHaveBeenCalled() + + it "opens a new tab for editing when provided an argument", -> + spyOn(Ex, 'tabnew').andCallThrough() + spyOn(Ex, 'tabedit') + openEx() + submitNormalModeInputText('tabnew tabnew-test') + expect(Ex.tabedit) + .toHaveBeenCalledWith(Ex.tabnew.calls[0].args...) + + describe ":split", -> + it "splits the current file upwards/downward", -> + pane = atom.workspace.getActivePane() + if atom.config.get('ex-mode.splitbelow') + spyOn(pane, 'splitDown').andCallThrough() + filePath = projectPath('split') + editor.saveAs(filePath) + openEx() + submitNormalModeInputText('split') + expect(pane.splitDown).toHaveBeenCalled() + else + spyOn(pane, 'splitUp').andCallThrough() + filePath = projectPath('split') + editor.saveAs(filePath) + openEx() + submitNormalModeInputText('split') + expect(pane.splitUp).toHaveBeenCalled() + # FIXME: Should test whether the new pane contains a TextEditor + # pointing to the same path + + describe ":vsplit", -> + it "splits the current file to the left/right", -> + if atom.config.get('ex-mode.splitright') + pane = atom.workspace.getActivePane() + spyOn(pane, 'splitRight').andCallThrough() + filePath = projectPath('vsplit') + editor.saveAs(filePath) + openEx() + submitNormalModeInputText('vsplit') + expect(pane.splitLeft).toHaveBeenCalled() + else + pane = atom.workspace.getActivePane() + spyOn(pane, 'splitLeft').andCallThrough() + filePath = projectPath('vsplit') + editor.saveAs(filePath) + openEx() + submitNormalModeInputText('vsplit') + expect(pane.splitLeft).toHaveBeenCalled() + # FIXME: Should test whether the new pane contains a TextEditor + # pointing to the same path + + describe ":delete", -> + beforeEach -> + editor.setText('abc\ndef\nghi\njkl') + editor.setCursorBufferPosition([2, 0]) + + it "deletes the current line", -> + openEx() + submitNormalModeInputText('delete') + expect(editor.getText()).toEqual('abc\ndef\njkl') + + it "copies the deleted text", -> + openEx() + submitNormalModeInputText('delete') + expect(atom.clipboard.read()).toEqual('ghi\n') + + it "deletes the lines in the given range", -> + processedOpStack = false + exState.onDidProcessOpStack -> processedOpStack = true + openEx() + submitNormalModeInputText('1,2delete') + expect(editor.getText()).toEqual('ghi\njkl') + + waitsFor -> processedOpStack + editor.setText('abc\ndef\nghi\njkl') + editor.setCursorBufferPosition([1, 1]) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(',/k/delete') + expect(editor.getText()).toEqual('abc\n') + + it "undos deleting several lines at once", -> + openEx() + submitNormalModeInputText('-1,.delete') + expect(editor.getText()).toEqual('abc\njkl') + atom.commands.dispatch(editorElement, 'core:undo') + expect(editor.getText()).toEqual('abc\ndef\nghi\njkl') + + describe ":substitute", -> + beforeEach -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + editor.setCursorBufferPosition([0, 0]) + + it "replaces a character on the current line", -> + openEx() + submitNormalModeInputText(':substitute /a/x') + expect(editor.getText()).toEqual('xbcaABC\ndefdDEF\nabcaABC') + + it "doesn't need a space before the arguments", -> + openEx() + submitNormalModeInputText(':substitute/a/x') + expect(editor.getText()).toEqual('xbcaABC\ndefdDEF\nabcaABC') + + it "respects modifiers passed to it", -> + openEx() + submitNormalModeInputText(':substitute/a/x/g') + expect(editor.getText()).toEqual('xbcxABC\ndefdDEF\nabcaABC') + + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':substitute/a/x/gi') + expect(editor.getText()).toEqual('xbcxxBC\ndefdDEF\nabcaABC') + + it "replaces on multiple lines", -> + openEx() + submitNormalModeInputText(':%substitute/abc/ghi') + expect(editor.getText()).toEqual('ghiaABC\ndefdDEF\nghiaABC') + + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':%substitute/abc/ghi/ig') + expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nghiaghi') + + it "set gdefault option", -> + openEx() + atom.config.set('ex-mode.gdefault', true) + submitNormalModeInputText(':substitute/a/x') + expect(editor.getText()).toEqual('xbcxABC\ndefdDEF\nabcaABC') + + atom.commands.dispatch(editorElement, 'ex-mode:open') + atom.config.set('ex-mode.gdefault', true) + submitNormalModeInputText(':substitute/a/x/g') + expect(editor.getText()).toEqual('xbcaABC\ndefdDEF\nabcaABC') + + describe ":yank", -> + beforeEach -> + editor.setText('abc\ndef\nghi\njkl') + editor.setCursorBufferPosition([2, 0]) + + it "yanks the current line", -> + openEx() + submitNormalModeInputText('yank') + expect(atom.clipboard.read()).toEqual('ghi\n') + + it "yanks the lines in the given range", -> + openEx() + submitNormalModeInputText('1,2yank') + expect(atom.clipboard.read()).toEqual('abc\ndef\n') + + describe "illegal delimiters", -> + test = (delim) -> + openEx() + submitNormalModeInputText(":substitute #{delim}a#{delim}x#{delim}gi") + expect(atom.notifications.notifications[0].message).toEqual( + "Command error: Regular expressions can't be delimited by alphanumeric characters, '\\', '\"' or '|'") + expect(editor.getText()).toEqual('abcaABC\ndefdDEF\nabcaABC') + + it "can't be delimited by letters", -> test 'n' + it "can't be delimited by numbers", -> test '3' + it "can't be delimited by '\\'", -> test '\\' + it "can't be delimited by '\"'", -> test '"' + it "can't be delimited by '|'", -> test '|' + + describe "empty replacement", -> + beforeEach -> + editor.setText('abcabc\nabcabc') + + it "removes the pattern without modifiers", -> + openEx() + submitNormalModeInputText(":substitute/abc//") + expect(editor.getText()).toEqual('abc\nabcabc') + + it "removes the pattern with modifiers", -> + openEx() + submitNormalModeInputText(":substitute/abc//g") + expect(editor.getText()).toEqual('\nabcabc') + + describe "replacing with escape sequences", -> + beforeEach -> + editor.setText('abc,def,ghi') + + test = (escapeChar, escaped) -> + openEx() + submitNormalModeInputText(":substitute/,/\\#{escapeChar}/g") + expect(editor.getText()).toEqual("abc#{escaped}def#{escaped}ghi") + + it "replaces with a tab", -> test('t', '\t') + it "replaces with a linefeed", -> test('n', '\n') + it "replaces with a carriage return", -> test('r', '\r') + + describe "case sensitivity", -> + describe "respects the smartcase setting", -> + beforeEach -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + + it "uses case sensitive search if smartcase is off and the pattern is lowercase", -> + atom.config.set('vim-mode.useSmartcaseForSearch', false) + openEx() + submitNormalModeInputText(':substitute/abc/ghi/g') + expect(editor.getText()).toEqual('ghiaABC\ndefdDEF\nabcaABC') + + it "uses case sensitive search if smartcase is off and the pattern is uppercase", -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + openEx() + submitNormalModeInputText(':substitute/ABC/ghi/g') + expect(editor.getText()).toEqual('abcaghi\ndefdDEF\nabcaABC') + + it "uses case insensitive search if smartcase is on and the pattern is lowercase", -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + atom.config.set('vim-mode.useSmartcaseForSearch', true) + openEx() + submitNormalModeInputText(':substitute/abc/ghi/g') + expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nabcaABC') + + it "uses case sensitive search if smartcase is on and the pattern is uppercase", -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + openEx() + submitNormalModeInputText(':substitute/ABC/ghi/g') + expect(editor.getText()).toEqual('abcaghi\ndefdDEF\nabcaABC') + + describe "\\c and \\C in the pattern", -> + beforeEach -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + + it "uses case insensitive search if smartcase is off and \c is in the pattern", -> + atom.config.set('vim-mode.useSmartcaseForSearch', false) + openEx() + submitNormalModeInputText(':substitute/abc\\c/ghi/g') + expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nabcaABC') + + it "doesn't matter where in the pattern \\c is", -> + atom.config.set('vim-mode.useSmartcaseForSearch', false) + openEx() + submitNormalModeInputText(':substitute/a\\cbc/ghi/g') + expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nabcaABC') + + it "uses case sensitive search if smartcase is on, \\C is in the pattern and the pattern is lowercase", -> + atom.config.set('vim-mode.useSmartcaseForSearch', true) + openEx() + submitNormalModeInputText(':substitute/a\\Cbc/ghi/g') + expect(editor.getText()).toEqual('ghiaABC\ndefdDEF\nabcaABC') + + it "overrides \\C with \\c if \\C comes first", -> + atom.config.set('vim-mode.useSmartcaseForSearch', true) + openEx() + submitNormalModeInputText(':substitute/a\\Cb\\cc/ghi/g') + expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nabcaABC') + + it "overrides \\C with \\c if \\c comes first", -> + atom.config.set('vim-mode.useSmartcaseForSearch', true) + openEx() + submitNormalModeInputText(':substitute/a\\cb\\Cc/ghi/g') + expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nabcaABC') + + it "overrides an appended /i flag with \\C", -> + atom.config.set('vim-mode.useSmartcaseForSearch', true) + openEx() + submitNormalModeInputText(':substitute/ab\\Cc/ghi/gi') + expect(editor.getText()).toEqual('ghiaABC\ndefdDEF\nabcaABC') + + describe "capturing groups", -> + beforeEach -> + editor.setText('abcaABC\ndefdDEF\nabcaABC') + + it "replaces \\1 with the first group", -> + openEx() + submitNormalModeInputText(':substitute/bc(.{2})/X\\1X') + expect(editor.getText()).toEqual('aXaAXBC\ndefdDEF\nabcaABC') + + it "replaces multiple groups", -> + openEx() + submitNormalModeInputText(':substitute/a([a-z]*)aA([A-Z]*)/X\\1XY\\2Y') + expect(editor.getText()).toEqual('XbcXYBCY\ndefdDEF\nabcaABC') + + it "replaces \\0 with the entire match", -> + openEx() + submitNormalModeInputText(':substitute/ab(ca)AB/X\\0X') + expect(editor.getText()).toEqual('XabcaABXC\ndefdDEF\nabcaABC') + + describe ":set", -> + it "throws an error without a specified option", -> + openEx() + submitNormalModeInputText(':set') + expect(atom.notifications.notifications[0].message).toEqual( + 'Command error: No option specified') + + it "sets multiple options at once", -> + atom.config.set('editor.showInvisibles', false) + atom.config.set('editor.showLineNumbers', false) + openEx() + submitNormalModeInputText(':set list number') + expect(atom.config.get('editor.showInvisibles')).toBe(true) + expect(atom.config.get('editor.showLineNumbers')).toBe(true) + + describe "the options", -> + beforeEach -> + atom.config.set('editor.showInvisibles', false) + atom.config.set('editor.showLineNumbers', false) + + it "sets (no)list", -> + openEx() + submitNormalModeInputText(':set list') + expect(atom.config.get('editor.showInvisibles')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nolist') + expect(atom.config.get('editor.showInvisibles')).toBe(false) + + it "sets (no)nu(mber)", -> + openEx() + submitNormalModeInputText(':set nu') + expect(atom.config.get('editor.showLineNumbers')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nonu') + expect(atom.config.get('editor.showLineNumbers')).toBe(false) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set number') + expect(atom.config.get('editor.showLineNumbers')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nonumber') + expect(atom.config.get('editor.showLineNumbers')).toBe(false) + + it "sets (no)sp(lit)r(ight)", -> + openEx() + submitNormalModeInputText(':set spr') + expect(atom.config.get('ex-mode.splitright')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nospr') + expect(atom.config.get('ex-mode.splitright')).toBe(false) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set splitright') + expect(atom.config.get('ex-mode.splitright')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nosplitright') + expect(atom.config.get('ex-mode.splitright')).toBe(false) + + it "sets (no)s(plit)b(elow)", -> + openEx() + submitNormalModeInputText(':set sb') + expect(atom.config.get('ex-mode.splitbelow')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nosb') + expect(atom.config.get('ex-mode.splitbelow')).toBe(false) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set splitbelow') + expect(atom.config.get('ex-mode.splitbelow')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nosplitbelow') + expect(atom.config.get('ex-mode.splitbelow')).toBe(false) + + it "sets (no)s(mart)c(a)s(e)", -> + openEx() + submitNormalModeInputText(':set scs') + expect(atom.config.get('vim-mode.useSmartcaseForSearch')).toBe(true) + openEx() + submitNormalModeInputText(':set noscs') + expect(atom.config.get('vim-mode.useSmartcaseForSearch')).toBe(false) + openEx() + submitNormalModeInputText(':set smartcase') + expect(atom.config.get('vim-mode.useSmartcaseForSearch')).toBe(true) + openEx() + submitNormalModeInputText(':set nosmartcase') + expect(atom.config.get('vim-mode.useSmartcaseForSearch')).toBe(false) + + it "sets (no)gdefault", -> + openEx() + submitNormalModeInputText(':set gdefault') + expect(atom.config.get('ex-mode.gdefault')).toBe(true) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText(':set nogdefault') + expect(atom.config.get('ex-mode.gdefault')).toBe(false) + + describe "aliases", -> + it "calls the aliased function without arguments", -> + ExClass.registerAlias('W', 'w') + spyOn(Ex, 'write') + openEx() + submitNormalModeInputText('W') + expect(Ex.write).toHaveBeenCalled() + + it "calls the aliased function with arguments", -> + ExClass.registerAlias('W', 'write') + spyOn(Ex, 'W').andCallThrough() + spyOn(Ex, 'write') + openEx() + submitNormalModeInputText('W') + WArgs = Ex.W.calls[0].args[0] + writeArgs = Ex.write.calls[0].args[0] + expect(WArgs).toBe writeArgs + + describe "with selections", -> + it "executes on the selected range", -> + spyOn(Ex, 's') + editor.setCursorBufferPosition([0, 0]) + editor.selectToBufferPosition([2, 1]) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText("'<,'>s/abc/def") + expect(Ex.s.calls[0].args[0].range).toEqual [0, 2] + + it "calls the functions multiple times if there are multiple selections", -> + spyOn(Ex, 's') + editor.setCursorBufferPosition([0, 0]) + editor.selectToBufferPosition([2, 1]) + editor.addCursorAtBufferPosition([3, 0]) + editor.selectToBufferPosition([3, 2]) + atom.commands.dispatch(editorElement, 'ex-mode:open') + submitNormalModeInputText("'<,'>s/abc/def") + calls = Ex.s.calls + expect(calls.length).toEqual 2 + expect(calls[0].args[0].range).toEqual [0, 2] + expect(calls[1].args[0].range).toEqual [3, 3] + + describe ':sort', -> + beforeEach -> + editor.setText('ghi\nabc\njkl\ndef\n142\nzzz\n91xfds9\n') + editor.setCursorBufferPosition([0, 0]) + + it "sorts entire file if range is not multi-line", -> + openEx() + submitNormalModeInputText('sort') + expect(editor.getText()).toEqual('142\n91xfds9\nabc\ndef\nghi\njkl\nzzz\n') + + it "sorts specific range if range is multi-line", -> + openEx() + submitNormalModeInputText('2,4sort') + expect(editor.getText()).toEqual('ghi\nabc\ndef\njkl\n142\nzzz\n91xfds9\n') diff --git a/spec/ex-input-spec.coffee b/spec/ex-input-spec.coffee new file mode 100644 index 0000000..cd7fbd0 --- /dev/null +++ b/spec/ex-input-spec.coffee @@ -0,0 +1,92 @@ +helpers = require './spec-helper' +describe "the input element", -> + [editor, editorElement, vimState, exState] = [] + beforeEach -> + vimMode = atom.packages.loadPackage('vim-mode-plus') + exMode = atom.packages.loadPackage('ex-mode') + waitsForPromise -> + activationPromise = exMode.activate() + helpers.activateExMode() + activationPromise + + runs -> + spyOn(exMode.mainModule.globalExState, 'setVim').andCallThrough() + + waitsForPromise -> + vimMode.activate() + + waitsFor -> + exMode.mainModule.globalExState.setVim.calls.length > 0 + + runs -> + helpers.getEditorElement (element) -> + atom.commands.dispatch(element, "ex-mode:open") + editorElement = element + editor = editorElement.getModel() + atom.commands.dispatch(getCommandEditor(), "core:cancel") + vimState = vimMode.mainModule.getEditorState(editor) + exState = exMode.mainModule.exStates.get(editor) + vimState.resetNormalMode() + editor.setText("abc\ndef\nabc\ndef") + + afterEach -> + atom.commands.dispatch(getCommandEditor(), "core:cancel") + + getVisibility = () -> + editor.normalModeInputView.panel.visible + + getCommandEditor = () -> + editor.normalModeInputView.editorElement + + it "opens with 'ex-mode:open'", -> + atom.commands.dispatch(editorElement, "ex-mode:open") + expect(getVisibility()).toBe true + + it "closes with 'core:cancel'", -> + atom.commands.dispatch(editorElement, "ex-mode:open") + expect(getVisibility()).toBe true + atom.commands.dispatch(getCommandEditor(), "core:cancel") + expect(getVisibility()).toBe false + + it "closes when opening and then pressing backspace", -> + atom.commands.dispatch(editorElement, "ex-mode:open") + expect(getVisibility()).toBe true + atom.commands.dispatch(getCommandEditor(), "core:backspace") + expect(getVisibility()).toBe false + + it "doesn't close when there is text and pressing backspace", -> + atom.commands.dispatch(editorElement, "ex-mode:open") + expect(getVisibility()).toBe true + commandEditor = getCommandEditor() + model = commandEditor.getModel() + model.setText('abc') + atom.commands.dispatch(commandEditor, "core:backspace") + expect(getVisibility()).toBe true + expect(model.getText()).toBe 'ab' + + it "closes when there is text and pressing backspace multiple times", -> + atom.commands.dispatch(editorElement, "ex-mode:open") + expect(getVisibility()).toBe true + commandEditor = getCommandEditor() + model = commandEditor.getModel() + expect(model.getText()).toBe '' + model.setText('abc') + atom.commands.dispatch(commandEditor, "core:backspace") + expect(getVisibility()).toBe true + expect(model.getText()).toBe 'ab' + atom.commands.dispatch(commandEditor, "core:backspace") + expect(getVisibility()).toBe true + expect(model.getText()).toBe 'a' + atom.commands.dispatch(commandEditor, "core:backspace") + expect(getVisibility()).toBe true + expect(model.getText()).toBe '' + atom.commands.dispatch(commandEditor, "core:backspace") + expect(getVisibility()).toBe false + + it "contains '<,'> when opened while there are selections", -> + editor.setCursorBufferPosition([0, 0]) + editor.selectToBufferPosition([0, 1]) + editor.addCursorAtBufferPosition([2, 0]) + editor.selectToBufferPosition([2, 1]) + atom.commands.dispatch(editorElement, "ex-mode:open") + expect(getCommandEditor().getModel().getText()).toBe "'<,'>" diff --git a/spec/ex-mode-spec.coffee b/spec/ex-mode-spec.coffee deleted file mode 100644 index bbc7fd7..0000000 --- a/spec/ex-mode-spec.coffee +++ /dev/null @@ -1,62 +0,0 @@ -ExMode = require '../lib/ex-mode' - -# Use the command `window:run-package-specs` (cmd-alt-ctrl-p) to run specs. -# -# To run a specific `it` or `describe` block add an `f` to the front (e.g. `fit` -# or `fdescribe`). Remove the `f` to unfocus the block. - -describe "ExMode", -> - [workspaceElement, activationPromise] = [] - - beforeEach -> - workspaceElement = atom.views.getView(atom.workspace) - activationPromise = atom.packages.activatePackage('ex-mode') - - describe "when the ex-mode:toggle event is triggered", -> - it "hides and shows the modal panel", -> - # Before the activation event the view is not on the DOM, and no panel - # has been created - expect(workspaceElement.querySelector('.ex-mode')).not.toExist() - - # This is an activation event, triggering it will cause the package to be - # activated. - atom.commands.dispatch workspaceElement, 'ex-mode:toggle' - - waitsForPromise -> - activationPromise - - runs -> - expect(workspaceElement.querySelector('.ex-mode')).toExist() - - exModeElement = workspaceElement.querySelector('.ex-mode') - expect(exModeElement).toExist() - - exModePanel = atom.workspace.panelForItem(exModeElement) - expect(exModePanel.isVisible()).toBe true - atom.commands.dispatch workspaceElement, 'ex-mode:toggle' - expect(exModePanel.isVisible()).toBe false - - it "hides and shows the view", -> - # This test shows you an integration test testing at the view level. - - # Attaching the workspaceElement to the DOM is required to allow the - # `toBeVisible()` matchers to work. Anything testing visibility or focus - # requires that the workspaceElement is on the DOM. Tests that attach the - # workspaceElement to the DOM are generally slower than those off DOM. - jasmine.attachToDOM(workspaceElement) - - expect(workspaceElement.querySelector('.ex-mode')).not.toExist() - - # This is an activation event, triggering it causes the package to be - # activated. - atom.commands.dispatch workspaceElement, 'ex-mode:toggle' - - waitsForPromise -> - activationPromise - - runs -> - # Now we can test for view visibility - exModeElement = workspaceElement.querySelector('.ex-mode') - expect(exModeElement).toBeVisible() - atom.commands.dispatch workspaceElement, 'ex-mode:toggle' - expect(exModeElement).not.toBeVisible() diff --git a/spec/ex-mode-view-spec.coffee b/spec/ex-mode-view-spec.coffee deleted file mode 100644 index d94ddf1..0000000 --- a/spec/ex-mode-view-spec.coffee +++ /dev/null @@ -1,5 +0,0 @@ -ExModeView = require '../lib/ex-mode-view' - -describe "ExModeView", -> - it "has one valid test", -> - expect("life").toBe "easy" diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee new file mode 100644 index 0000000..9ec4d58 --- /dev/null +++ b/spec/spec-helper.coffee @@ -0,0 +1,65 @@ +ExState = require '../lib/ex-state' +GlobalExState = require '../lib/global-ex-state' + +beforeEach -> + atom.workspace ||= {} + +activateExMode = -> + atom.workspace.open().then -> + atom.commands.dispatch(atom.views.getView(atom.workspace), 'ex-mode:open') + keydown('escape') + atom.workspace.getActivePane().destroyActiveItem() + + +getEditorElement = (callback) -> + textEditor = null + + waitsForPromise -> + atom.workspace.open().then (e) -> + textEditor = e + + runs -> + # element = document.createElement("atom-text-editor") + # element.setModel(textEditor) + # element.classList.add('vim-mode') + # element.exState = new ExState(element, new GlobalExState) + # + # element.addEventListener "keydown", (e) -> + # atom.keymaps.handleKeyboardEvent(e) + + element = atom.views.getView(textEditor) + + callback(element) + +dispatchKeyboardEvent = (target, eventArgs...) -> + e = document.createEvent('KeyboardEvent') + e.initKeyboardEvent(eventArgs...) + # 0 is the default, and it's valid ASCII, but it's wrong. + Object.defineProperty(e, 'keyCode', get: -> undefined) if e.keyCode is 0 + target.dispatchEvent e + +dispatchTextEvent = (target, eventArgs...) -> + e = document.createEvent('TextEvent') + e.initTextEvent(eventArgs...) + target.dispatchEvent e + +keydown = (key, {element, ctrl, shift, alt, meta, raw}={}) -> + key = "U+#{key.charCodeAt(0).toString(16)}" unless key is 'escape' or raw? + element ||= document.activeElement + eventArgs = [ + true, # bubbles + true, # cancelable + null, # view + key, # key + 0, # location + ctrl, alt, shift, meta + ] + + canceled = not dispatchKeyboardEvent(element, 'keydown', eventArgs...) + dispatchKeyboardEvent(element, 'keypress', eventArgs...) + if not canceled + if dispatchTextEvent(element, 'textInput', eventArgs...) + element.value += key + dispatchKeyboardEvent(element, 'keyup', eventArgs...) + +module.exports = {keydown, getEditorElement, activateExMode} diff --git a/styles/ex-mode.less b/styles/ex-mode.less index da5d1df..2a714b0 100644 --- a/styles/ex-mode.less +++ b/styles/ex-mode.less @@ -6,3 +6,25 @@ .ex-mode { } + +div[is=ex-command-mode-input] atom-text-editor[mini]::before { + content: ":"; + opacity: 0.5; +} + +div[is=vim-normal-mode-input] atom-text-editor[mini]::before { + content: "/"; + opacity: 0.5; +} + +.command-mode-input atom-text-editor[mini] { + background-color: inherit; + border: none; + width: 100%; + font-weight: normal; + color: @text-color; + line-height: 1.28; + cursor: default; + white-space: nowrap; + padding-left: 10px; +}