This commit is contained in:
Jasper v. B 2015-08-03 13:43:44 +00:00
commit 7f92b90e61
10 changed files with 862 additions and 400 deletions

View file

@ -14,8 +14,11 @@ Use the service to register commands, from your own package, or straight from `i
# in Atom's init.coffee
atom.packages.onDidActivatePackage (pack) ->
if pack.name == 'ex-mode'
Ex = pack.mainModule.provideEx()
Ex.registerCommand 'z', -> console.log("Zzzzzz...")
Ex = pack.mainModule.provideEx_0_30()
Ex.registerCommand
name: 'z'
priority: 1
callback: -> console.log('zzzzzz')
```
See `lib/ex.coffee` for some examples commands. Contributions are very welcome!

View file

@ -1,14 +1,44 @@
ExViewModel = require './ex-view-model'
Ex = require './ex'
ExCommands = require './ex-commands'
Find = require './find'
CommandError = require './command-error'
{getSearchTerm} = require './utils'
cmp = (x, y) -> if x > y then 1 else if x < y then -1 else 0
class Command
constructor: (@editor, @exState) ->
@viewModel = new ExViewModel(@)
@vimState = @exState.globalExState.vim?.getEditorState(@editor)
scanEditor: (term, position, reverse = false) ->
return if term is ""
[rangesBefore, rangesAfter] = [[], []]
@editor.scan getSearchTerm(term), ({range}) ->
isBefore = if reverse
range.start.compare(position) < 0
else
range.start.compare(position) <= 0
if isBefore
rangesBefore.push(range)
else
rangesAfter.push(range)
if reverse
rangesAfter.concat(rangesBefore).reverse()[0]
else
rangesAfter.concat(rangesBefore)[0]
checkForRepeatSearch: (term, reversed = false) ->
if term is '' or reversed and term is '?' or not reversed and term is '/'
@vimState.getSearchHistoryItem(0)
else
term
parseAddr: (str, curPos) ->
if str is '.'
if str in ['.', '']
addr = curPos.row
else if str is '$'
# Lines are 0-indexed in Atom, but 1-indexed in vim.
@ -20,18 +50,22 @@ class Command
else if str[0] is "'" # Parse Mark...
unless @vimState?
throw new CommandError("Couldn't get access to vim-mode.")
mark = @vimState.marks[str[1]]
mark = @vimState.getMark(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])
addr = mark.row
else if (first = str[0]) in ['/', '?']
reversed = first is '?'
str = @checkForRepeatSearch(str[1..], reversed)
throw new CommandError('No previous regular expression') if not str?
str = str[...-1] if str[str.length - 1] is first
@regex = str
lineRange = @editor.getLastCursor().getCurrentLineBufferRange()
pos = if reversed then lineRange.start else lineRange.end
addr = @scanEditor(str, pos, reversed)
unless addr?
throw new CommandError("Pattern not found: #{str[1...-1]}")
addr = addr.start.row
return addr
@ -47,26 +81,25 @@ class Command
else
return -o
execute: (input) ->
@vimState = @exState.globalExState.vim?.getEditorState(@editor)
parseLine: (commandLine) ->
# Command line parsing (mostly) following the rules at
# http://pubs.opengroup.org/onlinepubs/9699919799/utilities
# /ex.html#tag_20_40_13_03
_commandLine = commandLine
# Steps 1/2: Leading blanks and colons are ignored.
cl = input.characters
cl = cl.replace(/^(:|\s)*/, '')
return unless cl.length > 0
commandLine = commandLine.replace(/^(:|\s)*/, '')
return unless commandLine.length > 0
# Step 3: If the first character is a ", ignore the rest of the line
if cl[0] is '"'
if commandLine[0] is '"'
return
# Step 4: Address parsing
lastLine = @editor.getBuffer().lines.length - 1
if cl[0] is '%'
if commandLine[0] is '%'
range = [0, lastLine]
cl = cl[1..]
commandLine = commandLine[1..]
else
addrPattern = ///^
(?: # First address
@ -75,8 +108,8 @@ class Command
\$| # Last line
\d+| # n-th line
'[\[\]<>'`"^.(){}a-zA-Z]| # Marks
/.*?[^\\]/| # Regex
\?.*?[^\\]\?| # Backwards search
/(?:.*?[^\\]|)(?:/|$)| # Regex
\?(?:.*?[^\\]|)(?:\?|$)| # Backwards search
[+-]\d* # Current line +/- a number of lines
)((?:\s*[+-]\d*)*) # Line offset
)?
@ -86,14 +119,15 @@ class Command
\$|
\d+|
'[\[\]<>'`"^.(){}a-zA-Z]|
/.*?[^\\]/|
\?.*?[^\\]\?|
[+-]\d*
)((?:\s*[+-]\d*)*)
/(?:.*?[^\\]|)(?:/|$)|
\?(?:.*?[^\\]|)(?:\?|$)|
[+-]\d*|
# Empty second address
)((?:\s*[+-]\d*)*)|
)?
///
[match, addr1, off1, addr2, off2] = cl.match(addrPattern)
[match, addr1, off1, addr2, off2] = commandLine.match(addrPattern)
curPos = @editor.getCursorBufferPosition()
@ -115,6 +149,9 @@ class Command
if off2?
address2 += @parseOffset(off2)
if @regex?
@vimState.pushSearchHistory(@regex)
if address2 < 0 or address2 > lastLine
throw new CommandError('Invalid range')
@ -122,15 +159,15 @@ class Command
throw new CommandError('Backwards range given')
range = [address1, if address2? then address2 else address1]
cl = cl[match?.length..]
commandLine = commandLine[match?.length..]
# Step 5: Leading blanks are ignored
cl = cl.trimLeft()
commandLine = commandLine.trimLeft()
# Step 6a: If no command is specified, go to the last specified address
if cl.length is 0
if commandLine.length is 0
@editor.setCursorBufferPosition([range[1], 0])
return
return {range, command: undefined, args: undefined}
# Ignore steps 6b and 6c since they only make sense for print commands and
# print doesn't make sense
@ -139,31 +176,39 @@ class Command
# Step 7b: :k<valid mark> is equal to :mark <valid 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])
if commandLine.length is 2 and commandLine[0] is 'k' \
and /[a-z]/i.test(commandLine[1])
command = 'mark'
args = cl[1]
else if not /[a-z]/i.test(cl[0])
command = cl[0]
args = cl[1..]
args = commandLine[1]
else if not /[a-z]/i.test(commandLine[0])
command = commandLine[0]
args = commandLine[1..]
else
[m, command, args] = cl.match(/^(\w+)(.*)/)
[m, command, args] = commandLine.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)
commandLineRE = new RegExp("^" + command)
matching = []
matching.sort()
for name in Object.keys(ExCommands.commands)
if commandLineRE.test(name)
command = ExCommands.commands[name]
if matching.length is 0
matching = [command]
else
switch cmp(command.priority, matching[0].priority)
when 1 then matching = [command]
when 0 then matching.push(command)
command = matching[0]
command = matching.sort()[0]
unless command?
throw new CommandError("Not an editor command: #{_commandLine}")
func = Ex.singleton()[command]
if func?
func(range, args)
else
throw new CommandError("Not an editor command: #{input.characters}")
return {command: command.callback, range, args: args.trimLeft()}
execute: (input) ->
{command, range, args} = @parseLine(input.characters)
command?({args, range, @editor, @exState, @vimState})
module.exports = Command

321
lib/ex-commands.coffee Normal file
View file

@ -0,0 +1,321 @@
path = require 'path'
CommandError = require './command-error'
fs = require 'fs-plus'
VimOption = require './vim-option'
{getSearchTerm} = require './utils'
trySave = (func) ->
deferred = Promise.defer()
try
func()
deferred.resolve()
catch error
if error.message.endsWith('is a directory')
atom.notifications.addWarning("Unable to save file: #{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")
else
throw error
deferred.promise
saveAs = (filePath) ->
editor = atom.workspace.getActiveTextEditor()
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
module.exports =
class ExCommands
@commands =
'quit':
priority: 1000
callback: ->
atom.workspace.getActivePane().destroyActiveItem()
'tabclose':
priority: 1000
callback: (ev) =>
@callCommand('quit', ev)
'qall':
priority: 1000
callback: ->
atom.close()
'tabnext':
priority: 1000
callback: ->
atom.workspace.getActivePane().activateNextItem()
'tabprevious':
priority: 1000
callback: ->
atom.workspace.getActivePane().activatePreviousItem()
'write':
priority: 1001
callback: ({editor, args}) ->
if args[0] is '!'
force = true
args = args[1..]
filePath = args.trimLeft()
if /[^\\] /.test(filePath)
throw new CommandError('Only one file name allowed')
filePath = filePath.replace(/\\ /g, ' ')
deferred = Promise.defer()
if filePath.length isnt 0
fullPath = getFullPath(filePath)
else if editor.getPath()?
trySave(-> editor.save())
.then(deferred.resolve)
else
fullPath = atom.showSaveDialogSync()
if fullPath?
if not force and fs.existsSync(fullPath)
throw new CommandError("File exists (add ! to override)")
trySave(-> saveAs(fullPath, editor))
.then(deferred.resolve)
deferred.promise
'update':
priority: 1000
callback: (ev) =>
@callCommand('write', ev)
'wall':
priority: 1000
callback: ->
# FIXME: This is undocumented for quite a while now - not even
# deprecated. Should probably use PaneContainer::saveAll
atom.workspace.saveAll()
'wq':
priority: 1000
callback: (ev) =>
@callCommand('write', ev).then => @callCommand('quit')
'xit':
priority: 1000
callback: (ev) =>
@callCommand('wq', ev)
'exit':
priority: 1000
callback: (ev) => @callCommand('xit', ev)
'xall':
priority: 1000
callback: (ev) =>
atom.workspace.saveAll()
@callCommand('qall', ev)
'edit':
priority: 1001
callback: ({args, editor}) ->
args = args.trim()
if args[0] is '!'
force = true
args = args[1..]
if editor.isModified() and not force
throw new CommandError(
'No write since last change (add ! to override)')
filePath = args.trimLeft()
if /[^\\] /.test(filePath)
throw new CommandError('Only one file name allowed')
filePath = filePath.replace(/\\ /g, ' ')
if filePath.length isnt 0
fullPath = getFullPath(filePath)
if fullPath is editor.getPath()
editor.getBuffer().reload()
else
atom.workspace.open(fullPath)
else
if editor.getPath()?
editor.getBuffer().reload()
else
throw new CommandError('No file name')
'tabedit':
priority: 1000
callback: (ev) =>
if ev.args.trim() is ''
@callCommand('tabnew', ev)
else
@callCommand('edit', ev)
'tabnew':
priority: 1000
callback: ->
atom.workspace.open()
'split':
priority: 1000
callback: ({args}) ->
filePath = args.trim()
if /[^\\] /.test(filePath)
throw new CommandError('Only one file name allowed')
filePath = filePath.replace(/\\ /g, ' ')
pane = atom.workspace.getActivePane()
if filePath.length isnt 0
# FIXME: This is horribly slow
atom.workspace.openURIInPane(getFullPath(filePath), pane.splitUp())
else
pane.splitUp(copyActiveItem: true)
'new':
priority: 1000
callback: ({args}) ->
filePath = args.trim()
if /[^\\] /.test(filePath)
throw new CommandError('Only one file name allowed')
filePath = filePath.replace(/\\ /g, ' ')
filePath = undefined if filePath.length is 0
# FIXME: This is horribly slow
atom.workspace.openURIInPane(filePath,
atom.workspace.getActivePane().splitUp())
'vsplit':
priority: 1000
callback: ({args}) ->
filePath = args.trim()
if /[^\\] /.test(filePath)
throw new CommandError('Only one file name allowed')
filePath = filePath.replace(/\\ /g, ' ')
pane = atom.workspace.getActivePane()
if filePath.length isnt 0
# FIXME: This is horribly slow
atom.workspace.openURIInPane(getFullPath(filePath),
pane.splitLeft())
else
pane.splitLeft(copyActiveItem: true)
'vnew':
priority: 1000
callback: ({args}) ->
filePath = args.trim()
if /[^\\] /.test(filePath)
throw new CommandError('Only one file name allowed')
filePath = filePath.replace(/\\ /g, ' ')
filePath = undefined if filePath.length is 0
# FIXME: This is horribly slow
atom.workspace.openURIInPane(filePath,
atom.workspace.getActivePane().splitLeft())
'delete':
priority: 1000
callback: ({range, editor}) ->
range = [[range[0], 0], [range[1] + 1, 0]]
editor.setTextInBufferRange(range, '')
'substitute':
priority: 1001
callback: ({range, args, editor, vimState}) ->
args_ = args.trimLeft()
delim = args_[0]
if /[a-z]/i.test(delim)
throw new CommandError(
"Regular expressions can't be delimited by letters")
if delim is '\\'
throw new CommandError(
"Regular expressions can't be delimited by \\")
args_ = args_[1..]
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
escaped = false
parsed[parsing] += char
[pattern, substition, flags] = parsed
if pattern is ''
pattern = vimState.getSearchHistoryItem(0)
if not pattern?
atom.beep()
throw new CommandError('No previous regular expression')
else
vimState.pushSearchHistory(pattern)
try
flagsObj = {}
flags.split('').forEach((flag) -> flagsObj[flag] = true)
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))
)
'set':
priority: 1000
callback: ({args}) ->
if args is ''
throw new CommandError('No option specified')
options = args.split(' ')
for option in options
if option[...2] is 'no'
VimOption.set(option[2..], false)
else if option[...3] is 'inv'
VimOption.inv(option[3..])
else if option[-1..] is '!'
VimOption.inv(option[...-1])
else
VimOption.set(option, true)
@registerCommand: ({name, priority, callback}) =>
@commands[name] = {priority, callback}
@callCommand: (name, ev) =>
@commands[name].callback(ev)

View file

@ -1,6 +1,6 @@
GlobalExState = require './global-ex-state'
ExState = require './ex-state'
Ex = require './ex'
ExCommands = require './ex-commands'
{Disposable, CompositeDisposable} = require 'event-kit'
module.exports = ExMode =
@ -22,14 +22,18 @@ module.exports = ExMode =
@exStates.set(editor, exState)
@disposables.add new Disposable =>
@disposables.add new Disposable ->
exState.destroy()
deactivate: ->
@disposables.dispose()
provideEx: ->
registerCommand: Ex.registerCommand.bind(Ex)
provideEx_0_20: ->
registerCommand: (name, callback) ->
ExCommands.registerCommand({name, callback, priority: 1})
provideEx_0_30: ->
registerCommand: ExCommands.registerCommand
consumeVim: (vim) ->
@vim = vim

View file

@ -1,287 +0,0 @@
path = require 'path'
CommandError = require './command-error'
fs = require 'fs-plus'
VimOption = require './vim-option'
trySave = (func) ->
deferred = Promise.defer()
try
func()
deferred.resolve()
catch error
if error.message.endsWith('is a directory')
atom.notifications.addWarning("Unable to save file: #{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")
else
throw error
deferred.promise
saveAs = (filePath) ->
editor = atom.workspace.getActiveTextEditor()
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
class Ex
@singleton: =>
@ex ||= new Ex
@registerCommand: (name, func) =>
@singleton()[name] = func
quit: ->
atom.workspace.getActivePane().destroyActiveItem()
q: => @quit()
tabedit: (range, args) =>
if args.trim() isnt ''
@edit(range, args)
else
@tabnew(range, args)
tabe: (args...) => @tabedit(args...)
tabnew: (range, args) =>
if args.trim() is ''
atom.workspace.open()
else
@tabedit(range, args)
tabclose: (args...) => @quit(args...)
tabc: => @tabclose()
tabnext: ->
pane = atom.workspace.getActivePane()
pane.activateNextItem()
tabn: => @tabnext()
tabprevious: ->
pane = atom.workspace.getActivePane()
pane.activatePreviousItem()
tabp: => @tabprevious()
edit: (range, filePath) ->
filePath = filePath.trim()
if filePath[0] is '!'
force = true
filePath = filePath[1..].trim()
else
force = false
editor = atom.workspace.getActiveTextEditor()
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
atom.workspace.open(fullPath)
else
if editor.getPath()?
editor.getBuffer().reload()
else
throw new CommandError('No file name')
e: (args...) => @edit(args...)
enew: ->
buffer = atom.workspace.getActiveTextEditor().buffer
buffer.setPath(undefined)
buffer.load()
write: (range, filePath) ->
if filePath[0] is '!'
force = true
filePath = filePath[1..]
else
force = false
filePath = filePath.trim()
if filePath.indexOf(' ') isnt -1
throw new CommandError('Only one file name allowed')
deferred = Promise.defer()
editor = atom.workspace.getActiveTextEditor()
saved = false
if filePath.length isnt 0
fullPath = getFullPath(filePath)
if editor.getPath()? and (not fullPath? or editor.getPath() == fullPath)
# Use editor.save when no path is given or the path to the file is given
trySave(-> editor.save()).then(deferred.resolve)
saved = true
else if not fullPath?
fullPath = atom.showSaveDialogSync()
if not saved and fullPath?
if not force and fs.existsSync(fullPath)
throw new CommandError("File exists (add ! to override)")
trySave(-> saveAs(fullPath)).then(deferred.resolve)
deferred.promise
w: (args...) =>
@write(args...)
wq: (args...) =>
@write(args...).then => @quit()
xit: (args...) => @wq(args...)
wa: ->
atom.workspace.saveAll()
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
else
pane.splitUp(copyActiveItem: true)
sp: (args...) => @split(args...)
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] ?= ''
notDelimRE = new RegExp("\\\\#{delim}", 'g')
spl[0] = spl[0].replace(notDelimRE, delim)
spl[1] = spl[1].replace(notDelimRE, delim)
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
atom.workspace.getActiveTextEditor().transact ->
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]))
)
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
else
pane.splitLeft(copyActiveItem: true)
vsp: (args...) => @vsplit(args...)
delete: (range) ->
range = [[range[0], 0], [range[1] + 1, 0]]
atom.workspace.getActiveTextEditor().buffer.setTextInRange(range, '')
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()
module.exports = Ex

36
lib/utils.coffee Normal file
View file

@ -0,0 +1,36 @@
_ = require 'underscore-plus'
module.exports =
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)

View file

@ -1,23 +1,19 @@
class VimOption
@singleton: =>
@option ||= new VimOption
@options =
'list': 'editor.showInvisibles'
'nu': 'editor.showLineNumbers'
'number': 'editor.showLineNumbers'
list: =>
atom.config.set("editor.showInvisibles", true)
@registerOption: (vimName, atomName) ->
@options[vimName] = atomName
nolist: =>
atom.config.set("editor.showInvisibles", false)
@set: (name, value) ->
atom.config.set(@options[name], value)
number: =>
atom.config.set("editor.showLineNumbers", true)
@get: (name) ->
atom.config.get(@options[name])
nu: =>
@number()
nonumber: =>
atom.config.set("editor.showLineNumbers", false)
nonu: =>
@nonumber()
@inv: (name) ->
atom.config.set(@options[name], not atom.config.get(@options[name]))
module.exports = VimOption

View file

@ -29,7 +29,8 @@
"ex-mode": {
"description": "Ex commands",
"versions": {
"0.20.0": "provideEx"
"0.20.0": "provideEx_0_20",
"0.30.0": "provideEx_0_30"
}
}
},

View file

@ -0,0 +1,185 @@
helpers = require './spec-helper'
Command = require '../lib/command'
ExCommands = require('../lib/ex-commands')
describe "command parsing", ->
[editor, editorElement, vimState, exState, command, lines] = []
beforeEach ->
vimMode = atom.packages.loadPackage('vim-mode')
exMode = atom.packages.loadPackage('ex-mode')
exMode.activate()
waitsForPromise ->
vimMode.activate().then ->
helpers.activateExMode().then ->
helpers.getEditorElement (element) ->
editorElement = element
editor = editorElement.getModel()
atom.commands.dispatch(element, 'ex-mode:open')
atom.commands.dispatch(editor.normalModeInputView.editorElement,
'core:cancel')
vimState = vimMode.mainModule.getEditorState(editor)
exState = exMode.mainModule.exStates.get(editor)
command = new Command(editor, exState)
vimState.activateNormalMode()
vimState.resetNormalMode()
editor.setText(
'abc\nabc\nabc\nabc\nabc\nabc\nabc\nabc\nabc\nabc\nabc\nabc'
'\nabc\nabc\n')
lines = editor.getBuffer().getLines()
editor.setCursorBufferPosition([0, 0])
it "parses a simple command (e.g. `:quit`)", ->
expect(command.parseLine('quit')).toEqual
command: ExCommands.commands.quit.callback
args: ''
range: [0, 0]
it "matches sub-commands (e.g. `:q`)", ->
expect(command.parseLine('q')).toEqual
command: ExCommands.commands.quit.callback
args: ''
range: [0, 0]
it "uses the command with the highest priority if multiple match an input", ->
expect(command.parseLine('s').command)
.toEqual(ExCommands.commands.substitute.callback)
it "ignores leading blanks and spaces", ->
expect(command.parseLine(':::: :::: : : : ')).toBeUndefined
expect(command.parseLine(':: :::::: :quit')).toEqual
command: ExCommands.commands.quit.callback
args: ''
range: [0, 0]
expect(atom.notifications.notifications.length).toBe(0)
it 'ignores the line if it starts with a "', ->
expect(command.parseLine('"quit')).toBe(undefined)
expect(atom.notifications.notifications.length).toBe(0)
describe "address parsing", ->
describe "with only one address", ->
it "parses . as an address", ->
expect(command.parseLine('.').range).toEqual([0, 0])
editor.setCursorBufferPosition([2, 0])
expect(command.parseLine('.').range).toEqual([2, 2])
it "parses $ as an address", ->
expect(command.parseLine('$').range)
.toEqual([lines.length - 1, lines.length - 1])
it "parses a number as an address", ->
expect(command.parseLine('3').range).toEqual([2, 2])
expect(command.parseLine('7').range).toEqual([6, 6])
it "parses 'a as an address", ->
vimState.setMark('a', [3, 1])
expect(command.parseLine("'a").range).toEqual([3, 3])
it "throws an error if the mark is not set", ->
vimState.marks.a = undefined
expect(-> command.parseLine("'a")).toThrow()
it "parses /a and ?a as addresses", ->
expect(command.parseLine('/abc').range).toEqual([1, 1])
editor.setCursorBufferPosition([1, 0])
expect(command.parseLine('?abc').range).toEqual([0, 0])
editor.setCursorBufferPosition([0, 0])
expect(command.parseLine('/bc').range).toEqual([1, 1])
it "integrates the search history for :/", ->
vimState.pushSearchHistory('abc')
expect(command.parseLine('//').range).toEqual([1, 1])
command.parseLine('/ab/,/bc/+2')
expect(vimState.getSearchHistoryItem(0)).toEqual('bc')
describe "case sensitivity for search patterns", ->
beforeEach ->
editor.setText('abca\nABC\ndefdDEF\nabcaABC')
describe "respects the smartcase setting", ->
describe "with smartcasse off", ->
beforeEach ->
atom.config.set('vim-mode.useSmartcaseForSearch', false)
editor.setCursorBufferPosition([0, 0])
it "uses case sensitive search if pattern is lowercase", ->
expect(command.parseLine('/abc').range).toEqual([3, 3])
it "uses case sensitive search if the pattern is uppercase", ->
expect(command.parseLine('/ABC').range).toEqual([1, 1])
describe "with smartcase on", ->
beforeEach ->
atom.config.set('vim-mode.useSmartcaseForSearch', true)
it "uses case insensitive search if the pattern is lowercase", ->
editor.setCursorBufferPosition([0, 0])
expect(command.parseLine('/abc').range).toEqual([1, 1])
it "uses case sensitive search if the pattern is uppercase", ->
editor.setCursorBufferPosition([3, 3])
expect(command.parseLine('/ABC').range).toEqual([1, 1])
describe "\\c and \\C", ->
describe "only \\c in the pattern", ->
beforeEach ->
atom.config.set('vim-mode.useSmartcaseForSearch', false)
editor.setCursorBufferPosition([0, 0])
it "uses case insensitive search if smartcase is off", ->
expect(command.parseLine('/abc\\c').range).toEqual([1, 1])
it "doesn't matter where it is", ->
expect(command.parseLine('/ab\\cc').range).toEqual([1, 1])
describe "only \\C in the pattern with smartcase on", ->
beforeEach ->
atom.config.set('vim-mode.useSmartcaseForSearch', true)
editor.setCursorBufferPosition([0, 0])
it "uses case sensitive search if the pattern is lowercase", ->
expect(command.parseLine('/abc\\C').range).toEqual([3, 3])
it "doesn't matter where it is", ->
expect(command.parseLine('/ab\\Cc').range).toEqual([3, 3])
describe "with \\c and \\C in the pattern", ->
beforeEach ->
atom.config.set('vim-mode.useSmartcaseForSearch', false)
editor.setCursorBufferPosition([0, 0])
it "uses case insensitive search if \\C comes first", ->
expect(command.parseLine('/a\\Cb\\cc').range).toEqual([1, 1])
it "uses case insensitive search if \\c comes first", ->
expect(command.parseLine('/a\\cb\\Cc').range).toEqual([1, 1])
describe "with two addresses", ->
it "parses both", ->
expect(command.parseLine('5,10').range).toEqual([4, 9])
it "throws an error if it is in reverse order", ->
expect(-> command.parseLine('10,5').range).toThrow()
it "uses the current line as second address if empty", ->
editor.setCursorBufferPosition([3, 0])
expect(command.parseLine('-2,').range).toEqual([1, 3])
it "parses a command with a range and no arguments", ->
expect(command.parseLine('2,/abc/+4delete')).toEqual
command: ExCommands.commands.delete.callback
args: ''
range: [1, 5]
it "parses a command with no range and arguments", ->
expect(command.parseLine('edit edit-test test-2')).toEqual
command: ExCommands.commands.edit.callback
args: 'edit-test test-2'
range: [0, 0]
it "parses a command with range and arguments", ->
expect(command.parseLine('3,5+2s/abc/def/gi')).toEqual
command: ExCommands.commands.substitute.callback
args: '/abc/def/gi'
range: [2, 6]

View file

@ -3,8 +3,7 @@ path = require 'path'
os = require 'os'
uuid = require 'node-uuid'
helpers = require './spec-helper'
Ex = require('../lib/ex').singleton()
ExCommands = require('../lib/ex-commands')
describe "the commands", ->
[editor, editorElement, vimState, exState, dir, dir2] = []
@ -177,11 +176,25 @@ describe "the commands", ->
describe ":tabclose", ->
it "acts as an alias to :quit", ->
spyOn(Ex, 'tabclose').andCallThrough()
spyOn(Ex, 'quit').andCallThrough()
spyOn(ExCommands.commands.tabclose, 'callback').andCallThrough()
spyOn(ExCommands.commands.quit, 'callback').andCallThrough()
keydown(':')
submitNormalModeInputText('tabclose')
expect(Ex.quit).toHaveBeenCalledWith(Ex.tabclose.calls[0].args...)
expect(ExCommands.commands.quit.callback)
.toHaveBeenCalledWith(ExCommands.commands.tabclose.callback
.calls[0].args[0])
describe ":qall", ->
beforeEach ->
waitsForPromise ->
atom.workspace.open().then -> atom.workspace.open()
.then -> atom.workspace.open()
it "closes the window", ->
spyOn(atom, 'close')
keydown(':')
submitNormalModeInputText('qall')
expect(atom.close).toHaveBeenCalled()
describe ":tabnext", ->
pane = null
@ -223,45 +236,81 @@ describe "the commands", ->
submitNormalModeInputText('tabprevious')
expect(pane.getActiveItemIndex()).toBe(pane.getItems().length - 1)
describe ":update", ->
it "acts as an alias to :write", ->
spyOn(ExCommands.commands.update, 'callback')
.andCallThrough()
spyOn(ExCommands.commands.write, 'callback')
keydown(':')
submitNormalModeInputText('update')
expect(ExCommands.commands.write.callback).toHaveBeenCalledWith(
ExCommands.commands.update.callback.calls[0].args[0])
describe ":wall", ->
it "saves all open files", ->
spyOn(atom.workspace, 'saveAll')
keydown(':')
submitNormalModeInputText('wall')
expect(atom.workspace.saveAll).toHaveBeenCalled()
describe ":wq", ->
beforeEach ->
spyOn(Ex, 'write').andCallThrough()
spyOn(Ex, 'quit')
spyOn(ExCommands.commands.write, 'callback').andCallThrough()
spyOn(ExCommands.commands.quit, 'callback')
it "writes the file, then quits", ->
spyOn(atom, 'showSaveDialogSync').andReturn(projectPath('wq-1'))
keydown(':')
submitNormalModeInputText('wq')
expect(Ex.write).toHaveBeenCalled()
expect(ExCommands.commands.write.callback).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)
waitsFor((-> ExCommands.commands.quit.callback.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)
keydown(':')
submitNormalModeInputText('wq')
expect(Ex.write).toHaveBeenCalled()
expect(ExCommands.commands.write.callback).toHaveBeenCalled()
wasNotCalled = false
# FIXME: This seems dangerous, but setTimeout somehow doesn't work.
setImmediate((->
wasNotCalled = not Ex.quit.wasCalled))
wasNotCalled = not ExCommands.commands.quit.callback.wasCalled))
waitsFor((-> wasNotCalled), 100)
it "passes the file name", ->
keydown(':')
submitNormalModeInputText('wq wq-2')
expect(Ex.write)
expect(ExCommands.commands.write.callback)
.toHaveBeenCalled()
expect(Ex.write.calls[0].args[1].trim()).toEqual('wq-2')
waitsFor((-> Ex.quit.wasCalled), "the :quit command to be called", 100)
expect(ExCommands.commands.write.callback.calls[0].args[0].args)
.toEqual('wq-2')
waitsFor((-> ExCommands.commands.quit.callback.wasCalled),
"the :quit command to be called", 100)
describe ":xit", ->
it "acts as an alias to :wq", ->
spyOn(Ex, 'wq')
spyOn(ExCommands.commands.wq, 'callback')
keydown(':')
submitNormalModeInputText('xit')
expect(Ex.wq).toHaveBeenCalled()
expect(ExCommands.commands.wq.callback).toHaveBeenCalled()
describe ":exit", ->
it "is an alias to :xit", ->
spyOn(ExCommands.commands.xit, 'callback')
keydown(':')
submitNormalModeInputText('exit')
expect(ExCommands.commands.xit.callback).toHaveBeenCalled()
describe ":xall", ->
it "saves all open files and closes the window", ->
spyOn(atom.workspace, 'saveAll')
spyOn(atom, 'close')
keydown(':')
submitNormalModeInputText('xall')
expect(atom.workspace.saveAll).toHaveBeenCalled()
expect(atom.close).toHaveBeenCalled()
describe ":edit", ->
describe "without a file name", ->
@ -339,19 +388,20 @@ describe "the commands", ->
describe ":tabedit", ->
it "acts as an alias to :edit if supplied with a path", ->
spyOn(Ex, 'tabedit').andCallThrough()
spyOn(Ex, 'edit')
spyOn(ExCommands.commands.tabedit, 'callback').andCallThrough()
spyOn(ExCommands.commands.edit, 'callback')
keydown(':')
submitNormalModeInputText('tabedit tabedit-test')
expect(Ex.edit).toHaveBeenCalledWith(Ex.tabedit.calls[0].args...)
expect(ExCommands.commands.edit.callback).toHaveBeenCalledWith(
ExCommands.commands.tabedit.callback.calls[0].args...)
it "acts as an alias to :tabnew if not supplied with a path", ->
spyOn(Ex, 'tabedit').andCallThrough()
spyOn(Ex, 'tabnew')
spyOn(ExCommands.commands.tabedit, 'callback').andCallThrough()
spyOn(ExCommands.commands.tabnew, 'callback')
keydown(':')
submitNormalModeInputText('tabedit ')
expect(Ex.tabnew)
.toHaveBeenCalledWith(Ex.tabedit.calls[0].args...)
expect(ExCommands.commands.tabnew.callback).toHaveBeenCalledWith(
ExCommands.commands.tabedit.callback.calls[0].args...)
describe ":tabnew", ->
it "opens a new tab", ->
@ -372,6 +422,15 @@ describe "the commands", ->
# FIXME: Should test whether the new pane contains a TextEditor
# pointing to the same path
describe ":new", ->
it "splits a new file upwards", ->
pane = atom.workspace.getActivePane()
spyOn(pane, 'splitUp').andCallThrough()
keydown(':')
submitNormalModeInputText('new')
expect(pane.splitUp).toHaveBeenCalled()
# FIXME: Should test whether the new pane contains an empty file
describe ":vsplit", ->
it "splits the current file to the left", ->
pane = atom.workspace.getActivePane()
@ -384,6 +443,15 @@ describe "the commands", ->
# FIXME: Should test whether the new pane contains a TextEditor
# pointing to the same path
describe ":vnew", ->
it "splits a new file to the left", ->
pane = atom.workspace.getActivePane()
spyOn(pane, 'splitLeft').andCallThrough()
keydown(':')
submitNormalModeInputText('vnew')
expect(pane.splitLeft).toHaveBeenCalled()
# FIXME: Should test whether the new pane contains an empty file
describe ":delete", ->
beforeEach ->
editor.setText('abc\ndef\nghi\njkl')
@ -449,13 +517,89 @@ describe "the commands", ->
submitNormalModeInputText(':%substitute/abc/ghi/ig')
expect(editor.getText()).toEqual('ghiaghi\ndefdDEF\nghiaghi')
it "can't be delimited by letters", ->
it "can't be delimited by letters or \\", ->
keydown(':')
submitNormalModeInputText(':substitute nanxngi')
expect(atom.notifications.notifications[0].message).toEqual(
"Command error: Regular expressions can't be delimited by letters")
expect(editor.getText()).toEqual('abcaABC\ndefdDEF\nabcaABC')
atom.commands.dispatch(editorElement, 'ex-mode:open')
submitNormalModeInputText(':substitute\\a\\x\\gi')
expect(atom.notifications.notifications[1].message).toEqual(
"Command error: Regular expressions can't be delimited by \\")
expect(editor.getText()).toEqual('abcaABC\ndefdDEF\nabcaABC')
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)
keydown(':')
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')
keydown(':')
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)
keydown(':')
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')
keydown(':')
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)
keydown(':')
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)
keydown(':')
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)
keydown(':')
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)
keydown(':')
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)
keydown(':')
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)
keydown(':')
submitNormalModeInputText(':substitute/ab\\Cc/ghi/gi')
expect(editor.getText()).toEqual('ghiaABC\ndefdDEF\nabcaABC')
describe "capturing groups", ->
beforeEach ->
editor.setText('abcaABC\ndefdDEF\nabcaABC')
@ -478,7 +622,7 @@ describe "the commands", ->
describe ":set", ->
it "throws an error without a specified option", ->
keydown(':')
submitNormalModeInputText(':set')
submitNormalModeInputText('set')
expect(atom.notifications.notifications[0].message).toEqual(
'Command error: No option specified')
@ -486,33 +630,47 @@ describe "the commands", ->
atom.config.set('editor.showInvisibles', false)
atom.config.set('editor.showLineNumbers', false)
keydown(':')
submitNormalModeInputText(':set list number')
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 options to false with no{option}", ->
atom.config.set('editor.showInvisibles', true)
keydown(':')
submitNormalModeInputText('set nolist')
expect(atom.config.get('editor.showInvisibles')).toBe(false)
it "sets (no)list", ->
it "inverts options with inv{option}", ->
atom.config.set('editor.showInvisibles', true)
keydown(':')
submitNormalModeInputText('set invlist')
expect(atom.config.get('editor.showInvisibles')).toBe(false)
atom.commands.dispatch(editorElement, 'ex-mode:open')
submitNormalModeInputText('set invlist')
expect(atom.config.get('editor.showInvisibles')).toBe(true)
it "inverts options with {option}!", ->
atom.config.set('editor.showInvisibles', true)
keydown(':')
submitNormalModeInputText('set list!')
expect(atom.config.get('editor.showInvisibles')).toBe(false)
atom.commands.dispatch(editorElement, 'ex-mode:open')
submitNormalModeInputText('set list!')
expect(atom.config.get('editor.showInvisibles')).toBe(true)
describe "the options", ->
it "sets list", ->
atom.config.set('editor.showInvisibles', false)
keydown(':')
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)", ->
it "sets nu[mber]", ->
atom.config.set('editor.showLineNumbers', false)
keydown(':')
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.config.set('editor.showLineNumbers', 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)