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..0b5849b 100644 --- a/lib/command.coffee +++ b/lib/command.coffee @@ -1,22 +1,164 @@ 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(@) - execute: (input) -> - return unless input.characters.length > 0 - [command, args...] = input.characters.split(" ") + parseAddr: (str, curPos) -> + if str is '.' + addr = curPos.row + else if str is '$' + # Lines are 0-indexed in Atom, but 1-indexed in vim. + addr = @editor.getBuffer().lines.length - 1 + else if str[0] in ["+", "-"] + addr = curPos.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.marks[str[1]] + unless mark? + throw new CommandError("Mark #{str} not set.") + addr = mark.bufferMarker.range.end.row + else if str[0] is "/" + addr = Find.findNextInBuffer(@editor.buffer, curPos, str[1...-1]) + unless addr? + throw new CommandError("Pattern not found: #{str[1...-1]}") + else if str[0] is "?" + addr = Find.findPreviousInBuffer(@editor.buffer, curPos, str[1...-1]) + unless addr? + throw new CommandError("Pattern not found: #{str[1...-1]}") - func = Ex.singleton()[command] - if func? - func(args...) + return addr + + parseOffset: (str) -> + if str.length is 0 + return 0 + if str.length is 1 + o = 1 else - throw new CommandError("#{input.characters}") + o = parseInt(str[1..]) + if str[0] is '+' + return o + else + return -o -module.exports = {Command, CommandError} + execute: (input) -> + @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 + # 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 + lastLine = @editor.getBuffer().lines.length - 1 + if cl[0] is '%' + range = [0, lastLine] + cl = cl[1..] + else + 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*)*) + )? + /// + + [match, addr1, off1, addr2, off2] = cl.match(addrPattern) + + curPos = @editor.getCursorBufferPosition() + + if addr1? + address1 = @parseAddr(addr1, curPos) + else + # If no addr1 is given (,+3), assume it is '.' + address1 = curPos.row + if off1? + address1 += @parseOffset(off1) + + if address1 < 0 or address1 > lastLine + throw new CommandError('Invalid range') + + if addr2? + address2 = @parseAddr(addr2, curPos) + if off2? + address2 += @parseOffset(off2) + + if address2 < 0 or address2 > lastLine + 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 + if (func = Ex.singleton()[command])? + func(range, args) + else + # 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? + func(range, args) + else + throw new CommandError("Not an editor command: #{input.characters}") + +module.exports = Command diff --git a/lib/ex-mode.coffee b/lib/ex-mode.coffee index 243477a..1a4979d 100644 --- a/lib/ex-mode.coffee +++ b/lib/ex-mode.coffee @@ -33,3 +33,4 @@ module.exports = ExMode = consumeVim: (vim) -> @vim = vim + @globalExState.setVim(vim) diff --git a/lib/ex-state.coffee b/lib/ex-state.coffee index 7aff344..e52ea1c 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) -> diff --git a/lib/ex.coffee b/lib/ex.coffee index 024f04c..874c9f7 100644 --- a/lib/ex.coffee +++ b/lib/ex.coffee @@ -1,4 +1,5 @@ path = require 'path' +CommandError = require './command-error' trySave = (func) -> deferred = Promise.defer() @@ -9,20 +10,47 @@ trySave = (func) -> 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 +getFullPath = (filePath) -> + return filePath if path.isAbsolute(filePath) + return path.join(atom.project.getPath(), filePath) + +replaceGroups = (groups, replString) -> + arr = replString.split('') + offset = 0 + cdiff = 0 + + while (m = replString.match(/(?:[^\\]|^)\\(\d)/))? + group = groups[m[1]] or '' + i = replString.indexOf(m[0]) + l = m[0].length + replString = replString.slice(i + l) + arr[i + offset...i + offset + l] = (if l is 2 then '' else m[0][0]) + + group + arr = arr.join('').split '' + offset += i + l - group.length + + return arr.join('').replace(/\\\\(\d)/, '\\$1') + class Ex @singleton: => @ex ||= new Ex @@ -35,7 +63,9 @@ class Ex q: => @quit() - tabedit: (filePaths...) -> + tabedit: (range, args) -> + args = args.trim() + filePaths = args.split(' ') pane = atom.workspace.getActivePane() if filePaths? and filePaths.length > 0 for file in filePaths @@ -43,9 +73,9 @@ class Ex else atom.workspace.openURIInPane('', pane) - tabe: (filePaths...) => @tabedit(filePaths...) + tabe: (args...) => @tabedit(args...) - tabnew: (filePaths...) => @tabedit(filePaths...) + tabnew: (args...) => @tabedit(args...) tabclose: => @quit() @@ -63,25 +93,32 @@ class Ex tabp: => @tabprevious() - edit: (filePath) => @tabedit(filePath) if filePath? + edit: (range, filePath) -> + filePath = filePath.trim() + if filePath.indexOf(' ') isnt -1 + throw new CommandError('Only one file name allowed') + buffer = atom.workspace.getActiveEditor().buffer + filePath = buffer.getPath() if filePath is '' + buffer.setPath(getFullPath(filePath)) + buffer.load() - e: (filePath) => @edit(filePath) + e: (args...) => @edit(args...) - enew: => @edit() + enew: -> + buffer = atom.workspace.getActiveEditor().buffer + buffer.setPath(undefined) + buffer.load() - write: (filePath) -> + write: (range, filePath) -> + filePath = filePath.trim() deferred = Promise.defer() - projectPath = atom.project.getPath() pane = atom.workspace.getActivePane() editor = atom.workspace.getActiveEditor() if atom.workspace.getActiveTextEditor().getPath() isnt undefined - if filePath? + if filePath.length > 0 editorPath = editor.getPath() - fullPath = if path.isAbsolute(filePath) - filePath - else - path.join(projectPath, filePath) + fullPath = getFullPath(filePath) trySave(-> editor.saveAs(fullPath)) .then -> deferred.resolve() @@ -90,11 +127,8 @@ class Ex trySave(-> editor.save()) .then deferred.resolve else - if filePath? - fullPath = if path.isAbsolute(filePath) - filePath - else - path.join(projectPath, filePath) + if filePath.length > 0 + fullPath = getFullPath(filePath) trySave(-> editor.saveAs(fullPath)) .then deferred.resolve else @@ -105,18 +139,22 @@ class Ex deferred.promise - w: (filePath) => - @write(filePath) + w: (args...) => + @write(args...) + + wq: (args...) => + @write(args...).then => @quit() - wq: (filePath) => - @write(filePath).then => @quit() - x: => @wq() wa: -> atom.workspace.saveAll() - split: (filePaths...) -> + split: (range, args) -> + args = args.trim() + filePaths = args.split(' ') + filePaths = undefined if filePaths.length is 1 and filePaths[0] is '' + console.log filePaths, filePaths is [''] pane = atom.workspace.getActivePane() if filePaths? and filePaths.length > 0 newPane = pane.splitUp() @@ -126,9 +164,56 @@ class Ex else pane.splitUp(copyActiveItem: true) - sp: (filePaths...) => @split(filePaths...) + sp: (args...) => @split(args...) - vsplit: (filePaths...) -> + substitute: (range, args) -> + args = args.trimLeft() + delim = args[0] + if /[a-z]/i.test(delim) + throw new CommandError( + "Regular expressions can't be delimited by letters") + delimRE = new RegExp("[^\\\\]#{delim}") + spl = [] + args_ = args[1..] + while (i = args_.search(delimRE)) isnt -1 + spl.push args_[..i] + args_ = args_[i + 2..] + if args_.length is 0 and spl.length is 3 + throw new CommandError('Trailing characters') + else if args_.length isnt 0 + spl.push args_ + if spl.length > 3 + throw new CommandError('Trailing characters') + spl[1] ?= '' + spl[2] ?= '' + + try + pattern = new RegExp(spl[0], spl[2]) + catch e + if e.message.indexOf('Invalid flags supplied to RegExp constructor') is 0 + # vim only says 'Trailing characters', but let's be more descriptive + 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 + + buffer = atom.workspace.getActiveTextEditor().buffer + cp = buffer.history.createCheckpoint() + for line in [range[0]..range[1]] + buffer.scanInRange(pattern, + [[line, 0], [line, buffer.lines[line].length]], + ({match, matchText, range, stop, replace}) -> + replace(replaceGroups(match[..], spl[1])) + ) + buffer.history.groupChangesSinceCheckpoint(cp) + + 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() @@ -138,6 +223,6 @@ class Ex else pane.splitLeft(copyActiveItem: true) - vsp: (filePaths...) => @vsplit(filePaths...) + vsp: (args...) => @vsplit(args...) module.exports = Ex diff --git a/lib/find.coffee b/lib/find.coffee new file mode 100644 index 0000000..2f0c597 --- /dev/null +++ b/lib/find.coffee @@ -0,0 +1,27 @@ +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) + console.log found, curPos + 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 +} 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