utils = require 'core/utils' defaults = autoLineEndings: # Mapping ace mode language to line endings to automatically insert # E.g. javascript: ";" {} basic: true snippetsLangDefaults: true liveCompletion: true language: 'javascript' languagePrefixes: 'this.,@,self.' completers: snippets: true # TODO: Should we be hooking in completers differently? # TODO: https://github.com/ajaxorg/ace/blob/f133231df8c1f39156cc230ce31e66103ef4b1e2/lib/ace/ext/language_tools.js#L202 # TODO: Should show popup if we have a snippet match in Autocomplete.filterCompletions # TODO: https://github.com/ajaxorg/ace/blob/695e24c41844c17fb2029f073d06338cd73ec33e/lib/ace/autocomplete.js#L449 # TODO: Create list of manual test cases module.exports = class Autocomplete Tokenizer = '' BackgroundTokenizer = '' constructor: (aceEditor, options) -> {Tokenizer} = ace.require 'ace/tokenizer' {BackgroundTokenizer} = ace.require 'ace/background_tokenizer' @editor = aceEditor config = ace.require 'ace/config' options ?= {} defaultsCopy = _.extend {}, defaults @options = _.merge defaultsCopy, options #TODO: Renable option validation if we care #validationResult = optionsValidator @options #unless validationResult.valid # throw new Error "Invalid Autocomplete options: " + JSON.stringify(validationResult.errors, null, 4) ace.config.loadModule 'ace/ext/language_tools', () => @snippetManager = ace.require('ace/snippets').snippetManager # Prevent tabbing a selection trigging an incorrect autocomplete # E.g. Given this.moveRight() selecting ".moveRight" from left to right and hitting tab yields this.this.moveRight()() # TODO: Figure out how to intercept this properly # TODO: Or, override expandSnippet command # TODO: Or, SnippetManager's expandSnippetForSelection @snippetManager.expandWithTab = -> return false # Define a background tokenizer that constantly tokenizes the code highlightRules = new (@editor.getSession().getMode().HighlightRules)() tokenizer = new Tokenizer highlightRules.getRules() @bgTokenizer = new BackgroundTokenizer tokenizer, @editor aceDocument = @editor.getSession().getDocument() @bgTokenizer.setDocument aceDocument @bgTokenizer.start(0) @setAceOptions() @copyCompleters() @activateCompleter() @editor.commands.on 'afterExec', @doLiveCompletion setAceOptions: () -> aceOptions = 'enableLiveAutocompletion': @options.liveCompletion 'enableBasicAutocompletion': @options.basic 'enableSnippets': @options.completers.snippets @editor.setOptions aceOptions @editor.completer?.autoSelect = true copyCompleters: () -> @completers = {snippets: {}, text: {}, keywords: {}} if @editor.completers? [@completers.snippets.comp, @completers.text.comp, @completers.keywords.comp] = @editor.completers if @options.completers.snippets @completers.snippets = pos: 0 # Replace the default snippet completer with our custom one @completers.snippets.comp = require('./snippets') @snippetManager, @options.autoLineEndings activateCompleter: (comp) -> if Array.isArray comp @editor.completers = comp else if typeof comp is 'string' if @completers[comp]? and @editor.completers[@completers[comp].pos] isnt @completers[comp].comp @editor.completers.splice(@completers[comp].pos, 0, @completers[comp].comp) else @editor.completers = [] for type, comparator of @completers if @options.completers[type] is true @activateCompleter type addSnippets: (snippets, language) -> @options.language = language ace.config.loadModule 'ace/ext/language_tools', () => @snippetManager = ace.require('ace/snippets').snippetManager snippetModulePath = 'ace/snippets/' + language ace.config.loadModule snippetModulePath, (m) => if m? @snippetManager.files[language] = m @snippetManager.unregister m.snippets if m.snippets?.length > 0 @snippetManager.unregister @oldSnippets if @oldSnippets? m.snippets = if @options.snippetsLangDefaults then @snippetManager.parseSnippetFile m.snippetText else [] m.snippets.push s for s in snippets @snippetManager.register m.snippets @oldSnippets = m.snippets setLiveCompletion: (val) -> if val is true or val is false @options.liveCompletion = val @setAceOptions() set: (setting, value) -> switch setting when 'snippets' or 'completers.snippets' return unless typeof value is 'boolean' @options.completers.snippets = value @setAceOptions() @activateCompleter 'snippets' when 'basic' return unless typeof value is 'boolean' @options.basic = value @setAceOptions() @activateCompleter() when 'liveCompletion' return unless typeof value is 'boolean' @options.liveCompletion = value @setAceOptions() @activateCompleter() when 'language' return unless typeof value is 'string' @options.language = value @setAceOptions() @activateCompleter() when 'completers.keywords' return unless typeof value is 'boolean' @options.completers.keywords = value @activateCompleter() when 'completers.text' return unless typeof value is 'boolean' @options.completers.text = value @activateCompleter() return on: -> @paused = false off: -> @paused = true doLiveCompletion: (e) => # console.log 'Autocomplete doLiveCompletion', e return unless @options.basic or @options.liveCompletion or @options.completers.snippets return if @paused TokenIterator = TokenIterator or ace.require('ace/token_iterator').TokenIterator editor = e.editor text = e.args or "" hasCompleter = editor.completer and editor.completer.activated # We don't want to autocomplete with no prefix if e.command.name is "backspace" or e.command.name is "insertstring" pos = editor.getCursorPosition() token = (new TokenIterator editor.getSession(), pos.row, pos.column).getCurrentToken() if token? and token.type not in ['comment', 'string'] prefix = @getCompletionPrefix editor # Bake a fresh autocomplete every keystroke editor.completer?.detach() if hasCompleter # Skip common single letter variable names return if /^x$|^y$/i.test(prefix) # Only autocomplete if there's a prefix that can be matched if (prefix) unless (editor.completer) # Create new autocompleter Autocomplete = ace.require('ace/autocomplete').Autocomplete # Overwrite "Shift-Return" to Esc + Return instead # https://github.com/ajaxorg/ace/blob/695e24c41844c17fb2029f073d06338cd73ec33e/lib/ace/autocomplete.js#L208 # TODO: Need a better way to update this command. This is super shady. # TODO: Shift-Return errors when Autocomplete is open, dying on this call: # TODO: calls editor.completer.insertMatch(true) in lib/ace/autocomplete.js if Autocomplete?.prototype?.commands? exitAndReturn = (editor) => # TODO: Execute a proper Return that selects the Autocomplete if open editor.completer.detach() @editor.insert "\n" Autocomplete.prototype.commands["Shift-Return"] = exitAndReturn editor.completer = new Autocomplete() # Disable autoInsert and show popup editor.completer.autoSelect = true editor.completer.autoInsert = false editor.completer.showPopup(editor) # Hide popup if too many suggestions # TODO: Completions aren't asked for unless we show popup, so this is super hacky # TODO: Backspacing to yield more suggestions does not close popup if editor.completer?.completions?.filtered?.length > 20 editor.completer.detach() # Update popup CSS after it's been launched # TODO: Popup has original CSS on first load, and then visibly/weirdly changes based on these updates # TODO: Find better way to extend popup. else if editor.completer.popup? $('.ace_autocomplete').find('.ace_content').css('cursor', 'pointer') $('.ace_autocomplete').css('font-size', @options.popupFontSizePx + 'px') if @options.popupFontSizePx? $('.ace_autocomplete').css('line-height', @options.popupLineHeightPx + 'px') if @options.popupLineHeightPx? $('.ace_autocomplete').css('width', @options.popupWidthPx + 'px') if @options.popupWidthPx? editor.completer.popup.resize?() # TODO: Can't change padding before resize(), but changing it afterwards clears new padding # TODO: Figure out how to hook into events rather than using setTimeout() # fixStuff = => # $('.ace_autocomplete').find('.ace_line').css('color', 'purple') # $('.ace_autocomplete').find('.ace_line').css('padding', '20px') # # editor.completer.popup.resize?(true) # setTimeout fixStuff, 1000 # Update tokens for text completer if @options.completers.text and e.command.name in ['backspace', 'del', 'insertstring', 'removetolinestart', 'Enter', 'Return', 'Space', 'Tab'] @bgTokenizer.fireUpdateEvent 0, @editor.getSession().getLength() getCompletionPrefix: (editor) -> # TODO: this is not used to get prefix that is passed to completer.getCompletions # TODO: Autocomplete.gatherCompletions is using this (no regex 3rd param): # TODO: var prefix = util.retrievePrecedingIdentifier(line, pos.column); util = util or ace.require 'ace/autocomplete/util' pos = editor.getCursorPosition() line = editor.session.getLine pos.row prefix = null editor.completers?.forEach (completer) -> if completer?.identifierRegexps completer.identifierRegexps.forEach (identifierRegex) -> if not prefix and identifierRegex prefix = util.retrievePrecedingIdentifier line, pos.column, identifierRegex prefix = util.retrievePrecedingIdentifier line, pos.column unless prefix? prefix addCodeCombatSnippets: (level, spellView, e) -> snippetEntries = [] source = spellView.getSource() haveFindNearestEnemy = false haveFindNearest = false for group, props of e.propGroups for prop in props if _.isString prop # organizePalette owner = group else # organizePaletteHero owner = prop.owner prop = prop.prop doc = _.find (e.allDocs['__' + prop] ? []), (doc) -> return true if doc.owner is owner return (owner is 'this' or owner is 'more') and (not doc.owner? or doc.owner is 'this') if doc?.snippets?[e.language] name = doc.name content = doc.snippets[e.language].code if /loop/.test(content) and level.get 'moveRightLoopSnippet' # Replace default loop snippet with an embedded moveRight() content = switch e.language when 'python' then 'loop:\n self.moveRight()\n ${1:}' when 'javascript' then 'loop {\n this.moveRight();\n ${1:}\n}' else content if /loop/.test(content) and level.isType('course', 'course-ladder') # Temporary hackery to make it look like we meant while True: in our loop snippets until we can update everything content = switch e.language when 'python' then content.replace /loop:/, 'while True:' when 'javascript' then content.replace /loop/, 'while (true)' when 'lua' then content.replace /loop/, 'while true then' when 'coffeescript' then content else content name = switch e.language when 'python' then 'while True' when 'coffeescript' then 'loop' else 'while true' # For now, update autocomplete to use hero instead of self/this, if hero is already used in the source. # Later, we should make this happen all the time - or better yet update the snippets. if /hero/.test(source) or not /(self[\.\:]|this\.|\@)/.test(source) thisToken = 'python': /self/, 'javascript': /this/, 'lua': /self/ if thisToken[e.language] and thisToken[e.language].test(content) content = content.replace thisToken[e.language], 'hero' entry = content: content meta: $.i18n.t('keyboard_shortcuts.press_enter', defaultValue: 'press enter') name: name tabTrigger: doc.snippets[e.language].tab importance: doc.autoCompletePriority ? 1.0 haveFindNearestEnemy ||= name is 'findNearestEnemy' haveFindNearest ||= name is 'findNearest' if name is 'attack' # Postpone this until we know if findNearestEnemy is available attackEntry = entry else snippetEntries.push entry if doc.userShouldCaptureReturn varName = doc.userShouldCaptureReturn.variableName ? 'result' entry.captureReturn = switch e.language when 'javascript' then 'var ' + varName + ' = ' #when 'lua' then 'local ' + varName + ' = ' # TODO: should we do this? else varName + ' = ' # TODO: Generalize this snippet replacement # TODO: Where should this logic live, and what format should it be in? if attackEntry? unless haveFindNearestEnemy or haveFindNearest or level.get('slug') in ['known-enemy', 'course-known-enemy'] # No findNearestEnemy, so update attack snippet to string-based target # (On Known Enemy, we are introducing enemy2 = "Gert", so we want them to do attack(enemy2).) attackEntry.content = attackEntry.content.replace '${1:enemy}', '"${1:Enemy Name}"' snippetEntries.push attackEntry # Update 'hero.' and 'game.' entries to include their prefixes for entry in snippetEntries if entry.content?.indexOf('hero.') is 0 and entry.name?.indexOf('hero.') < 0 entry.name = "hero.#{entry.name}" else if entry.content?.indexOf('game.') is 0 and entry.name?.indexOf('game.') < 0 entry.name = "game.#{entry.name}" if haveFindNearest and not haveFindNearestEnemy spellView.translateFindNearest() # window.AutocompleteInstance = @Autocomplete # For debugging. Make sure to not leave active when committing. # window.snippetEntries = snippetEntries lang = utils.aceEditModes[e.language].substr 'ace/mode/'.length @addSnippets snippetEntries, lang spellView.editorLang = lang